diff --git a/CITATION.cff b/CITATION.cff index 3bc695d..5b30352 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -7,8 +7,8 @@ authors: given-names: Michael url: "https://github.com/TheStreamCode/mikesoft-teamvault" repository-code: "https://github.com/TheStreamCode/mikesoft-teamvault" -version: "3.3.0" -date-released: "2026-10-11" +version: "3.4.0" +date-released: "2026-10-21" keywords: - WordPress - document management diff --git a/README.de.md b/README.de.md index 2bb1659..e599f7d 100644 --- a/README.de.md +++ b/README.de.md @@ -11,7 +11,7 @@ Privater Dokumenten-Arbeitsbereich für WordPress-Teams, Agenturen und Betriebsabläufe, die eine kontrollierte Dateifreigabe außerhalb der Mediathek benötigen. -Aktuelle Plugin-Version: `3.3.0`. +Aktuelle Plugin-Version: `3.4.0`. Direkt über WordPress.org verfügbar und für aktuelle WordPress-Versionen gepflegt. @@ -61,6 +61,8 @@ Governance-Funktionen (alle kostenlos, seit 2.6): ## Neueste Version +Version `3.4.0` macht E-Mail-Benachrichtigungen ordnerbewusst: Ausgewählte Benutzer und Gruppen erhalten ein Ereignis nur, wenn sie den Ordner, in dem es entstanden ist, anzeigen dürfen; Administratoren bleiben eingeschlossen, wenn sie aktiviert sind. Eine neue Einstellung unter Benachrichtigungen stellt das bisherige globale Verhalten wieder her, und der Filter `mstv_notification_recipients` ermöglicht seitenspezifische Weiterleitungen. Weitere Konfigurationsänderungen sind nicht erforderlich. + Version `3.3.0` beschränkt die Verwaltung von Zugriffsregeln auf Administratoren oder Benutzer mit einer expliziten Verwaltungsregel, behebt ZIP-Exporte auf Servern, auf denen der temporäre Archivpfad nicht erstellt werden konnte, zeigt bei fehlgeschlagenen Aktionen die spezifischen Servermeldungen an und ergänzt Ordner in den Suchergebnissen. Konfigurationsänderungen sind nicht erforderlich. Version `3.2.5` schließt die in 3.2.4 begonnene Ausgabekodierung ab: Das Detailfenster maskiert die gespeicherte Dateiendung nun vor der Darstellung. Der Wert ist durch die Upload- und Reindex-Validierung bereits auf `[a-z0-9]` beschränkt; es handelt sich also um Verteidigung in der Tiefe und nicht um die Behebung einer ausnutzbaren Schwachstelle. Konfigurationsänderungen sind nicht erforderlich. diff --git a/README.es.md b/README.es.md index 6fd6e2c..4ef0294 100644 --- a/README.es.md +++ b/README.es.md @@ -11,7 +11,7 @@ Espacio de trabajo de documentos privados para equipos, agencias y operaciones de WordPress que necesitan compartir archivos de forma controlada fuera de la Biblioteca de medios. -Versión actual del plugin: `3.3.0`. +Versión actual del plugin: `3.4.0`. Disponible directamente en WordPress.org y mantenido para las versiones actuales de WordPress. @@ -61,6 +61,8 @@ Capacidades de gobernanza (todas gratuitas, desde la versión 2.6): ## Última versión +La versión `3.4.0` hace que las notificaciones por correo tengan en cuenta la carpeta: los usuarios y grupos seleccionados solo reciben un evento si pueden ver la carpeta que lo originó, mientras que los administradores siguen incluidos cuando están activados. Un nuevo ajuste en Notificaciones restaura el comportamiento global anterior y el filtro `mstv_notification_recipients` permite enrutamientos específicos por sitio. No se requieren otros cambios de configuración. + La versión `3.3.0` reserva la gestión de las reglas de permisos a los administradores o a los usuarios con una regla de gestión explícita, corrige las exportaciones ZIP en servidores donde no se podía crear la ruta temporal del archivo, muestra los mensajes específicos del servidor en las acciones fallidas y añade las carpetas a los resultados de búsqueda. No se requieren cambios de configuración. La versión `3.2.5` completa el trabajo de codificación de salida iniciado en la 3.2.4: el panel de detalles del archivo ahora escapa la extensión almacenada antes de mostrarla. El valor ya está limitado a `[a-z0-9]` por la validación de subida y reindexación, por lo que se trata de defensa en profundidad y no de la corrección de una vulnerabilidad explotable. No se requieren cambios de configuración. diff --git a/README.fr.md b/README.fr.md index 066bde6..1fd2928 100644 --- a/README.fr.md +++ b/README.fr.md @@ -11,7 +11,7 @@ Espace de travail documentaire privé pour les équipes, agences et services d'exploitation WordPress qui ont besoin d'un partage de fichiers contrôlé en dehors de la Médiathèque. -Version actuelle du plugin : `3.3.0`. +Version actuelle du plugin : `3.4.0`. Disponible directement sur WordPress.org et maintenu pour les versions actuelles de WordPress. @@ -61,6 +61,8 @@ Fonctionnalités de gouvernance (toutes gratuites, depuis la 2.6) : ## Dernière version +La version `3.4.0` rend les notifications par e-mail conscientes du dossier : les utilisateurs et groupes sélectionnés ne reçoivent un événement que s'ils peuvent consulter le dossier qui l'a produit, tandis que les administrateurs restent inclus lorsqu'ils sont activés. Un nouveau réglage dans Notifications rétablit le comportement global précédent et le filtre `mstv_notification_recipients` permet un routage propre à chaque site. Aucune autre modification de configuration n'est nécessaire. + La version `3.3.0` réserve la gestion des règles d'autorisation aux administrateurs ou aux utilisateurs disposant d'une règle de gestion explicite, corrige les exports ZIP sur les serveurs où le chemin temporaire de l'archive ne pouvait pas être créé, affiche les messages spécifiques du serveur en cas d'échec des actions et ajoute les dossiers aux résultats de recherche. Aucune modification de configuration n'est nécessaire. La version `3.2.5` achève le travail d'encodage des sorties entamé en 3.2.4 : le panneau de détails du fichier échappe désormais l'extension enregistrée avant de l'afficher. Cette valeur est déjà limitée à `[a-z0-9]` par la validation de téléversement et de réindexation ; il s'agit donc d'une défense en profondeur et non de la correction d'une faille exploitable. Aucune modification de configuration n'est nécessaire. diff --git a/README.it.md b/README.it.md index 6996641..e7dc70c 100644 --- a/README.it.md +++ b/README.it.md @@ -11,7 +11,7 @@ Spazio di lavoro documentale privato per team, agenzie e reparti operativi WordPress che hanno bisogno di condividere file in modo controllato al di fuori della Libreria Media. -Versione attuale del plugin: `3.3.0`. +Versione attuale del plugin: `3.4.0`. Disponibile direttamente su WordPress.org e mantenuto per le versioni correnti di WordPress. @@ -61,6 +61,8 @@ Funzionalità di governance (tutte gratuite, dalla versione 2.6): ## Ultima versione +La versione `3.4.0` rende le notifiche email consapevoli della cartella: utenti e gruppi selezionati ricevono un evento solo se possono visualizzare la cartella che lo ha generato, mentre gli amministratori restano inclusi se abilitati. Una nuova impostazione in Notifiche ripristina il precedente comportamento globale e il filtro `mstv_notification_recipients` consente routing specifici per sito. Non sono richieste altre modifiche alla configurazione. + La versione `3.3.0` riserva la gestione delle regole di permesso agli amministratori o agli utenti con una regola di gestione esplicita, corregge gli export ZIP sui server in cui il percorso temporaneo dell'archivio non poteva essere creato, mostra i messaggi specifici del server per le azioni non riuscite e aggiunge le cartelle ai risultati di ricerca. Non sono richieste modifiche alla configurazione. La versione `3.2.5` completa il lavoro di codifica dell'output iniziato con la 3.2.4: il pannello dei dettagli file applica ora l'escaping all'estensione memorizzata prima di visualizzarla. Il valore è già limitato a `[a-z0-9]` dalla validazione di upload e reindicizzazione, quindi si tratta di difesa in profondità e non della correzione di una vulnerabilità sfruttabile. Non sono richieste modifiche alla configurazione. diff --git a/README.md b/README.md index 8d99d09..5410726 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ > [!NOTE] > This is the public source mirror for TeamVault. Installations and updates are distributed through [WordPress.org](https://wordpress.org/plugins/mikesoft-teamvault/); user support is handled in the [official support forum](https://wordpress.org/support/plugin/mikesoft-teamvault/). -Current plugin version: `3.3.0`. +Current plugin version: `3.4.0`. ![TeamVault file manager — folder tree, file cards with type-aware icons and image thumbnails, and the details panel with inline preview](.wordpress-org/assets/screenshot-1.jpg) @@ -57,7 +57,7 @@ Typical uses include internal company documents, agency-to-client delivery, part - validated drag-and-drop uploads and inline previews, including PDFs - preview-only access that blocks downloads and ZIP exports - per-user and per-group storage quotas enforced before upload -- access reports, activity logging, CSV export, and event notifications +- access reports, activity logging, CSV export, and folder-aware event notifications - ZIP export plus orphan cleanup and storage reindex maintenance tools - English, Italian, French, Spanish, and German admin interfaces @@ -65,7 +65,7 @@ All governance capabilities are included in the free plugin. ## Latest Release -Version `3.3.0` restricts permission-rule management to administrators or users with an explicit manage rule, fixes ZIP exports on servers where the temporary archive path could not be created, surfaces specific server messages on failed actions, and adds folder results to search. No configuration changes are required. +Version `3.4.0` makes email notifications folder-aware: selected users and groups receive an event only if they can view the folder that produced it, while administrators remain included when enabled. A new Notifications setting restores the previous global behavior, and the `mstv_notification_recipients` filter allows site-specific routing. No other configuration changes are required. See the [full changelog](changelog.txt) and [GitHub releases](https://github.com/TheStreamCode/mikesoft-teamvault/releases) for complete history. diff --git a/assets/js/admin-app-governance.js b/assets/js/admin-app-governance.js index e1fb4ba..e5ed83a 100644 --- a/assets/js/admin-app-governance.js +++ b/assets/js/admin-app-governance.js @@ -623,6 +623,7 @@ this._notif = res.success ? res.data : null; if (this._notif) { this._notif.recipients = this._notif.recipients || { admins: true, users: [], groups: [] }; + this._notif.folder_aware = this._notif.folder_aware !== false; this.renderNotifications(); } } catch (e) { @@ -664,6 +665,10 @@ ${i18n.notifAdmins} +
@@ -692,6 +697,7 @@ root.querySelector('#mstv-notif-enabled').addEventListener('change', e => { n.enabled = e.target.checked; }); root.querySelector('#mstv-notif-admins').addEventListener('change', e => { n.recipients.admins = e.target.checked; }); + root.querySelector('#mstv-notif-folder-aware').addEventListener('change', e => { n.folder_aware = e.target.checked; }); root.querySelectorAll('.mstv-notif-event').forEach(cb => cb.addEventListener('change', () => { n.events = Array.from(root.querySelectorAll('.mstv-notif-event:checked')).map(c => c.value); @@ -736,6 +742,7 @@ const n = this._notif; const payload = { enabled: !!n.enabled, + folder_aware: n.folder_aware !== false, events: n.events, recipients: { admins: !!n.recipients.admins, diff --git a/changelog.txt b/changelog.txt index 2509c9f..d1832fc 100644 --- a/changelog.txt +++ b/changelog.txt @@ -2,6 +2,12 @@ Mikesoft TeamVault Changelog Entries are ordered newest first. Release dates are included where recorded. +## 3.4.0 - 2026-10-21 +- Feature: email notifications are folder-aware by default. Selected users and groups receive an upload, download, delete, or access-denied event only when they can view the folder that produced it, so a document name is not disclosed to a group without access. Administrators remain included when the administrator recipient option is enabled. +- Feature: a Notifications setting restores the previous global recipient behavior; the `mstv_notification_recipients` filter lets site code refine the final recipient list. +- Improvement: download notifications include folder context, and queued events are grouped by recipient set so files from folders with different access rules are never combined in one email. +- Tests: expanded notification coverage for folder filtering, administrator bypass, direct recipients, download context, opt-out behavior, and cross-folder batching. + ## 3.3.0 - 2026-10-11 - Security: permission-rule management is now restricted to administrators or users whose manage grant comes from an explicit folder rule; the capability-based free fallback no longer delegates rule management, and the interface hides the rule editor accordingly. - Fix: ZIP exports build their temporary archive through a correctly separated temp directory path, restoring folder and library exports on servers where the previous malformed path could not be created. diff --git a/docs/developer/hooks.md b/docs/developer/hooks.md index 9959c09..a02270e 100644 --- a/docs/developer/hooks.md +++ b/docs/developer/hooks.md @@ -78,6 +78,7 @@ Parameters: Payload keys currently include: - `display_name` +- `folder_id` - `extension` - `mime_type` @@ -276,10 +277,33 @@ add_filter('mstv_upload_validation', function (array $result, array $files): arr }, 10, 2); ``` +### `mstv_notification_recipients` + +Filters the email addresses that will receive a notification event after the +configured administrators, users, and groups have been resolved and, when +folder-aware delivery is enabled, filtered by folder access. + +Parameters: + +- `string[] $emails` +- `string $event` (`upload`, `download`, `delete`, or `access_denied`) +- `?int $folderId` + +```php +add_filter('mstv_notification_recipients', function (array $emails, string $event, ?int $folderId): array { + if ($event === 'access_denied') { + return array_values(array_intersect($emails, ['security@example.test'])); + } + + return $emails; +}, 10, 3); +``` + ## Notes - These hooks are intended for WordPress customizations and must be used from site code, custom plugins, or mu-plugins. - Hook payloads reflect the current implementation and may expand in future releases. +- Email notifications are folder-aware by default: selected users and groups receive an event only when they can view the folder that produced it, while administrators remain included if the administrator recipient option is enabled. Site owners can disable this behavior in TeamVault → Notifications, and `mstv_notification_recipients` can refine the final list. - Since 3.3.0, folder permission rules can only be read or changed through the governance REST endpoints by administrators or users whose `manage` grant comes from an explicit folder rule (`MSTV_Permissions::has_rule_based_manage()`). diff --git a/docs/maintainer/security-review.md b/docs/maintainer/security-review.md index ad118fb..e27b71c 100644 --- a/docs/maintainer/security-review.md +++ b/docs/maintainer/security-review.md @@ -1,8 +1,8 @@ -# Security Review — 2026-10-11 +# Security Review — 2026-10-21 ## Scope and Method -This review covers the 3.3.0 working tree: the permission resolver and governance REST surface, the export/temp-path handling, the validator messages, the admin JavaScript rendering layer, and the test infrastructure. It combines manual source review with the expanded PHPUnit suite (186 tests), behavioral node tests for the shared helpers, PHP lint on the full tree, live HTTP verification against a WordPress 7.1.3 staging site (upload, preview, download, REST, governance, exports, maintenance, uninstall), WordPress Plugin Check (zero errors), and scripted browser QA of the refactored interface. +This review covers the 3.4.0 working tree: the folder-aware notification path, the permission resolver and governance REST surface, the export/temp-path handling, the validator messages, the admin JavaScript rendering layer, and the test infrastructure. It combines manual source review with the expanded PHPUnit suite, behavioral node tests for the shared helpers, PHP lint on the full tree, Composer validation and audit, JavaScript syntax checks, WordPress Plugin Check on an active local staging installation (no errors), and a WP-CLI functional notification test on that installation. It was not exercised through a browser session or a real mail transport in this cycle; those release checks remain required before publication. No critical issue remains open in this cycle. The findings below were fixed in the reviewed tree and carry regression coverage where practical. This document is a point-in-time engineering review, not a guarantee against every vulnerability or a substitute for production configuration testing. @@ -41,8 +41,18 @@ No critical issue remains open in this cycle. The findings below were fixed in t - Evidence: localized strings interpolated into HTML attributes, governance enum fallbacks, and REST-derived identifiers reached sinks without mechanical escaping (all values server-controlled, so defense in depth rather than an exploitable path). - Resolution: attribute sinks route through the shared escaper; enum fallbacks are escaped; IDs are normalized with `parseInt`; the preview download binding is null-guarded. Shared pure helpers now carry behavioral node tests, and the packaging excludes repository-only metadata files (the only WordPress Plugin Check warning). +### SEC-009 — Notifications: recipients could receive document names from folders they cannot view + +- Severity: Medium (information disclosure) +- Status: Fixed in 3.4.0 +- Location: `includes/class-mstv-notifications.php`, `includes/class-mstv-bootstrap.php`, `includes/class-mstv-download.php` +- Evidence: notification recipients were resolved once from the global administrator/user/group selection and reused for every event. A group selected for upload notifications received the document name even when the folder that produced the event was governed by rules that excluded that group. The existing `mstv_folder_notifications` option was not used for routing. +- Resolution: each queued event now carries its `folder_id`; non-administrator recipients are filtered through `MSTV_Permissions::user_can($userId, $folderId, 'view')`, administrators bypass the filter when the administrator recipient option is enabled, and events are grouped by recipient set so one email cannot mix files from folders with different access rules. Download events now include the folder in their hook payload. The behavior is enabled by default and can be disabled with the Notifications setting; the `mstv_notification_recipients` filter is the documented extension point. Regression tests cover group filtering, administrator bypass, direct recipients, downloads, opt-out, and cross-folder batching. +- Compatibility note: existing sites begin filtering after the update; the setting restores the previous global behavior. This behavior change is called out in the upgrade notice. + ## Residual Risks - Storage protection still depends on server configuration: the generated `.htaccess`/`web.config` cover Apache/IIS, while Nginx requires an equivalent deny rule (surfaced to operators by the settings notice). - Header semantics for private streams remain guarded by source-level tests plus live HTTP verification; a CLI unit test cannot observe `header()` output. - The whitelist, quota, and notification surfaces rely on the capability floor plus, where applicable, database advisory locks; they were re-verified through the live governance battery but not fuzzed beyond their regression suites. +- The notification filter resolves access per recipient and folder, which adds one cached permission lookup per event recipient; very large selected-recipient sets are bounded by the notification configuration and were not benchmarked at extreme scale. diff --git a/includes/class-mstv-activator.php b/includes/class-mstv-activator.php index 455b100..a1e472c 100644 --- a/includes/class-mstv-activator.php +++ b/includes/class-mstv-activator.php @@ -281,6 +281,7 @@ private static function set_default_options(): void 'mstv_notify_enabled' => false, 'mstv_notify_events' => '', 'mstv_notify_recipients' => ['admins' => true, 'users' => [], 'groups' => []], + 'mstv_notify_folder_aware' => true, 'mstv_folder_notifications' => [], self::VERSION_OPTION => MSTV_VERSION, ]; diff --git a/includes/class-mstv-assets.php b/includes/class-mstv-assets.php index 4312c97..536ab82 100644 --- a/includes/class-mstv-assets.php +++ b/includes/class-mstv-assets.php @@ -239,6 +239,7 @@ public function enqueue_assets(string $hook): void 'notifEvents' => __('Events', 'mikesoft-teamvault'), 'notifRecipients' => __('Recipients', 'mikesoft-teamvault'), 'notifAdmins' => __('All administrators', 'mikesoft-teamvault'), + 'notifFolderAware' => __('Only notify people who can access the folder', 'mikesoft-teamvault'), 'notifUsers' => __('Specific users', 'mikesoft-teamvault'), 'notifGroups' => __('Groups', 'mikesoft-teamvault'), 'notifAccessDenied' => __('Access denied', 'mikesoft-teamvault'), diff --git a/includes/class-mstv-bootstrap.php b/includes/class-mstv-bootstrap.php index ab5e023..7358f4f 100644 --- a/includes/class-mstv-bootstrap.php +++ b/includes/class-mstv-bootstrap.php @@ -139,8 +139,20 @@ public function init_services(): void // Record completed exports in the activity log (export is bulk download). add_action(MSTV_Hooks::EXPORT_COMPLETED, [$this, 'log_export_completed'], 10, 3); + if (!isset($this->services['permissions'])) { + $this->services['permissions'] = new MSTV_Permissions( + new MSTV_Repository_Folders(), + new MSTV_Repository_Groups(), + new MSTV_Repository_Permissions(), + $this->services['settings'] + ); + } + if (!isset($this->services['notifications'])) { - $this->services['notifications'] = new MSTV_Notifications(new MSTV_Repository_Groups()); + $this->services['notifications'] = new MSTV_Notifications( + new MSTV_Repository_Groups(), + $this->services['permissions'] + ); $this->services['notifications']->register(); } diff --git a/includes/class-mstv-download.php b/includes/class-mstv-download.php index 3e5593d..9232d6a 100644 --- a/includes/class-mstv-download.php +++ b/includes/class-mstv-download.php @@ -81,6 +81,7 @@ public function serve(int $fileId): void if (class_exists('MSTV_Hooks')) { MSTV_Hooks::do_file_downloaded($fileId, [ 'display_name' => $files->display_name, + 'folder_id' => $files->folder_id ? (int) $files->folder_id : null, 'extension' => $files->extension, 'mime_type' => $files->mime_type, ]); diff --git a/includes/class-mstv-hooks.php b/includes/class-mstv-hooks.php index 88c61a0..1dd62f8 100644 --- a/includes/class-mstv-hooks.php +++ b/includes/class-mstv-hooks.php @@ -26,6 +26,7 @@ class MSTV_Hooks public const FILTER_STORAGE_PATH = 'mstv_storage_path'; public const FILTER_FILE_NAME = 'mstv_file_name'; public const FILTER_UPLOAD_VALIDATION = 'mstv_upload_validation'; + public const FILTER_NOTIFICATION_RECIPIENTS = 'mstv_notification_recipients'; public static function do_file_uploaded(int $fileId, array $fileData): void { @@ -134,4 +135,14 @@ public static function filter_upload_validation(array $result, array $files): ar // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Hook name is prefixed in constant value. return apply_filters(self::FILTER_UPLOAD_VALIDATION, $result, $files); } + + /** + * @param string[] $emails + * @return string[] + */ + public static function filter_notification_recipients(array $emails, string $event, ?int $folderId): array + { + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Hook name is prefixed in constant value. + return apply_filters(self::FILTER_NOTIFICATION_RECIPIENTS, $emails, $event, $folderId); + } } \ No newline at end of file diff --git a/includes/class-mstv-i18n.php b/includes/class-mstv-i18n.php index 4c74ea1..60c0341 100644 --- a/includes/class-mstv-i18n.php +++ b/includes/class-mstv-i18n.php @@ -22,6 +22,7 @@ class MSTV_I18n 'Access denied' => 'Accesso negato', 'Access denied for %1$s (action: %2$s, folder: %3$s)' => 'Accesso negato per %1$s (azione: %2$s, cartella: %3$s)', 'All administrators' => 'Tutti gli amministratori', + 'Only notify people who can access the folder' => 'Notifica solo chi può accedere alla cartella', 'Deleted: %s' => 'Eliminato: %s', 'Downloaded: %s' => 'Scaricato: %s', 'Email notifications' => 'Notifiche email', @@ -337,6 +338,7 @@ class MSTV_I18n 'Access denied' => 'Accès refusé', 'Access denied for %1$s (action: %2$s, folder: %3$s)' => 'Accès refusé pour %1$s (action : %2$s, dossier : %3$s)', 'All administrators' => 'Tous les administrateurs', + 'Only notify people who can access the folder' => 'Notifier uniquement les personnes ayant accès au dossier', 'Deleted: %s' => 'Supprimé : %s', 'Downloaded: %s' => 'Téléchargé : %s', 'Email notifications' => 'Notifications par e-mail', @@ -652,6 +654,7 @@ class MSTV_I18n 'Access denied' => 'Acceso denegado', 'Access denied for %1$s (action: %2$s, folder: %3$s)' => 'Acceso denegado para %1$s (acción: %2$s, carpeta: %3$s)', 'All administrators' => 'Todos los administradores', + 'Only notify people who can access the folder' => 'Notificar solo a quienes puedan acceder a la carpeta', 'Deleted: %s' => 'Eliminado: %s', 'Downloaded: %s' => 'Descargado: %s', 'Email notifications' => 'Notificaciones por correo', @@ -967,6 +970,7 @@ class MSTV_I18n 'Access denied' => 'Zugriff verweigert', 'Access denied for %1$s (action: %2$s, folder: %3$s)' => 'Zugriff verweigert für %1$s (Aktion: %2$s, Ordner: %3$s)', 'All administrators' => 'Alle Administratoren', + 'Only notify people who can access the folder' => 'Nur Personen benachrichtigen, die auf den Ordner zugreifen können', 'Deleted: %s' => 'Gelöscht: %s', 'Downloaded: %s' => 'Heruntergeladen: %s', 'Email notifications' => 'E-Mail-Benachrichtigungen', diff --git a/includes/class-mstv-notifications.php b/includes/class-mstv-notifications.php index b51ac21..65b32bf 100644 --- a/includes/class-mstv-notifications.php +++ b/includes/class-mstv-notifications.php @@ -5,22 +5,28 @@ /** * Email notifications for document events (upload, download, delete, access-denied). * - * Native and restrained: events are collected during the request and a single email - * per event type is sent on `shutdown`, so uploads/downloads are never slowed on the - * request path and bursts within one request are summarized instead of one-per-file. - * Recipients are resolved to administrators, selected users, and/or TeamVault groups. + * Events are collected during the request and sent on `shutdown`, so document + * operations are never slowed on the request path and bursts within one request + * are summarized instead of sent one-per-file. + * + * Recipients are resolved to administrators, selected users, and/or TeamVault + * groups. When folder-aware delivery is enabled, non-administrator recipients + * must also be able to view the folder that produced the event; otherwise a + * document name could be disclosed to a group that has no access to it. */ class MSTV_Notifications { private MSTV_Repository_Groups $groupsRepo; + private ?MSTV_Permissions $permissions; - /** @var array event => human lines */ + /** @var array */ private array $queue = []; private bool $shutdownHooked = false; - public function __construct(MSTV_Repository_Groups $groupsRepo) + public function __construct(MSTV_Repository_Groups $groupsRepo, ?MSTV_Permissions $permissions = null) { $this->groupsRepo = $groupsRepo; + $this->permissions = $permissions; } public function register(): void @@ -51,7 +57,7 @@ public function on_file_uploaded($fileId, $data): void /* translators: %s: file name. */ __('Uploaded: %s', 'mikesoft-teamvault'), $name - )); + ), $this->folder_id_from_data($data)); } public function on_file_downloaded($fileId, $data): void @@ -61,7 +67,7 @@ public function on_file_downloaded($fileId, $data): void /* translators: %s: file name. */ __('Downloaded: %s', 'mikesoft-teamvault'), $name - )); + ), $this->folder_id_from_data($data)); } public function on_file_deleted($fileId, $data): void @@ -71,7 +77,7 @@ public function on_file_deleted($fileId, $data): void /* translators: %s: file name. */ __('Deleted: %s', 'mikesoft-teamvault'), $name - )); + ), $this->folder_id_from_data($data)); } public function on_access_denied($userId, $folderId, $action): void @@ -84,16 +90,20 @@ public function on_access_denied($userId, $folderId, $action): void $who, (string) $action, $folderId !== null ? (string) $folderId : 'root' - )); + ), $folderId !== null ? (int) $folderId : null); } - private function enqueue(string $event, string $line): void + private function enqueue(string $event, string $line, ?int $folderId): void { if (!$this->is_enabled() || !in_array($event, $this->enabled_events(), true)) { return; } - $this->queue[$event][] = $line; + $this->queue[] = [ + 'event' => $event, + 'line' => $line, + 'folder_id' => $folderId, + ]; if (!$this->shutdownHooked) { add_action('shutdown', [$this, 'flush'], 100); @@ -107,30 +117,47 @@ public function flush(): void return; } - $recipients = $this->recipient_emails(); - if (empty($recipients)) { - $this->queue = []; + // Group by event and recipient set: a single email must never mix files + // from folders with different access rules. + $batches = []; + foreach ($this->queue as $item) { + $recipients = $this->recipient_emails($item['event'], $item['folder_id']); + if (empty($recipients)) { + continue; + } + + $key = $item['event'] . ':' . implode('|', $recipients); + if (!isset($batches[$key])) { + $batches[$key] = [ + 'event' => $item['event'], + 'recipients' => $recipients, + 'lines' => [], + ]; + } + + $batches[$key]['lines'][] = $item['line']; + } + + $this->queue = []; + + if (empty($batches)) { return; } $siteName = (string) get_bloginfo('name'); - foreach ($this->queue as $event => $lines) { + foreach ($batches as $batch) { $subject = sprintf( /* translators: 1: site name, 2: number of events, 3: event label. */ __('[%1$s] TeamVault: %2$d %3$s event(s)', 'mikesoft-teamvault'), $siteName, - count($lines), - $this->event_label($event) + count($batch['lines']), + $this->event_label($batch['event']) ); - $body = implode("\n", $lines); - // wp_mail failures are non-fatal here; notifications must never break a request. - wp_mail($recipients, $subject, $body); + wp_mail($batch['recipients'], $subject, implode("\n", $batch['lines'])); } - - $this->queue = []; } private function event_label(string $event): string @@ -150,18 +177,30 @@ private function event_label(string $event): string } /** + * Resolve recipient emails for one event. + * + * Administrators are always kept when the relevant option is enabled. Other + * recipients are filtered against the folder that produced the event when + * folder-aware delivery is on, so the notification cannot reveal a document + * name to someone who cannot view that folder. + * * @return string[] unique recipient email addresses */ - private function recipient_emails(): array + private function recipient_emails(string $event, ?int $folderId): array { $config = get_option('mstv_notify_recipients', ['admins' => true, 'users' => [], 'groups' => []]); $config = is_array($config) ? $config : []; $userIds = []; + $adminIds = []; if (!empty($config['admins'])) { - foreach (get_users(['role' => 'administrator', 'fields' => 'ID']) as $adminId) { - $userIds[] = (int) $adminId; + foreach (get_users(['role' => 'administrator', 'fields' => 'ID']) as $admin) { + $adminId = is_object($admin) ? (int) ($admin->ID ?? 0) : (int) $admin; + if ($adminId > 0) { + $userIds[] = $adminId; + $adminIds[] = $adminId; + } } } @@ -175,14 +214,52 @@ private function recipient_emails(): array } } + $enforceFolderAccess = $this->is_folder_aware() && $this->permissions !== null; $emails = []; + foreach (array_unique(array_filter($userIds)) as $uid) { $user = get_userdata($uid); - if ($user && is_email($user->user_email)) { - $emails[] = $user->user_email; + if (!$user || !is_email($user->user_email)) { + continue; + } + + if ($enforceFolderAccess + && !in_array($uid, $adminIds, true) + && !$this->permissions->user_can($uid, $folderId, MSTV_Permissions::ACTION_VIEW)) { + continue; } + + $emails[] = $user->user_email; + } + + $emails = array_values(array_unique($emails)); + + if (class_exists('MSTV_Hooks')) { + $filtered = MSTV_Hooks::filter_notification_recipients($emails, $event, $folderId); + if (is_array($filtered)) { + $emails = $filtered; + } + } + + $emails = array_values(array_unique(array_filter( + array_map('strval', $emails), + static fn($email): bool => is_string($email) && is_email($email) !== false + ))); + + return $emails; + } + + private function is_folder_aware(): bool + { + return (bool) get_option('mstv_notify_folder_aware', true); + } + + private function folder_id_from_data(mixed $data): ?int + { + if (!is_array($data) || empty($data['folder_id'])) { + return null; } - return array_values(array_unique($emails)); + return (int) $data['folder_id']; } } diff --git a/includes/class-mstv-rest-governance.php b/includes/class-mstv-rest-governance.php index aa2b454..c72d653 100644 --- a/includes/class-mstv-rest-governance.php +++ b/includes/class-mstv-rest-governance.php @@ -295,6 +295,7 @@ public function get_notifications(\WP_REST_Request $request): \WP_REST_Response 'success' => true, 'data' => [ 'enabled' => (bool) get_option('mstv_notify_enabled', false), + 'folder_aware' => (bool) get_option('mstv_notify_folder_aware', true), 'events' => array_values($events), 'available_events' => ['upload', 'download', 'delete', 'access_denied'], 'recipients' => [ @@ -313,6 +314,11 @@ public function update_notifications(\WP_REST_Request $request): \WP_REST_Respon { update_option('mstv_notify_enabled', wp_validate_boolean($request->get_param('enabled'))); + $folderAware = $request->get_param('folder_aware'); + if ($folderAware !== null) { + update_option('mstv_notify_folder_aware', wp_validate_boolean($folderAware)); + } + $allowedEvents = ['upload', 'download', 'delete', 'access_denied']; $events = array_values(array_intersect($allowedEvents, (array) $request->get_param('events'))); update_option('mstv_notify_events', implode(',', $events)); diff --git a/mikesoft-teamvault.php b/mikesoft-teamvault.php index 6107be4..bfc8d4d 100644 --- a/mikesoft-teamvault.php +++ b/mikesoft-teamvault.php @@ -3,7 +3,7 @@ * Plugin Name: Mikesoft TeamVault * Plugin URI: https://github.com/TheStreamCode/mikesoft-teamvault * Description: Private shared document management separated from the WordPress Media Library with secure access control, preview and drag-and-drop. - * Version: 3.3.0 + * Version: 3.4.0 * Author: Mikesoft * Author URI: https://mikesoft.it * License: GPL-2.0-or-later @@ -16,7 +16,7 @@ defined('ABSPATH') || exit; -define('MSTV_VERSION', '3.3.0'); +define('MSTV_VERSION', '3.4.0'); define('MSTV_PLUGIN_FILE', __FILE__); define('MSTV_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('MSTV_PLUGIN_URL', plugin_dir_url(__FILE__)); diff --git a/readme.txt b/readme.txt index d74eab1..7e1b8f6 100644 --- a/readme.txt +++ b/readme.txt @@ -5,7 +5,7 @@ Tags: documents, secure, collaboration, privacy, file-manager Requires at least: 6.0 Tested up to: 7.1 Requires PHP: 8.0 -Stable tag: 3.3.0 +Stable tag: 3.4.0 License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -35,7 +35,7 @@ What you can do with TeamVault: * Allow preview-only access without download for sensitive documents * Set per-user or per-group storage quotas * Review access reports and export the activity log to CSV -* Send email notifications on key document events +* Send email notifications on key document events, with folder-aware recipients Why use TeamVault instead of the Media Library? @@ -61,7 +61,7 @@ Key features: * Preview-only access that blocks download and ZIP export * Per-user and per-group storage quotas enforced before upload * Access reports by user, file, or folder, with CSV export of the activity log -* Email notifications for upload, download, delete, and access-denied events +* Email notifications for upload, download, delete, and access-denied events, restricted to recipients who can view the folder unless the site owner opts out * Folder create, rename, move, and delete operations * Drag-and-drop uploads with upload validation and clear error messages * Inline preview for supported files, including PDFs @@ -167,6 +167,12 @@ For direct contact, email teamvault@mikesoft.it. == Changelog == += 3.4.0 = +* Added: folder-aware email notifications. Selected users and groups receive an upload, download, delete, or access-denied event only when they can view the folder that produced it; administrators remain included when the administrator recipient option is enabled. +* Added: a Notifications setting to restore the previous global recipient behavior, and the `mstv_notification_recipients` filter for site-specific routing. +* Improved: download notifications now carry folder context, and events are grouped by recipient set so files from folders with different access rules are never combined in one email. +* Tests: added regression coverage for folder filtering, administrator bypass, direct recipients, downloads, opt-out, and cross-folder batching. + = 3.3.0 = * Security: permission-rule management is restricted to administrators or users with an explicit manage rule on the folder; the capability-based free fallback no longer delegates it and the interface hides the rule editor accordingly. * Fixed: ZIP exports work again on servers where the temporary archive path could not be created. @@ -316,6 +322,10 @@ For the full release history, see `changelog.txt` in the plugin package. == Upgrade Notice == += 3.4.0 = + +Email notifications now default to folder-aware delivery: selected users and groups receive an event only if they can view the folder, while administrators remain included when enabled. A Notifications setting restores the previous global behavior. No other configuration changes are required. + = 3.3.0 = Folder permission rules can now be managed only by administrators or users with an explicit manage rule on the folder. ZIP exports work again on servers where the temporary archive path could not be created. No configuration changes are required. diff --git a/tests/PDMGovernanceAtomicityTest.php b/tests/PDMGovernanceAtomicityTest.php index adff1f5..cec90c9 100644 --- a/tests/PDMGovernanceAtomicityTest.php +++ b/tests/PDMGovernanceAtomicityTest.php @@ -87,6 +87,34 @@ public function test_notification_boolean_strings_are_normalized_before_storage( self::assertFalse(get_option('mstv_notify_recipients')['admins']); } + public function test_notification_folder_aware_boolean_is_normalized_before_storage(): void + { + $controller = $this->buildController(); + + $controller->update_notifications(new WP_REST_Request([ + 'enabled' => true, + 'folder_aware' => 'false', + 'events' => ['upload'], + 'recipients' => ['admins' => true], + ])); + + self::assertFalse(get_option('mstv_notify_folder_aware')); + } + + public function test_notification_folder_aware_is_preserved_when_omitted_by_old_clients(): void + { + update_option('mstv_notify_folder_aware', false); + $controller = $this->buildController(); + + $controller->update_notifications(new WP_REST_Request([ + 'enabled' => true, + 'events' => ['upload'], + 'recipients' => ['admins' => true], + ])); + + self::assertFalse(get_option('mstv_notify_folder_aware')); + } + public function test_quota_boolean_strings_are_normalized_before_storage(): void { $groups = $this->getMockBuilder(MSTV_Repository_Groups::class)->disableOriginalConstructor()->getMock(); diff --git a/tests/PDMNotificationsTest.php b/tests/PDMNotificationsTest.php index 14367a6..7250c5b 100644 --- a/tests/PDMNotificationsTest.php +++ b/tests/PDMNotificationsTest.php @@ -19,6 +19,31 @@ public function find_members(int $groupId): array } } +/** + * Minimal permissions double: the notification code only needs user_can() for + * the folder attached to each event. The real resolver is covered by + * PDMPermissionsTest. + */ +final class FakeNotifPermissions extends MSTV_Permissions +{ + /** @var array userId => folder IDs the user may view */ + public array $viewableFolders = []; + + public function __construct(array $viewableFolders = []) + { + $this->viewableFolders = $viewableFolders; + } + + public function user_can(int $userId, ?int $folderId, string $action): bool + { + if ($action !== self::ACTION_VIEW) { + return false; + } + + return in_array($folderId, $this->viewableFolders[$userId] ?? [], true); + } +} + final class PDMNotificationsTest extends TestCase { protected function setUp(): void @@ -27,6 +52,7 @@ protected function setUp(): void 'mstv_notify_enabled' => true, 'mstv_notify_events' => 'upload,download', 'mstv_notify_recipients' => ['admins' => false, 'users' => [9], 'groups' => []], + 'mstv_notify_folder_aware' => true, ]; $GLOBALS['pdm_test_users'] = [ 9 => (object) ['ID' => 9, 'user_email' => 'ops@example.test'], @@ -34,9 +60,14 @@ protected function setUp(): void $GLOBALS['pdm_test_mails'] = []; } - private function notifications(array $members = []): MSTV_Notifications + private function notifications(array $members = [], ?MSTV_Permissions $permissions = null): MSTV_Notifications { - return new MSTV_Notifications(new FakeNotifGroupsRepo($members)); + return new MSTV_Notifications(new FakeNotifGroupsRepo($members), $permissions); + } + + private function addUser(int $id, string $email): void + { + $GLOBALS['pdm_test_users'][$id] = (object) ['ID' => $id, 'user_email' => $email]; } public function test_enabled_event_sends_one_summary_email_per_event_type(): void @@ -80,7 +111,7 @@ public function test_event_not_in_enabled_list_is_ignored(): void public function test_group_members_are_resolved_as_recipients(): void { $GLOBALS['pdm_test_options']['mstv_notify_recipients'] = ['admins' => false, 'users' => [], 'groups' => [3]]; - $GLOBALS['pdm_test_users'][11] = (object) ['ID' => 11, 'user_email' => 'team@example.test']; + $this->addUser(11, 'team@example.test'); $n = $this->notifications([3 => [11]]); $n->on_file_uploaded(1, ['display_name' => 'A.pdf']); @@ -89,4 +120,153 @@ public function test_group_members_are_resolved_as_recipients(): void self::assertCount(1, $GLOBALS['pdm_test_mails']); self::assertSame(['team@example.test'], $GLOBALS['pdm_test_mails'][0]['to']); } + + public function test_folder_aware_delivery_filters_group_members_by_folder_access(): void + { + $GLOBALS['pdm_test_options']['mstv_notify_recipients'] = [ + 'admins' => false, + 'users' => [], + 'groups' => [3, 4], + ]; + $this->addUser(11, 'consiglio@example.test'); + $this->addUser(12, 'notai@example.test'); + + $permissions = new FakeNotifPermissions([ + 11 => [10], + 12 => [20], + ]); + + $n = $this->notifications([3 => [11], 4 => [12]], $permissions); + $n->on_file_uploaded(1, ['display_name' => 'Verbale.pdf', 'folder_id' => 10]); + $n->flush(); + + self::assertCount(1, $GLOBALS['pdm_test_mails']); + self::assertSame(['consiglio@example.test'], $GLOBALS['pdm_test_mails'][0]['to']); + } + + public function test_folder_aware_delivery_keeps_administrators(): void + { + $GLOBALS['pdm_test_users'] = [ + 1 => (object) ['ID' => 1, 'user_email' => 'admin@example.test'], + ]; + $GLOBALS['pdm_test_options']['mstv_notify_recipients'] = [ + 'admins' => true, + 'users' => [], + 'groups' => [], + ]; + + $permissions = new FakeNotifPermissions(); + + $n = $this->notifications([], $permissions); + $n->on_file_uploaded(1, ['display_name' => 'Verbale.pdf', 'folder_id' => 10]); + $n->flush(); + + self::assertCount(1, $GLOBALS['pdm_test_mails']); + self::assertSame(['admin@example.test'], $GLOBALS['pdm_test_mails'][0]['to']); + } + + public function test_folder_aware_delivery_can_be_disabled(): void + { + $GLOBALS['pdm_test_options']['mstv_notify_folder_aware'] = false; + $GLOBALS['pdm_test_options']['mstv_notify_recipients'] = [ + 'admins' => false, + 'users' => [], + 'groups' => [3, 4], + ]; + $this->addUser(11, 'consiglio@example.test'); + $this->addUser(12, 'notai@example.test'); + + $permissions = new FakeNotifPermissions([ + 11 => [10], + ]); + + $n = $this->notifications([3 => [11], 4 => [12]], $permissions); + $n->on_file_uploaded(1, ['display_name' => 'Verbale.pdf', 'folder_id' => 10]); + $n->flush(); + + self::assertCount(1, $GLOBALS['pdm_test_mails']); + self::assertSame( + ['consiglio@example.test', 'notai@example.test'], + $GLOBALS['pdm_test_mails'][0]['to'] + ); + } + + public function test_direct_recipients_are_filtered_by_folder_access(): void + { + $GLOBALS['pdm_test_options']['mstv_notify_recipients'] = [ + 'admins' => false, + 'users' => [11, 12], + 'groups' => [], + ]; + $this->addUser(11, 'consiglio@example.test'); + $this->addUser(12, 'notai@example.test'); + + $permissions = new FakeNotifPermissions([ + 11 => [10], + 12 => [20], + ]); + + $n = $this->notifications([], $permissions); + $n->on_file_uploaded(1, ['display_name' => 'Verbale.pdf', 'folder_id' => 20]); + $n->flush(); + + self::assertCount(1, $GLOBALS['pdm_test_mails']); + self::assertSame(['notai@example.test'], $GLOBALS['pdm_test_mails'][0]['to']); + } + + public function test_download_event_uses_its_folder_context(): void + { + $GLOBALS['pdm_test_options']['mstv_notify_recipients'] = [ + 'admins' => false, + 'users' => [], + 'groups' => [3, 4], + ]; + $this->addUser(11, 'consiglio@example.test'); + $this->addUser(12, 'notai@example.test'); + + $permissions = new FakeNotifPermissions([ + 11 => [10], + 12 => [20], + ]); + + $n = $this->notifications([3 => [11], 4 => [12]], $permissions); + $n->on_file_downloaded(1, ['display_name' => 'Verbale.pdf', 'folder_id' => 20]); + $n->flush(); + + self::assertCount(1, $GLOBALS['pdm_test_mails']); + self::assertSame(['notai@example.test'], $GLOBALS['pdm_test_mails'][0]['to']); + } + + public function test_different_folders_never_share_an_email(): void + { + $GLOBALS['pdm_test_options']['mstv_notify_recipients'] = [ + 'admins' => false, + 'users' => [], + 'groups' => [3, 4], + ]; + $this->addUser(11, 'consiglio@example.test'); + $this->addUser(12, 'notai@example.test'); + + $permissions = new FakeNotifPermissions([ + 11 => [10], + 12 => [20], + ]); + + $n = $this->notifications([3 => [11], 4 => [12]], $permissions); + $n->on_file_uploaded(1, ['display_name' => 'Verbale Consiglio.pdf', 'folder_id' => 10]); + $n->on_file_uploaded(2, ['display_name' => 'Verbale Notai.pdf', 'folder_id' => 20]); + $n->flush(); + + self::assertCount(2, $GLOBALS['pdm_test_mails']); + + $byRecipient = []; + foreach ($GLOBALS['pdm_test_mails'] as $mail) { + $byRecipient[$mail['to'][0]] = $mail['message']; + } + + self::assertStringContainsString('Verbale Consiglio.pdf', $byRecipient['consiglio@example.test']); + self::assertStringNotContainsString('Verbale Notai.pdf', $byRecipient['consiglio@example.test']); + self::assertStringContainsString('Verbale Notai.pdf', $byRecipient['notai@example.test']); + self::assertStringNotContainsString('Verbale Consiglio.pdf', $byRecipient['notai@example.test']); + } } diff --git a/tests/PDMReleaseMetadataTest.php b/tests/PDMReleaseMetadataTest.php index 94cac08..1c57b25 100644 --- a/tests/PDMReleaseMetadataTest.php +++ b/tests/PDMReleaseMetadataTest.php @@ -6,8 +6,8 @@ final class PDMReleaseMetadataTest extends TestCase { - private const RELEASE_VERSION = '3.3.0'; - private const RELEASE_DATE = '2026-10-11'; + private const RELEASE_VERSION = '3.4.0'; + private const RELEASE_DATE = '2026-10-21'; private const TESTED_UP_TO = '7.1'; private const CONTACT_EMAIL = 'teamvault@mikesoft.it'; private const PLUGIN_AUTHOR = 'Mikesoft'; diff --git a/uninstall.php b/uninstall.php index b33fbc7..7e53362 100644 --- a/uninstall.php +++ b/uninstall.php @@ -64,6 +64,7 @@ function mstv_uninstall_site(array $storage_path_usage = []) { delete_option('mstv_notify_enabled'); delete_option('mstv_notify_events'); delete_option('mstv_notify_recipients'); + delete_option('mstv_notify_folder_aware'); delete_option('mstv_folder_notifications'); delete_option('mstv_white_label_enabled'); delete_option('mstv_brand_name');