From 5b938ff90e3f984d1ea2bf8a3d439056202cdf12 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Tue, 29 Sep 2026 15:30:05 -0700 Subject: [PATCH 1/2] Add style color picker detectors --- {styles/all/theme => adm/style}/acp.css | 10 ++ adm/style/acp.js | 151 ++++++++++++++++++ adm/style/acp_pwakit.html | 10 ++ event/main_listener.php | 1 - language/en/acp_pwa.php | 4 + styles/all/template/acp.js | 37 ----- .../event/overall_header_head_append.html | 4 +- tests/unit/event_listener_test.php | 1 - 8 files changed, 176 insertions(+), 42 deletions(-) rename {styles/all/theme => adm/style}/acp.css (91%) create mode 100644 adm/style/acp.js delete mode 100644 styles/all/template/acp.js diff --git a/styles/all/theme/acp.css b/adm/style/acp.css similarity index 91% rename from styles/all/theme/acp.css rename to adm/style/acp.css index 61d5f5f..26cf666 100644 --- a/styles/all/theme/acp.css +++ b/adm/style/acp.css @@ -91,3 +91,13 @@ input[type="color"]::-moz-color-swatch { display: inline-block; width: 120px; } + +.pwa-colour-preview { + position: fixed; + left: -10000px; + top: 0; + visibility: hidden; + width: 1280px; + height: 800px; + border: 0; +} diff --git a/adm/style/acp.js b/adm/style/acp.js new file mode 100644 index 0000000..1237da6 --- /dev/null +++ b/adm/style/acp.js @@ -0,0 +1,151 @@ +document.addEventListener('DOMContentLoaded', () => { + 'use strict'; + + const HEX_REGEX = /^#([A-Fa-f0-9]{6})$/; + const DETECTION_TIMEOUT = 30000; + + const colorPickers = document.querySelectorAll('input[type="color"]'); + + colorPickers.forEach(colorPicker => { + const colorText = colorPicker.previousElementSibling; + + if (!colorText || colorText.type !== 'text') { + return; + } + + const syncColors = (source, target) => { + const value = source.value.trim(); + target.value = HEX_REGEX.test(value) ? value : colorText.placeholder; + }; + + const handleInput = ({ target }) => { + if (target === colorPicker) { + colorText.value = target.value; + } else { + syncColors(colorText, colorPicker); + } + }; + + colorPicker.addEventListener('input', handleInput); + colorText.addEventListener('input', handleInput); + colorText.addEventListener('blur', () => { + if (!colorText.value.trim()) { + colorPicker.value = colorText.placeholder; + } + }); + + syncColors(colorText, colorPicker); + }); + + // Read an element's computed background colour as RGBA values. + const readColor = (view, element) => { + const value = view.getComputedStyle(element).backgroundColor; + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + + canvas.width = 1; + canvas.height = 1; + context.clearRect(0, 0, 1, 1); + context.fillStyle = value; + context.fillRect(0, 0, 1, 1); + + return Array.from(context.getImageData(0, 0, 1, 1).data); + }; + + // Blend a foreground colour over a background colour. + const compositeColor = (foreground, background) => { + const alpha = foreground[3] / 255; + + return [ + Math.round(foreground[0] * alpha + background[0] * (1 - alpha)), + Math.round(foreground[1] * alpha + background[1] * (1 - alpha)), + Math.round(foreground[2] * alpha + background[2] * (1 - alpha)), + 255, + ]; + }; + + // Resolve the visible page colour from the preview's HTML and body backgrounds. + const detectPageColor = iframe => { + const previewDocument = iframe.contentDocument; + const previewWindow = iframe.contentWindow; + const htmlColor = readColor(previewWindow, previewDocument.documentElement); + const bodyColor = readColor(previewWindow, previewDocument.body); + + if (htmlColor[3] === 0 && bodyColor[3] === 0) { + return null; + } + + const canvasColor = compositeColor(htmlColor, [ 255, 255, 255, 255 ]); + return compositeColor(bodyColor, canvasColor); + }; + + // Convert RGB values to a six-digit hexadecimal colour. + const toHex = color => '#' + color.slice(0, 3) + .map(channel => channel.toString(16).padStart(2, '0')) + .join(''); + + // Update a style's text field and synchronize its colour picker. + const setColor = (styleId, name, value) => { + const colorText = document.getElementById(`pwa_${name}_color_${styleId}`); + + colorText.value = value; + colorText.dispatchEvent(new Event('input', { bubbles: true })); + }; + + // Attach colour detection to each installed style's button. + document.querySelectorAll('.pwa-detect-colours').forEach(button => { + button.addEventListener('click', () => { + const originalLabel = button.value; + const status = button.closest('dd').querySelector('.pwa-detect-status'); + const iframe = document.createElement('iframe'); + const previewUrl = new URL(button.dataset.previewUrl, window.location.href); + let finished = false; + + // Remove the preview and restore the detector button after completion. + const finish = error => { + if (finished) { + return; + } + + finished = true; + clearTimeout(timeout); + iframe.remove(); + button.disabled = false; + button.value = originalLabel; + status.textContent = error ? button.dataset.errorMessage : ''; + }; + + const timeout = setTimeout(() => finish(true), DETECTION_TIMEOUT); + + button.disabled = true; + button.value = button.dataset.detectingLabel; + status.textContent = ''; + status.title = ''; + iframe.className = 'pwa-colour-preview'; + iframe.setAttribute('aria-hidden', 'true'); + iframe.setAttribute('sandbox', 'allow-same-origin'); + iframe.addEventListener('load', () => { + try { + const themeColor = detectPageColor(iframe); + + if (!themeColor) { + throw new Error(button.dataset.noBackgroundMessage); + } + + const pageColor = toHex(themeColor); + + setColor(button.dataset.styleId, 'theme', pageColor); + setColor(button.dataset.styleId, 'bg', pageColor); + finish(false); + } catch (error) { + status.title = error.message; + finish(true); + } + }); + iframe.addEventListener('error', () => finish(true)); + previewUrl.searchParams.set('style', button.dataset.styleId); + iframe.src = previewUrl.href; + document.body.appendChild(iframe); + }); + }); +}); diff --git a/adm/style/acp_pwakit.html b/adm/style/acp_pwakit.html index 896ae29..365728a 100644 --- a/adm/style/acp_pwakit.html +++ b/adm/style/acp_pwakit.html @@ -51,6 +51,16 @@

{{ lang('ACP_PWA_COLORS') }}

+ + {% endfor %} diff --git a/event/main_listener.php b/event/main_listener.php index 9e7ac7c..0c4c4ac 100644 --- a/event/main_listener.php +++ b/event/main_listener.php @@ -61,7 +61,6 @@ public function header_updates(): void { $this->template->assign_vars([ 'PWA_THEME_COLOR' => $this->user->style['pwa_theme_color'], - 'PWA_BG_COLOR' => $this->user->style['pwa_bg_color'], 'U_TOUCH_ICONS' => array_column($this->pwa_helper->get_icons(), 'src'), ]); } diff --git a/language/en/acp_pwa.php b/language/en/acp_pwa.php index 8f20f39..c07fe04 100644 --- a/language/en/acp_pwa.php +++ b/language/en/acp_pwa.php @@ -49,6 +49,10 @@ 'ACP_PWA_COLORS_EXPLAIN' => 'Used to specify the default colour for your web application’s user interface. Theme colours may be applied to various browser UI elements, such as the toolbar, address bar, and status bar. Background colours appears in the application window before your application’s stylesheets have loaded.', 'ACP_PWA_THEME_COLOR' => 'Theme colour', 'ACP_PWA_BG_COLOR' => 'Background colour', + 'ACP_PWA_DETECT_COLOURS' => 'Detect colours', + 'ACP_PWA_DETECTING_COLOURS' => 'Detecting…', + 'ACP_PWA_DETECT_COLOURS_FAILED' => 'Could not detect this style’s background colour.', + 'ACP_PWA_DETECT_COLOURS_NO_BG' => 'The style has no HTML or body background colour.', 'ACP_PWA_INVALID_COLOR' => 'The colour code “%s” is not a valid hex code.', 'ACP_PWA_KIT_APP_ICONS' => 'Web app icons (optional)', 'ACP_PWA_KIT_APP_ICONS_EXPLAIN' => 'Used to specify the touch-icon for your web application. This icon will be used when your web application is added to the home screen. Multiple sizes are preferred for compatibility with various devices. Note that when adding or removing icons, you may need to clear your web app/browser cache to see them update.', diff --git a/styles/all/template/acp.js b/styles/all/template/acp.js deleted file mode 100644 index fc28f27..0000000 --- a/styles/all/template/acp.js +++ /dev/null @@ -1,37 +0,0 @@ -document.addEventListener('DOMContentLoaded', () => { - const DEFAULT_COLOR = '#000000'; - const HEX_REGEX = /^#([A-Fa-f0-9]{6})$/; - - const colorPickers = document.querySelectorAll('input[type="color"]'); - - colorPickers.forEach(colorPicker => { - const colorText = colorPicker.previousElementSibling; - - if (!colorText || colorText.type !== 'text') { - return; - } - - const syncColors = (source, target) => { - const value = source.value.trim(); - target.value = HEX_REGEX.test(value) ? value : DEFAULT_COLOR; - }; - - const handleInput = ({ target }) => { - if (target === colorPicker) { - colorText.value = target.value; - } else { - syncColors(colorText, colorPicker); - } - }; - - colorPicker.addEventListener('input', handleInput); - colorText.addEventListener('input', handleInput); - colorText.addEventListener('blur', () => { - if (!colorText.value.trim()) { - colorPicker.value = DEFAULT_COLOR; - } - }); - - syncColors(colorText, colorPicker); - }); -}); diff --git a/styles/all/template/event/overall_header_head_append.html b/styles/all/template/event/overall_header_head_append.html index aa569f4..5c314ae 100644 --- a/styles/all/template/event/overall_header_head_append.html +++ b/styles/all/template/event/overall_header_head_append.html @@ -7,7 +7,5 @@ {% if PWA_THEME_COLOR -%} + {% endif %} -{% if PWA_BG_COLOR -%} - -{%- endif %} diff --git a/tests/unit/event_listener_test.php b/tests/unit/event_listener_test.php index 7d01b5c..e9c2153 100644 --- a/tests/unit/event_listener_test.php +++ b/tests/unit/event_listener_test.php @@ -161,7 +161,6 @@ public function test_header_updates($configs, $icons, $expected): void $templateVars = [ 'PWA_THEME_COLOR' => $expected['pwa_theme_color'], - 'PWA_BG_COLOR' => $expected['pwa_bg_color'], 'U_TOUCH_ICONS' => $expected['icons'], ]; From 688412a26abeb09ca71db512f2073da83da711f1 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Tue, 29 Sep 2026 15:31:35 -0700 Subject: [PATCH 2/2] Fix icons conditional Mass refactor, strengthen png file handling, better consistency --- README.md | 28 ++++++- acp/pwa_acp_info.php | 3 +- adm/style/acp_pwakit.html | 22 ++--- controller/admin_controller.php | 12 +-- event/main_listener.php | 55 +++++++++++-- ext.php | 9 ++- helper/helper.php | 65 ++++++++------- language/en/acp_pwa.php | 24 +++--- language/en/info_acp_pwa.php | 2 +- migrations/m1_initial.php | 7 +- migrations/m2_data.php | 13 ++- migrations/m3_storage.php | 15 +++- storage/file_tracker.php | 28 ++++++- .../event/overall_header_head_append.html | 5 +- tests/functional/acp_file_test.php | 2 +- tests/functional/acp_settings_test.php | 18 ++++- tests/unit/acp_module_test.php | 3 +- tests/unit/admin_controller_test.php | 17 +++- tests/unit/event_listener_test.php | 69 ++++++++++++++-- tests/unit/ext_test.php | 30 +++++-- tests/unit/file_tracker_test.php | 80 +++++++++++++++++++ tests/unit/helper_test.php | 17 ++++ tests/unit/upload_test.php | 4 +- 23 files changed, 418 insertions(+), 110 deletions(-) create mode 100644 tests/unit/file_tracker_test.php diff --git a/README.md b/README.md index 90f296c..d69512f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,32 @@ # Progressive Web App Kit extension for phpBB -Under development... +Progressive Web App Kit complements phpBB 4's native Web Push and web-app +manifest support. It carries forward the PWA presentation features previously +provided by the phpBB 3.3 Browser Push Notifications extension without +duplicating phpBB 4's notification delivery, subscription, or service-worker +implementation. -Allows phpBB board admins to manage web app icons and colour themes for their site. +The extension provides: + +- Per-style theme and background colours. +- Managed upload, storage, discovery, and removal of PNG app/touch icons. +- Manifest icons and Apple touch-icon links. +- Automatic discovery of existing `images/site_icons` files. + +Settings are available under **General → Client communication → PWA settings**, +next to phpBB 4's native Web Push settings. + +## Transitioning from phpBB 3.3 + +After upgrading to phpBB 4, disable Browser Push Notifications and select +**Delete data** before installing PWA Kit. Removing the old extension performs +its Web Push subscription and VAPID-key handoff to phpBB 4 core. PWA Kit will +not enable while Browser Push Notifications is still configured. + +The old PWA short name and per-style colours are removed with the old +extension's data and must be configured again. Existing files in +`images/site_icons` are retained by the old extension and PWA Kit discovers +them automatically. [![Build Status](https://github.com/phpbb-extensions/pwakit/workflows/Tests/badge.svg)](https://github.com/phpbb-extensions/pwakit/actions) [![codecov](https://codecov.io/gh/phpbb-extensions/pwakit/graph/badge.svg?token=34V2MQSY3H)](https://codecov.io/gh/phpbb-extensions/pwakit) diff --git a/acp/pwa_acp_info.php b/acp/pwa_acp_info.php index f1fc41c..c472783 100644 --- a/acp/pwa_acp_info.php +++ b/acp/pwa_acp_info.php @@ -21,7 +21,8 @@ public function module(): array 'settings' => [ 'title' => 'ACP_PWA_KIT_SETTINGS', 'auth' => 'ext_phpbb/pwakit && acl_a_board', - 'cat' => ['ACP_PWA_KIT_TITLE'] + 'cat' => ['ACP_CLIENT_COMMUNICATION'], + 'after' => 'ACP_WEBPUSH_SETTINGS', ], ], ]; diff --git a/adm/style/acp_pwakit.html b/adm/style/acp_pwakit.html index 365728a..0dd6a73 100644 --- a/adm/style/acp_pwakit.html +++ b/adm/style/acp_pwakit.html @@ -5,7 +5,7 @@

{{ lang('ACP_PWA_KIT_TITLE') }}

-

{{ lang('ACP_PWA_KIT_EXPLAIN', PWA_IMAGES_DIR) }}

+

{{ lang('ACP_PWA_EXPLAIN') }}

{% if S_ERROR %}
@@ -16,13 +16,13 @@

{{ lang('WARNING') }}

- {{ lang('ACP_PWA_KIT_LEGEND_IDENTITY') }} + {{ lang('ACP_PWA_LEGEND_IDENTITY') }}
-

{{ lang('ACP_PWA_KIT_SITE_NAME_EXPLAIN', U_BOARD_SETTINGS) }}
+

{{ lang('ACP_PWA_SITE_NAME_EXPLAIN', U_BOARD_SETTINGS) }}
{{ SITE_NAME }}
-

{{ lang('ACP_PWA_KIT_SHORT_NAME_EXPLAIN', U_BOARD_SETTINGS) }}
+

{{ lang('ACP_PWA_SHORT_NAME_EXPLAIN', U_BOARD_SETTINGS) }}
{{ SITE_NAME_SHORT }}
@@ -30,7 +30,7 @@

{{ lang('WARNING') }}

{{ lang('ACP_PWA_COLORS') }}

{{ lang('ACP_PWA_COLORS_EXPLAIN') }}

- {{ lang('ACP_PWA_KIT_LEGEND_PRESENTS') }} + {{ lang('ACP_PWA_LEGEND_PRESENTS') }}
@@ -75,20 +75,20 @@

{{ lang('ACP_PWA_COLORS') }}

{{ S_FORM_TOKEN }}
-

{{ lang('ACP_PWA_KIT_APP_ICONS') }}

-

{{ lang('ACP_PWA_KIT_APP_ICONS_EXPLAIN') }}

+

{{ lang('ACP_PWA_APP_ICONS') }}

+

{{ lang('ACP_PWA_APP_ICONS_EXPLAIN') }}

- {{ lang('ACP_PWA_KIT_LEGEND_ICONS') }} + {{ lang('ACP_PWA_LEGEND_ICONS') }}

{{ lang('ACP_PWA_IMG_UPLOAD_EXPLAIN', PWA_IMAGES_DIR, U_STORAGE_SETTINGS) }}
- +
-

{{ lang('ACP_PWA_KIT_ICONS_EXPLAIN') }}
+

{{ lang('ACP_PWA_ICONS_EXPLAIN') }}
{% for icon in PWA_KIT_ICONS %} {% set iconName = icon.src|split('/')|last %} @@ -105,7 +105,7 @@

{{ lang('ACP_PWA_KIT_APP_ICONS') }}

{{ iconName }}
{{ icon.sizes }}

{% else %} - {{ lang('ACP_PWA_KIT_NO_ICONS') }} + {{ lang('ACP_PWA_NO_ICONS') }} {% endfor %}
diff --git a/controller/admin_controller.php b/controller/admin_controller.php index 534676f..77557db 100644 --- a/controller/admin_controller.php +++ b/controller/admin_controller.php @@ -183,11 +183,13 @@ protected function execute_action(string $action): void */ protected function display_settings(): void { + $icons = $this->helper->get_icons($this->phpbb_root_path); + $this->template->assign_vars([ 'SITE_NAME' => $this->config->offsetGet('sitename'), 'SITE_NAME_SHORT' => $this->config->offsetGet('sitename_short') ?: $this->trim_name($this->config->offsetGet('sitename'), 0, 12), 'PWA_IMAGES_DIR' => $this->helper->get_storage_path(), - 'PWA_KIT_ICONS' => $this->helper->get_icons($this->phpbb_root_path), + 'PWA_KIT_ICONS' => $icons, 'STYLES' => $this->get_styles(), 'U_BOARD_SETTINGS' => append_sid("{$this->phpbb_admin_path}index.$this->php_ext", 'i=acp_board&mode=settings'), 'U_STORAGE_SETTINGS'=> append_sid("{$this->phpbb_admin_path}index.$this->php_ext", 'i=acp_storage&mode=settings'), @@ -210,8 +212,8 @@ protected function save_settings(): void foreach ($styles as $row) { $style_id = $row['style_id']; - $pwa_bg_color = $this->request->variable('pwa_bg_color_' . $style_id, ''); - $pwa_theme_color = $this->request->variable('pwa_theme_color_' . $style_id, ''); + $pwa_bg_color = trim($this->request->variable('pwa_bg_color_' . $style_id, '')); + $pwa_theme_color = trim($this->request->variable('pwa_theme_color_' . $style_id, '')); $updates[$style_id] = [ 'pwa_bg_color' => $this->validate_hex_color($pwa_bg_color) ? $pwa_bg_color : $row['pwa_bg_color'], @@ -219,13 +221,13 @@ protected function save_settings(): void ]; } - $this->set_styles($updates); - if ($this->has_errors()) { return; } + $this->set_styles($updates); + $this->success('CONFIG_UPDATED'); } diff --git a/event/main_listener.php b/event/main_listener.php index 0c4c4ac..30d1a2f 100644 --- a/event/main_listener.php +++ b/event/main_listener.php @@ -15,6 +15,8 @@ use phpbb\template\template; use phpbb\user; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\Event\ResponseEvent; +use Symfony\Component\HttpKernel\KernelEvents; class main_listener implements EventSubscriberInterface { @@ -49,6 +51,7 @@ public static function getSubscribedEvents(): array return [ 'core.page_header' => 'header_updates', 'core.modify_manifest' => 'manifest_updates', + KernelEvents::RESPONSE => 'manifest_response', ]; } @@ -60,8 +63,8 @@ public static function getSubscribedEvents(): array public function header_updates(): void { $this->template->assign_vars([ - 'PWA_THEME_COLOR' => $this->user->style['pwa_theme_color'], - 'U_TOUCH_ICONS' => array_column($this->pwa_helper->get_icons(), 'src'), + 'PWA_THEME_COLOR' => $this->get_style_color('pwa_theme_color'), + 'U_TOUCH_ICONS' => $this->pwa_helper->get_icons(), ]); } @@ -78,17 +81,20 @@ public function manifest_updates(data $event): void // TODO This may need to be removed if manifest goes stateless (no user/session) // Add theme and background colors if configured - if (!empty($this->user->style['pwa_theme_color'])) + $theme_color = $this->get_style_color('pwa_theme_color'); + if ($theme_color !== '') { - $manifest_updates['theme_color'] = $this->user->style['pwa_theme_color']; + $manifest_updates['theme_color'] = $theme_color; } - if (!empty($this->user->style['pwa_bg_color'])) + + $background_color = $this->get_style_color('pwa_bg_color'); + if ($background_color !== '') { - $manifest_updates['background_color'] = $this->user->style['pwa_bg_color']; + $manifest_updates['background_color'] = $background_color; } // Add icons if available - if (!empty($icons = $this->pwa_helper->get_icons($event['board_path']))) + if (!empty($icons = $this->pwa_helper->get_icons($event['scope']))) { $manifest_updates['icons'] = $icons; } @@ -102,4 +108,39 @@ public function manifest_updates(data $event): void } } } + + /** + * Use the registered manifest media type and prevent shared caches from + * serving one user's style colours to users of another style. + * + * @param ResponseEvent $event + * @return void + */ + public function manifest_response(ResponseEvent $event): void + { + if ($event->getRequest()->attributes->get('_route') !== 'phpbb_manifest_controller') + { + return; + } + + $response = $event->getResponse(); + $response->headers->set('Content-Type', 'application/manifest+json'); + $response->setPrivate(); + $response->setVary('Cookie', false); + } + + /** + * Return a style colour only when it is safe and valid for HTML and manifest output. + * + * @param string $key Style data key + * @return string + */ + private function get_style_color(string $key): string + { + $color = $this->user->style[$key] ?? ''; + + return is_string($color) && preg_match('/^#(?:[0-9a-f]{3}){1,2}$/i', $color) === 1 + ? $color + : ''; + } } diff --git a/ext.php b/ext.php index 97343f8..1d28cb3 100644 --- a/ext.php +++ b/ext.php @@ -20,6 +20,7 @@ class ext extends base { public const PWA_ICON_DIR = 'images/site_icons'; public const PHPBB_MIN_VERSION = '4.0.0-dev'; + public const LEGACY_EXTENSION = 'phpbb/webpushnotifications'; /** * {@inheritdoc} @@ -27,7 +28,13 @@ class ext extends base public function is_enableable(): array|bool { $config = $this->container->get('config'); - return $this->version_check($config['version']) && $this->version_check(PHPBB_VERSION); + if (!$this->version_check($config['version']) || !$this->version_check(PHPBB_VERSION)) + { + return false; + } + + $extension_manager = $this->container->get('ext.manager'); + return !$extension_manager->is_configured(self::LEGACY_EXTENSION); } /** diff --git a/helper/helper.php b/helper/helper.php index 11c719c..0d16e3e 100644 --- a/helper/helper.php +++ b/helper/helper.php @@ -90,8 +90,9 @@ public function resync_icons(): void $path = $this->get_storage_path() . '/'; $full_base_path = $this->root_path . $path; - // Create a single reusable callback function - $remove_path = static fn($file) => str_replace($path, '', $file); + $remove_path = static fn($file) => str_starts_with($file, $path) + ? substr($file, strlen($path)) + : $file; // Get and process both arrays using the same callback $untracked_files = array_map($remove_path, $this->get_images()); @@ -101,11 +102,10 @@ public function resync_icons(): void $files_to_track = array_diff($untracked_files, $tracked_files); $files_to_untrack = array_diff($tracked_files, $untracked_files); - // Prepare batch tracking array with array_map instead of foreach $files = !empty($files_to_track) ? array_map( static fn($file) => [ 'file_path' => $file, - 'filesize' => filesize($full_base_path . $file) + 'filesize' => (int) filesize($full_base_path . $file), ], $files_to_track ) : []; @@ -138,20 +138,30 @@ public function delete_icon(string $path): string throw new runtime_exception('ACP_PWA_IMG_DELETE_PATH_ERR'); } - // Remove any directory traversal attempts + // Convert a displayed storage path back to its tracked, storage-relative path. $storage_path = $this->get_storage_path() . '/'; - $pos = strpos($path, $storage_path); - if ($pos !== false) + $path = str_replace('\\', '/', $path); + $storage_position = strpos($path, $storage_path); + if ($storage_position !== false) { - $path = substr($path, $pos + strlen($storage_path)); + $display_prefix = substr($path, 0, $storage_position); + if (!preg_match('#^(?:\./|\.\./)*$#D', $display_prefix)) + { + throw new runtime_exception('ACP_PWA_IMG_DELETE_PATH_ERR'); + } + + $path = substr($path, $storage_position + strlen($storage_path)); } - else + + $segments = explode('/', $path); + if (str_starts_with($path, '/') || in_array('', $segments, true) + || in_array('.', $segments, true) || in_array('..', $segments, true)) { - $path = basename($path); + throw new runtime_exception('ACP_PWA_IMG_DELETE_PATH_ERR'); } // Check for valid filename characters - if (!preg_match('#^[a-zA-Z0-9_\-./]+$#', $path)) + if (!preg_match('#^[a-zA-Z0-9_./-]+$#D', $path)) { throw new runtime_exception('ACP_PWA_IMG_DELETE_NAME_ERR'); } @@ -181,7 +191,7 @@ protected function get_stored_images(): array $result = []; foreach ($images as $image) { - if (stripos(strrev($image), 'gnp.') === 0) + if (str_ends_with(strtolower($image), '.png')) { $result[] = $path . '/' . $image; } @@ -197,17 +207,12 @@ protected function get_stored_images(): array */ protected function get_images(): array { - static $images = null; - - if ($images === null) - { - $finder = $this->extension_manager->get_finder(); - $images = $finder - ->set_extensions([]) - ->suffix('.png') - ->core_path($this->get_storage_path() . '/') - ->find(); - } + $finder = $this->extension_manager->get_finder(); + $images = $finder + ->set_extensions([]) + ->suffix('.png') + ->core_path($this->get_storage_path() . '/') + ->find(); return array_keys($images); } @@ -221,23 +226,23 @@ protected function get_images(): array */ private function prepare_icons(array $images, string $use_path): array { - // Use array_reduce instead of foreach for better performance - return array_reduce($images, function ($carry, $image) use ($use_path) + $icons = []; + foreach ($images as $image) { $image_info = $this->imagesize->getImageSize($this->root_path . $image); - if ($image_info === false) + if ($image_info === false || $image_info['type'] !== IMAGETYPE_PNG) { - return $carry; + continue; } - $carry[] = [ + $icons[] = [ 'src' => $use_path ? $use_path . $image : $image, 'sizes' => $image_info['width'] . 'x' . $image_info['height'], 'type' => 'image/png' ]; + } - return $carry; - }, []); + return $icons; } } diff --git a/language/en/acp_pwa.php b/language/en/acp_pwa.php index c07fe04..178ff8c 100644 --- a/language/en/acp_pwa.php +++ b/language/en/acp_pwa.php @@ -39,14 +39,14 @@ // $lang = array_merge($lang, [ - 'ACP_PWA_KIT_EXPLAIN' => 'Here you can manage the appearance and branding of your Web Application as it will appear on mobile devices with custom app icons and UI colors.', - 'ACP_PWA_KIT_SITE_NAME_EXPLAIN' => 'Used to specify the full name of your web application. This can be configured in General » Board settings.', - 'ACP_PWA_KIT_SHORT_NAME_EXPLAIN'=> 'Used to specify a short name for your web application, which may be used when the full name is too long for the available space. This can be configured in General » Board settings.', - 'ACP_PWA_KIT_LEGEND_IDENTITY' => 'Identity', - 'ACP_PWA_KIT_LEGEND_PRESENTS' => 'Presentation', - 'ACP_PWA_KIT_LEGEND_ICONS' => 'Icons', + 'ACP_PWA_EXPLAIN' => 'Here you can manage the appearance and branding of your web application as it will appear on mobile devices with custom app icons and interface colours.', + 'ACP_PWA_SITE_NAME_EXPLAIN' => 'Used to specify the full name of your web application. This can be configured in General » Board settings.', + 'ACP_PWA_SHORT_NAME_EXPLAIN' => 'Used to specify a short name for your web application, which may be used when the full name is too long for the available space. This can be configured in General » Board settings.', + 'ACP_PWA_LEGEND_IDENTITY' => 'Identity', + 'ACP_PWA_LEGEND_PRESENTS' => 'Presentation', + 'ACP_PWA_LEGEND_ICONS' => 'Icons', 'ACP_PWA_COLORS' => 'Theme & background colours (optional)', - 'ACP_PWA_COLORS_EXPLAIN' => 'Used to specify the default colour for your web application’s user interface. Theme colours may be applied to various browser UI elements, such as the toolbar, address bar, and status bar. Background colours appears in the application window before your application’s stylesheets have loaded.', + 'ACP_PWA_COLORS_EXPLAIN' => 'The theme colour may be applied to browser interface elements such as the toolbar, address bar, and status bar. The background colour appears before the application’s stylesheets load and should match the style’s page background.', 'ACP_PWA_THEME_COLOR' => 'Theme colour', 'ACP_PWA_BG_COLOR' => 'Background colour', 'ACP_PWA_DETECT_COLOURS' => 'Detect colours', @@ -54,11 +54,11 @@ 'ACP_PWA_DETECT_COLOURS_FAILED' => 'Could not detect this style’s background colour.', 'ACP_PWA_DETECT_COLOURS_NO_BG' => 'The style has no HTML or body background colour.', 'ACP_PWA_INVALID_COLOR' => 'The colour code “%s” is not a valid hex code.', - 'ACP_PWA_KIT_APP_ICONS' => 'Web app icons (optional)', - 'ACP_PWA_KIT_APP_ICONS_EXPLAIN' => 'Used to specify the touch-icon for your web application. This icon will be used when your web application is added to the home screen. Multiple sizes are preferred for compatibility with various devices. Note that when adding or removing icons, you may need to clear your web app/browser cache to see them update.', - 'ACP_PWA_KIT_ICONS' => 'Web application icons', - 'ACP_PWA_KIT_ICONS_EXPLAIN' => 'PNG image files that represent your web application. Multiple sizes are preferred for compatibility with various devices.', - 'ACP_PWA_KIT_NO_ICONS' => 'No icons are available. Click Upload to add new icons or click Resync to find existing icons that were previously uploaded. Recommended PNG sizes include 180x180, 192x192 and 512x512.', + 'ACP_PWA_APP_ICONS' => 'Web app icons (optional)', + 'ACP_PWA_APP_ICONS_EXPLAIN' => 'Used to specify icons for your web application and Apple home-screen links. Multiple sizes improve compatibility with different devices. After adding or removing icons, you may need to clear the installed app or browser cache to see the changes.', + 'ACP_PWA_ICONS' => 'Web application icons', + 'ACP_PWA_ICONS_EXPLAIN' => 'PNG image files that represent your web application. Multiple sizes are preferred for compatibility with various devices.

For Chromium-based browsers, upload square 192x192 and 512x512 PNG icons. A 180×180 icon is also recommended for Apple devices.', + 'ACP_PWA_NO_ICONS' => 'No icons are available. Click Upload to add icons or Resync to discover existing files.', 'ACP_PWA_IMG_UPLOAD' => 'Upload web application icons', 'ACP_PWA_IMG_UPLOAD_EXPLAIN' => 'Upload PNG images. Images are currently being stored in %1$s. This can be changed at any time to another location in General » Storage settings.', 'ACP_PWA_IMG_UPLOAD_SUCCESS' => 'Image uploaded successfully.', diff --git a/language/en/info_acp_pwa.php b/language/en/info_acp_pwa.php index b82ddf8..263036e 100644 --- a/language/en/info_acp_pwa.php +++ b/language/en/info_acp_pwa.php @@ -39,7 +39,7 @@ $lang = array_merge($lang, [ 'ACP_PWA_KIT_TITLE' => 'Progressive Web App Kit', - 'ACP_PWA_KIT_SETTINGS' => 'Settings', + 'ACP_PWA_KIT_SETTINGS' => 'PWA settings', 'STORAGE_PHPBB_PWAKIT_TITLE' => 'Web app icons storage', 'LOG_PWA_DIR_FAIL' => 'Progressive Web App Kit extension could not create the icon directory. You must manually add the site_icons directory to phpBB’s images
» %1$s » %2$s', ]); diff --git a/migrations/m1_initial.php b/migrations/m1_initial.php index 946f785..395e22a 100644 --- a/migrations/m1_initial.php +++ b/migrations/m1_initial.php @@ -16,12 +16,15 @@ class m1_initial extends migration { public static function depends_on(): array { - return ['\phpbb\db\migration\data\v400\dev']; + return ['\phpbb\db\migration\data\v400\add_webpush']; } public function effectively_installed(): bool { - return $this->db_tools->sql_column_exists($this->table_prefix . 'styles', 'pwa_bg_color'); + $styles_table = $this->table_prefix . 'styles'; + + return $this->db_tools->sql_column_exists($styles_table, 'pwa_bg_color') + && $this->db_tools->sql_column_exists($styles_table, 'pwa_theme_color'); } public function update_schema(): array diff --git a/migrations/m2_data.php b/migrations/m2_data.php index 001dff7..0bd1c3a 100644 --- a/migrations/m2_data.php +++ b/migrations/m2_data.php @@ -21,15 +21,17 @@ public static function depends_on(): array public function effectively_installed(): bool { + $module_basename = $this->db->sql_escape('\phpbb\pwakit\acp\pwa_acp_module'); $sql = 'SELECT module_id FROM ' . $this->table_prefix . "modules WHERE module_class = 'acp' - AND module_langname = 'ACP_PWA_KIT_TITLE'"; + AND module_basename = '$module_basename' + AND module_langname = 'ACP_PWA_KIT_SETTINGS'"; $result = $this->db->sql_query($sql); $module_id = (int) $this->db->sql_fetchfield('module_id'); $this->db->sql_freeresult($result); - return (bool) $module_id; + return $module_id > 0; } public function update_data(): array @@ -37,12 +39,7 @@ public function update_data(): array return [ ['module.add', [ 'acp', - 'ACP_CAT_DOT_MODS', - 'ACP_PWA_KIT_TITLE' - ]], - ['module.add', [ - 'acp', - 'ACP_PWA_KIT_TITLE', + 'ACP_CLIENT_COMMUNICATION', [ 'module_basename' => '\phpbb\pwakit\acp\pwa_acp_module', 'modes' => ['settings'], diff --git a/migrations/m3_storage.php b/migrations/m3_storage.php index 576b3b9..9a4288f 100644 --- a/migrations/m3_storage.php +++ b/migrations/m3_storage.php @@ -13,6 +13,7 @@ use phpbb\db\migration\container_aware_migration; use phpbb\extension\manager; use phpbb\pwakit\ext; +use phpbb\pwakit\storage\file_tracker as pwakit_file_tracker; use phpbb\storage\file_tracker; use phpbb\storage\provider\local; @@ -23,7 +24,7 @@ public static function depends_on(): array return ['\phpbb\pwakit\migrations\m2_data']; } - public function effectively_installed(): int + public function effectively_installed(): bool { return $this->config->offsetExists('storage\\phpbb_pwakit\\provider') && $this->config->offsetExists('storage\\phpbb_pwakit\\config\\path'); @@ -74,12 +75,15 @@ public function add_tracked_files(): void $pos = strpos($image, $storage_path); return [ 'file_path' => $pos !== false ? substr($image, $pos + strlen($storage_path)) : $image, - 'filesize' => filesize($image) + 'filesize' => (int) filesize($image) ]; }, $files); // Track files - $file_tracker->track_files('phpbb_pwakit', $files); + if ($files) + { + $file_tracker->track_files(pwakit_file_tracker::STORAGE_NAME, $files); + } } /** @@ -89,6 +93,9 @@ public function add_tracked_files(): void */ public function remove_tracked_files(): void { - $this->db->sql_query('DELETE FROM ' . $this->tables['storage'] . " WHERE storage = 'phpbb_pwakit'"); + $this->db->sql_query('DELETE FROM ' . $this->tables['storage'] . " + WHERE storage = '" . $this->db->sql_escape(pwakit_file_tracker::STORAGE_NAME) . "'"); + + $this->container->get('cache.driver')->destroy('sql', $this->tables['storage']); } } diff --git a/storage/file_tracker.php b/storage/file_tracker.php index 16cc68e..f1fd82e 100644 --- a/storage/file_tracker.php +++ b/storage/file_tracker.php @@ -14,6 +14,32 @@ class file_tracker extends \phpbb\storage\file_tracker { public const STORAGE_NAME = 'phpbb_pwakit'; + /** + * {@inheritdoc} + */ + public function track_files(string $storage, array $files): void + { + parent::track_files($storage, $files); + + if ($storage === self::STORAGE_NAME) + { + $this->cache->destroy('sql', $this->storage_table); + } + } + + /** + * {@inheritdoc} + */ + public function untrack_file(string $storage, $path): void + { + parent::untrack_file($storage, $path); + + if ($storage === self::STORAGE_NAME) + { + $this->cache->destroy('sql', $this->storage_table); + } + } + /** * Gets tracked files in the storage table * @@ -24,7 +50,7 @@ public function get_tracked_files(): array $sql = 'SELECT file_path FROM ' . $this->storage_table . " WHERE storage = '" . self::STORAGE_NAME . "' ORDER BY file_path"; - $result = $this->db->sql_query($sql); + $result = $this->db->sql_query($sql, 3600); $files = $this->db->sql_fetchrowset($result); $this->db->sql_freeresult($result); diff --git a/styles/all/template/event/overall_header_head_append.html b/styles/all/template/event/overall_header_head_append.html index 5c314ae..2f6a705 100644 --- a/styles/all/template/event/overall_header_head_append.html +++ b/styles/all/template/event/overall_header_head_append.html @@ -2,10 +2,11 @@ {%- if loop.first -%} {% endif -%} - + {% endfor %} {% if PWA_THEME_COLOR -%} - {% endif %} + + diff --git a/tests/functional/acp_file_test.php b/tests/functional/acp_file_test.php index 5838325..9c93d47 100644 --- a/tests/functional/acp_file_test.php +++ b/tests/functional/acp_file_test.php @@ -222,7 +222,7 @@ private function assertAppleTouchIconPresent(string $icon): void private function assertIconsNotInACP(): Crawler { $crawler = self::request('GET', 'adm/index.php?i=-phpbb-pwakit-acp-pwa_acp_module&mode=settings&sid=' . $this->sid); - self::assertContainsLang('ACP_PWA_KIT_NO_ICONS', $crawler->filter('fieldset')->eq(3)->html()); + self::assertContainsLang('ACP_PWA_NO_ICONS', $crawler->filter('fieldset')->eq(3)->html()); return $crawler; } diff --git a/tests/functional/acp_settings_test.php b/tests/functional/acp_settings_test.php index 7db095d..299e5aa 100644 --- a/tests/functional/acp_settings_test.php +++ b/tests/functional/acp_settings_test.php @@ -70,9 +70,23 @@ public function test_basic_form(): void $this->assertEquals($value, $crawler->filter('input[name="' . $name . '"]')->attr('value')); } - // Check saved data appears in the forum's meta tags as expected + // Check saved theme data appears in the document and manifest as expected $crawler = self::request('GET', 'index.php?sid=' . $this->sid); $this->assertEquals($form_data['pwa_theme_color_1'], $crawler->filter('meta[name="theme-color"]')->attr('content')); - $this->assertEquals($form_data['pwa_bg_color_1'], $crawler->filter('meta[name="background-color"]')->attr('content')); + $this->assertCount(1, $crawler->filter('meta[name="apple-mobile-web-app-capable"]')); + $this->assertCount(1, $crawler->filter('meta[name="apple-mobile-web-app-title"]')); + $this->assertCount(1, $crawler->filter('meta[name="mobile-web-app-capable"]')); + $this->assertCount(1, $crawler->filter('meta[name="apple-mobile-web-app-status-bar-style"][content="default"]')); + + $manifest_url = $crawler->filter('link[rel="manifest"]')->attr('href'); + self::request('GET', $manifest_url, [], false); + $response = self::$client->getResponse(); + $manifest = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + $this->assertEquals($form_data['pwa_theme_color_1'], $manifest['theme_color']); + $this->assertEquals($form_data['pwa_bg_color_1'], $manifest['background_color']); + $this->assertSame('application/manifest+json', $response->getHeader('Content-Type')); + $this->assertStringContainsString('private', $response->getHeader('Cache-Control')); + $this->assertStringContainsString('Cookie', $response->getHeader('Vary')); } } diff --git a/tests/unit/acp_module_test.php b/tests/unit/acp_module_test.php index 5e92203..43e5860 100644 --- a/tests/unit/acp_module_test.php +++ b/tests/unit/acp_module_test.php @@ -68,7 +68,8 @@ public function test_module_info(): void 'settings' => [ 'title' => 'ACP_PWA_KIT_SETTINGS', 'auth' => 'ext_phpbb/pwakit && acl_a_board', - 'cat' => ['ACP_PWA_KIT_TITLE'] + 'cat' => ['ACP_CLIENT_COMMUNICATION'], + 'after' => 'ACP_WEBPUSH_SETTINGS', ], ], ], diff --git a/tests/unit/admin_controller_test.php b/tests/unit/admin_controller_test.php index c732aed..62b0277 100644 --- a/tests/unit/admin_controller_test.php +++ b/tests/unit/admin_controller_test.php @@ -310,8 +310,8 @@ public static function submit_test_data(): array 'pwa_theme_color_2' => '', ], [ - 0 => ['pwa_bg_color' => '#000000', 'pwa_theme_color' => '#000fff'], - 1 => ['pwa_bg_color' => '#ffffff', 'pwa_theme_color' => ''], + 0 => ['pwa_bg_color' => '#fff000', 'pwa_theme_color' => '#000fff'], + 1 => ['pwa_bg_color' => '', 'pwa_theme_color' => ''], ], 'ACP_PWA_INVALID_COLOR', ], @@ -341,6 +341,19 @@ public static function submit_test_data(): array ], 'CONFIG_UPDATED' ], + 'inputs with whitespace' => [ + [ + 'pwa_bg_color_1' => ' #000000 ', + 'pwa_theme_color_1' => "\t#ffffff\n", + 'pwa_bg_color_2' => '', + 'pwa_theme_color_2' => '', + ], + [ + 0 => ['pwa_bg_color' => '#000000', 'pwa_theme_color' => '#ffffff'], + 1 => ['pwa_bg_color' => '', 'pwa_theme_color' => ''], + ], + 'CONFIG_UPDATED', + ], ]; } diff --git a/tests/unit/event_listener_test.php b/tests/unit/event_listener_test.php index e9c2153..90c5c47 100644 --- a/tests/unit/event_listener_test.php +++ b/tests/unit/event_listener_test.php @@ -18,6 +18,11 @@ use phpbb\template\template; use phpbb\user; use phpbb_test_case; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\ResponseEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpKernel\KernelEvents; class event_listener_test extends phpbb_test_case { @@ -81,9 +86,49 @@ public function test_getSubscribedEvents(): void static::assertEquals([ 'core.page_header' => 'header_updates', 'core.modify_manifest' => 'manifest_updates', + KernelEvents::RESPONSE => 'manifest_response', ], $events); } + public function test_manifest_response(): void + { + $request = new Request(); + $request->attributes->set('_route', 'phpbb_manifest_controller'); + $response = new JsonResponse([]); + $response->setPublic(); + $event = new ResponseEvent( + $this->createMock(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + $response + ); + + $this->get_listener()->manifest_response($event); + + $this->assertSame('application/manifest+json', $response->headers->get('Content-Type')); + $this->assertTrue($response->headers->hasCacheControlDirective('private')); + $this->assertFalse($response->headers->hasCacheControlDirective('public')); + $this->assertSame(['Cookie'], $response->getVary()); + } + + public function test_manifest_response_ignores_other_routes(): void + { + $request = new Request(); + $request->attributes->set('_route', 'phpbb_index_controller'); + $response = new JsonResponse([]); + $event = new ResponseEvent( + $this->createMock(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + $response + ); + + $this->get_listener()->manifest_response($event); + + $this->assertSame('application/json', $response->headers->get('Content-Type')); + $this->assertSame([], $response->getVary()); + } + public static function header_updates_test_data(): array { return [ @@ -109,7 +154,7 @@ public static function header_updates_test_data(): array ], [], [ - 'pwa_theme_color' => '#gggggg', + 'pwa_theme_color' => '', 'pwa_bg_color' => 'invalid', 'icons' => [], ], @@ -161,7 +206,7 @@ public function test_header_updates($configs, $icons, $expected): void $templateVars = [ 'PWA_THEME_COLOR' => $expected['pwa_theme_color'], - 'U_TOUCH_ICONS' => $expected['icons'], + 'U_TOUCH_ICONS' => $icons, ]; $this->template->expects(static::once()) @@ -226,6 +271,14 @@ public static function manifest_updates_test_data(): array ], ], ], + 'invalid color options' => [ + '/board/', + [ + 'pwa_theme_color' => '#gggggg', + 'pwa_bg_color' => 'not-a-color', + ], + [], + ], 'empty' => [ '', [], @@ -235,26 +288,26 @@ public static function manifest_updates_test_data(): array } /** - * @param $board_path + * @param $scope * @param $configs * @param $expected * @return void * @dataProvider manifest_updates_test_data */ - public function test_manifest_updates($board_path, $configs, $expected): void + public function test_manifest_updates($scope, $configs, $expected): void { $initialManifest = [ 'name' => 'Test Site', 'short_name' => 'TestSite', 'display' => 'standalone', 'orientation' => 'portrait', - 'start_url' => './', - 'scope' => './', + 'start_url' => $scope, + 'scope' => $scope, ]; $event = new data([ 'manifest' => $initialManifest, - 'board_path' => $board_path, + 'scope' => $scope, ]); // Set up and verify the initial state @@ -270,7 +323,7 @@ public function test_manifest_updates($board_path, $configs, $expected): void // Verify helper method call $this->pwa_helper->expects(static::once()) ->method('get_icons') - ->with($board_path) + ->with($scope) ->willReturn($expected['icons'] ?? []); // Execute test diff --git a/tests/unit/ext_test.php b/tests/unit/ext_test.php index 11fea71..cadf965 100644 --- a/tests/unit/ext_test.php +++ b/tests/unit/ext_test.php @@ -14,6 +14,7 @@ use Symfony\Component\DependencyInjection\ContainerInterface; use phpbb\config\config; use phpbb\db\migrator; +use phpbb\extension\manager; use phpbb\filesystem\exception\filesystem_exception; use phpbb\filesystem\filesystem; use phpbb\finder\finder; @@ -52,14 +53,22 @@ public static function ext_test_data(): array return [ 'current version' => [ ext::PHPBB_MIN_VERSION, + false, true ], 'future version' => [ '4.0.0', + false, true ], + 'legacy extension not purged' => [ + ext::PHPBB_MIN_VERSION, + true, + false + ], 'outdated version' => [ '3.3.14', + false, false ], ]; @@ -70,23 +79,30 @@ public static function ext_test_data(): array * phpBB version requirement is satisfied. * * @param $version - * @param $expected + * @param bool $legacy_configured + * @param bool $expected * * @dataProvider ext_test_data */ - public function test_ext($version, $expected): void + public function test_ext($version, bool $legacy_configured, bool $expected): void { // Instantiate a config object and set a config version $config = new config([ 'version' => $version, ]); - // Mocked container should return the config object - // when encountering $this->container->get('config') - $this->container->expects(self::once()) + $extension_manager = $this->createMock(manager::class); + $extension_manager->expects($version === '3.3.14' ? self::never() : self::once()) + ->method('is_configured') + ->with(ext::LEGACY_EXTENSION) + ->willReturn($legacy_configured); + + $this->container->expects($version === '3.3.14' ? self::once() : self::exactly(2)) ->method('get') - ->with('config') - ->willReturn($config); + ->willReturnCallback(static fn(string $service) => match ($service) { + 'config' => $config, + 'ext.manager' => $extension_manager, + }); $ext = new ext($this->container, $this->extension_finder, $this->migrator, 'phpbb/pwakit', ''); diff --git a/tests/unit/file_tracker_test.php b/tests/unit/file_tracker_test.php new file mode 100644 index 0000000..08452ec --- /dev/null +++ b/tests/unit/file_tracker_test.php @@ -0,0 +1,80 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\pwakit\tests\unit; + +use phpbb\cache\driver\driver_interface as cache; +use phpbb\db\driver\driver_interface as db; +use phpbb\pwakit\storage\file_tracker; +use phpbb_test_case; + +class file_tracker_test extends phpbb_test_case +{ + public function test_track_file_invalidates_tracked_files_query(): void + { + $destroyed = []; + $cache = $this->createMock(cache::class); + $cache->expects($this->exactly(3)) + ->method('destroy') + ->willReturnCallback(static function(string $key, string $table = '') use (&$destroyed): void { + $destroyed[] = [$key, $table]; + }); + + $db = $this->createMock(db::class); + $db->expects($this->once()) + ->method('sql_multi_insert') + ->with('phpbb_storage', [[ + 'storage' => file_tracker::STORAGE_NAME, + 'file_path' => 'icon.png', + 'filesize' => 123, + ]]); + + $tracker = new file_tracker($cache, $db, 'phpbb_storage'); + $tracker->track_file(file_tracker::STORAGE_NAME, 'icon.png', 123); + + $this->assertSame([ + ['_storage_phpbb_pwakit_totalsize', ''], + ['_storage_phpbb_pwakit_numfiles', ''], + ['sql', 'phpbb_storage'], + ], $destroyed); + } + + public function test_untrack_file_invalidates_tracked_files_query(): void + { + $destroyed = []; + $cache = $this->createMock(cache::class); + $cache->expects($this->exactly(3)) + ->method('destroy') + ->willReturnCallback(static function(string $key, string $table = '') use (&$destroyed): void { + $destroyed[] = [$key, $table]; + }); + + $db = $this->createMock(db::class); + $db->expects($this->once()) + ->method('sql_build_array') + ->with('DELETE', [ + 'file_path' => 'icon.png', + 'storage' => file_tracker::STORAGE_NAME, + ]) + ->willReturn("file_path = 'icon.png' AND storage = 'phpbb_pwakit'"); + $db->expects($this->once()) + ->method('sql_query') + ->with($this->stringContains("DELETE FROM phpbb_storage")); + + $tracker = new file_tracker($cache, $db, 'phpbb_storage'); + $tracker->untrack_file(file_tracker::STORAGE_NAME, 'icon.png'); + + $this->assertSame([ + ['_storage_phpbb_pwakit_totalsize', ''], + ['_storage_phpbb_pwakit_numfiles', ''], + ['sql', 'phpbb_storage'], + ], $destroyed); + } +} diff --git a/tests/unit/helper_test.php b/tests/unit/helper_test.php index c5d5b30..4cec12a 100644 --- a/tests/unit/helper_test.php +++ b/tests/unit/helper_test.php @@ -177,6 +177,18 @@ public function test_get_icons_empty(): void $this->assertCount(0, array_column($this->helper->get_icons(), 'src')); } + public function test_get_icons_ignores_non_png_content(): void + { + $disguised_gif = self::FIXTURES . 'site_icons/bar.png'; + copy(self::FIXTURES . 'foo.gif', $disguised_gif); + $this->file_tracker->track_file(file_tracker::STORAGE_NAME, 'bar.png', (int) filesize($disguised_gif)); + + $this->assertSame( + [$this->storage_path . '/foo.png'], + array_column($this->helper->get_icons(), 'src') + ); + } + public static function delete_icon_test_data(): array { return [ @@ -202,6 +214,11 @@ public static function delete_icon_test_data(): array ], 'icon name with possible path traversal' => [ '../foo.png', + 'ACP_PWA_IMG_DELETE_PATH_ERR', + ['foo.png'] // nothing gets deleted + ], + 'icon with ACP relative path' => [ + './../ext/phpbb/pwakit/tests/fixtures/site_icons/foo.png', '', [] // gets deleted ], diff --git a/tests/unit/upload_test.php b/tests/unit/upload_test.php index 251f25b..673ed39 100644 --- a/tests/unit/upload_test.php +++ b/tests/unit/upload_test.php @@ -110,11 +110,11 @@ public function test_upload($file_move_success): void $file->expects(self::once()) ->method('get') ->with('realname') - ->willReturn('abcdef.jpg'); + ->willReturn('abcdef.png'); $result = $upload->upload(); - self::assertEquals('abcdef.jpg', $result); + self::assertEquals('abcdef.png', $result); } }