Skip to content

Commit ebad026

Browse files
author
mikesoft-codex
committed
Release version 2.6.2
Code quality maintenance release applying all findings from the internal code review: - Extract MSTV_Binary_Stream trait to eliminate the stream_absolute_file() duplication between download and preview handlers - Add server-side error_log() diagnostics for file-not-readable and stream-failure conditions in download and preview (server log only) - Move storage notice dismiss script from inline <script> to a dedicated admin-notice-dismiss.js asset enqueued via wp_enqueue_script() and wp_localize_script(); improves CSP compatibility - Cache orphaned-file count in a short-lived transient on the settings page to avoid O(n) filesystem stats on every page load; invalidated after cleanup - Extract build_audit_csv_row() private method from handle_export_audit_csv() - Add PHPDoc to security-critical filesystem methods (verify_path, get_verified_path, path_has_symlink) and bootstrap entry points - Apply PHP 8.0 type hints (array, mixed) to MSTV_Settings properties and method signatures; replace last stripslashes() with wp_unslash()
1 parent ab50423 commit ebad026

15 files changed

Lines changed: 200 additions & 101 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
Private document workspace for WordPress teams, agencies, and operations that need controlled file sharing outside the Media Library.
1111

12-
Current plugin version: `2.6.1`.
12+
Current plugin version: `2.6.2`.
1313

1414
If TeamVault is useful to you, consider [sponsoring the project on GitHub](https://github.com/sponsors/TheStreamCode) — it is developed and maintained for free, and sponsorships help keep it going.
1515

assets/js/admin-notice-dismiss.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/* global mstvNoticeData, ajaxurl */
2+
(function () {
3+
document.addEventListener('DOMContentLoaded', function () {
4+
document.querySelectorAll('.mstv-storage-security-notice').forEach(function (notice) {
5+
notice.addEventListener('click', function (e) {
6+
if (!e.target.classList.contains('notice-dismiss')) return;
7+
fetch(ajaxurl, {
8+
method: 'POST',
9+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
10+
body: 'action=mstv_dismiss_storage_notice&_ajax_nonce=' + encodeURIComponent(mstvNoticeData.dismissNonce),
11+
});
12+
});
13+
});
14+
});
15+
}());

changelog.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@ Mikesoft TeamVault Changelog
22

33
Entries are ordered newest first. Release dates are included where recorded.
44

5+
## 2.6.2 - 2026-06-10
6+
- Code quality: extracted shared binary streaming logic into a reusable `MSTV_Binary_Stream` trait, eliminating duplicate `stream_absolute_file()` implementations across download and preview handlers.
7+
- Code quality: added server-side `error_log()` diagnostics for file-not-readable and stream-failure conditions in download and preview handlers; errors go to the server log only, not to the user.
8+
- Code quality: moved the storage security notice dismiss script from an inline `<script>` block to a dedicated `admin-notice-dismiss.js` asset loaded with `wp_enqueue_script()` and `wp_localize_script()`; improves compatibility with strict Content Security Policy headers.
9+
- Performance: cached the orphaned-file count in a short-lived transient so the settings page does not repeat a filesystem stat per database record on every load; the cache is invalidated immediately after running orphan cleanup.
10+
- Code quality: extracted the audit CSV row builder into a dedicated private method `build_audit_csv_row()` in the admin class.
11+
- Code quality: added PHPDoc to the security-critical filesystem methods (`verify_path`, `get_verified_path`, `path_has_symlink`) and the bootstrap entry points (`init`, `init_services`, `init_rest_api`).
12+
- Code quality: applied PHP 8.0 type hints (`array`, `mixed`) to the untyped `MSTV_Settings` properties and method signatures; replaced the last `stripslashes()` call with the canonical `wp_unslash()`.
13+
514
## 2.6.1 - 2026-06-09
615
- Admin UI: added a discreet Sponsor link to the plugin row on the Plugins screen and a "Support the project" line in the TeamVault settings Information box. Both stay inside plugin-owned surfaces, with no dashboard-wide notices. TeamVault remains free with all governance features included; sponsorships support continued development.
716
- Code quality: annotated the governance repository queries (filtered activity log queries and the dynamic IN() placeholder list) for the WordPress Plugin Check static analysis. Comment-only changes with no runtime behavior differences.

includes/class-mstv-admin.php

Lines changed: 32 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -66,24 +66,6 @@ public function render_storage_security_notice(): void
6666
'mikesoft-teamvault'
6767
);
6868
echo '</p></div>';
69-
?>
70-
<script>
71-
(function () {
72-
document.addEventListener('DOMContentLoaded', function () {
73-
document.querySelectorAll('.mstv-storage-security-notice').forEach(function (notice) {
74-
notice.addEventListener('click', function (e) {
75-
if (!e.target.classList.contains('notice-dismiss')) return;
76-
fetch(ajaxurl, {
77-
method: 'POST',
78-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
79-
body: 'action=mstv_dismiss_storage_notice&_ajax_nonce=<?php echo esc_js(wp_create_nonce('mstv_dismiss_storage_notice')); ?>'
80-
});
81-
});
82-
});
83-
});
84-
})();
85-
</script>
86-
<?php
8769
}
8870

8971
public function handle_dismiss_storage_notice(): void
@@ -379,6 +361,8 @@ public function handle_cleanup_orphans(): void
379361

380362
$deletedCount = $this->cleanup_orphaned_files();
381363

364+
delete_transient('mstv_orphan_count_' . get_current_blog_id());
365+
382366
set_transient('mstv_cleanup_orphans_' . get_current_user_id(), [
383367
'deleted_count' => $deletedCount,
384368
], MINUTE_IN_SECONDS);
@@ -518,21 +502,7 @@ public function handle_export_audit_csv(): void
518502
do {
519503
$result = $repo->find_filtered($filters, $page, 200);
520504
foreach ($result['items'] as $log) {
521-
$context = json_decode($log->context ?? '{}', true);
522-
$name = '';
523-
if (is_array($context)) {
524-
$name = $context['filename'] ?? $context['name'] ?? $context['display_name'] ?? '';
525-
}
526-
fputcsv($out, [
527-
$log->created_at,
528-
$log->user_login ?? '',
529-
(int) $log->user_id,
530-
$log->action,
531-
$log->target_type,
532-
$log->target_id !== null ? (int) $log->target_id : '',
533-
$name,
534-
$log->ip_address ?? '',
535-
]);
505+
fputcsv($out, $this->build_audit_csv_row($log));
536506
}
537507
$page++;
538508
} while ($page <= ($result['pagination']['total_pages'] ?? 0));
@@ -542,6 +512,26 @@ public function handle_export_audit_csv(): void
542512
exit;
543513
}
544514

515+
private function build_audit_csv_row(object $log): array
516+
{
517+
$context = json_decode($log->context ?? '{}', true);
518+
$name = '';
519+
if (is_array($context)) {
520+
$name = $context['filename'] ?? $context['name'] ?? $context['display_name'] ?? '';
521+
}
522+
523+
return [
524+
$log->created_at,
525+
$log->user_login ?? '',
526+
(int) $log->user_id,
527+
$log->action,
528+
$log->target_type,
529+
$log->target_id !== null ? (int) $log->target_id : '',
530+
$name,
531+
$log->ip_address ?? '',
532+
];
533+
}
534+
545535
private function guard_stream_request(): void
546536
{
547537
if (!$this->current_user_can_manage()) {
@@ -580,6 +570,13 @@ private function build_files_services(): array
580570

581571
private function count_orphaned_files(): int
582572
{
573+
$cacheKey = 'mstv_orphan_count_' . get_current_blog_id();
574+
$cached = get_transient($cacheKey);
575+
576+
if ($cached !== false) {
577+
return (int) $cached;
578+
}
579+
583580
$services = $this->build_files_services();
584581
$filesystem = $services['storage']->get_filesystem();
585582
$count = 0;
@@ -590,6 +587,8 @@ private function count_orphaned_files(): int
590587
}
591588
}
592589

590+
set_transient($cacheKey, $count, 5 * MINUTE_IN_SECONDS);
591+
593592
return $count;
594593
}
595594

includes/class-mstv-assets.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,18 @@ public function enqueue_assets(string $hook): void
3838
true
3939
);
4040

41+
wp_enqueue_script(
42+
'mstv-admin-notices',
43+
MSTV_PLUGIN_URL . 'assets/js/admin-notice-dismiss.js',
44+
[],
45+
MSTV_VERSION,
46+
true
47+
);
48+
49+
wp_localize_script('mstv-admin-notices', 'mstvNoticeData', [
50+
'dismissNonce' => wp_create_nonce('mstv_dismiss_storage_notice'),
51+
]);
52+
4153
wp_localize_script('mstv-admin', 'mstvConfig', [
4254
'branding' => [
4355
'name' => $this->settings->get_brand_name(),

includes/class-mstv-bootstrap.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ private function __construct()
2020
{
2121
}
2222

23+
/**
24+
* Bootstrap the plugin: load all class files and register WP hooks.
25+
* Called directly from the plugin entry point after the singleton is created.
26+
*/
2327
public function init(): void
2428
{
2529
$this->load_dependencies();
@@ -47,6 +51,7 @@ private function load_dependencies(): void
4751
'class-mstv-permissions',
4852
'class-mstv-quota',
4953
'class-mstv-notifications',
54+
'trait-mstv-binary-stream',
5055
'class-mstv-download',
5156
'class-mstv-preview',
5257
'class-mstv-export',
@@ -109,6 +114,13 @@ public function deactivate(): void
109114
MSTV_Deactivator::deactivate();
110115
}
111116

117+
/**
118+
* Instantiate core services and wire their WP hooks.
119+
*
120+
* Runs on `init` at priority 5 (after maybe_upgrade at priority 1). Admin-only services
121+
* (MSTV_Admin, MSTV_Assets) are only instantiated when is_admin() is true to avoid loading
122+
* unnecessary code on front-end requests.
123+
*/
112124
public function init_services(): void
113125
{
114126
if (!isset($this->services['settings'])) {
@@ -161,6 +173,14 @@ public function initialize_site(\WP_Site $newSite): void
161173
MSTV_Activator::initialize_site((int) $newSite->blog_id);
162174
}
163175

176+
/**
177+
* Wire the REST API controllers with all required dependencies.
178+
*
179+
* Runs on `rest_api_init`. Builds the full dependency graph (settings → auth → storage →
180+
* repos → permissions → quota → download/preview) and registers routes for both
181+
* MSTV_REST_Controller (browser, files, exports) and MSTV_REST_Governance_Controller
182+
* (groups, permissions, quotas, reports).
183+
*/
164184
public function init_rest_api(): void
165185
{
166186
$settings = $this->service('settings');

includes/class-mstv-download.php

Lines changed: 5 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
class MSTV_Download
66
{
7+
use MSTV_Binary_Stream;
78
private MSTV_Storage $storage;
89
private MSTV_Repository_Files $filesRepo;
910
private MSTV_Auth $auth;
@@ -105,6 +106,8 @@ private function build_download_filename(string $displayName, string $extension)
105106
private function stream_file(string $path, string $filename, string $mimeType, int $fileSize): void
106107
{
107108
if (!is_readable($path)) {
109+
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Server-side diagnostic; not exposed to users.
110+
error_log('TeamVault: file not readable for download: ' . $path);
108111
wp_die(
109112
esc_html__('Unable to read the file.', 'mikesoft-teamvault'),
110113
esc_html__('Error', 'mikesoft-teamvault'),
@@ -126,6 +129,8 @@ private function stream_file(string $path, string $filename, string $mimeType, i
126129
header('X-Robots-Tag: noindex, nofollow');
127130

128131
if (!$this->stream_absolute_file($path)) {
132+
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Server-side diagnostic; not exposed to users.
133+
error_log('TeamVault: stream failed for download: ' . $path);
129134
wp_die(
130135
esc_html__('Unable to read the file.', 'mikesoft-teamvault'),
131136
esc_html__('Error', 'mikesoft-teamvault'),
@@ -136,35 +141,6 @@ private function stream_file(string $path, string $filename, string $mimeType, i
136141
exit;
137142
}
138143

139-
private function stream_absolute_file(string $path): bool
140-
{
141-
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Authenticated binary downloads need chunked streaming; WP_Filesystem::get_contents() loads full files into memory.
142-
$handle = @fopen($path, 'rb');
143-
144-
if ($handle === false) {
145-
return false;
146-
}
147-
148-
while (!feof($handle)) {
149-
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Authenticated binary downloads need chunked streaming; WP_Filesystem::get_contents() loads full files into memory.
150-
$chunk = fread($handle, 1048576);
151-
if ($chunk === false) {
152-
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing a local stream opened only for chunked binary output.
153-
fclose($handle);
154-
return false;
155-
}
156-
157-
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Binary files stream output must not be escaped.
158-
echo $chunk;
159-
flush();
160-
}
161-
162-
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing a local stream opened only for chunked binary output.
163-
fclose($handle);
164-
165-
return true;
166-
}
167-
168144
private function sanitize_filename(string $filename): string
169145
{
170146
return MSTV_Helpers::sanitize_archive_entry_segment($filename);

includes/class-mstv-filesystem.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@ public function resolve(string $relativePath): string
4444
return wp_normalize_path($this->basePath . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relativePath));
4545
}
4646

47+
/**
48+
* Confirm that an absolute path lives inside the base storage directory.
49+
*
50+
* Uses realpath() to resolve symlinks and then a normalized prefix comparison to block
51+
* traversal. Falls back to dirname()+basename() when the path does not yet exist (e.g.
52+
* a file being written for the first time).
53+
*
54+
* @param string $path Absolute filesystem path to validate.
55+
* @return bool True only if the path is inside the configured base directory.
56+
*/
4757
public function verify_path(string $path): bool
4858
{
4959
$realBase = realpath($this->basePath);
@@ -75,6 +85,18 @@ public function verify_path(string $path): bool
7585
|| strpos(trailingslashit($normalizedPath), $normalizedBase) === 0;
7686
}
7787

88+
/**
89+
* Resolve a relative path and run all security checks in one call.
90+
*
91+
* Combines verify_path() (boundary check), optional file-existence check, and
92+
* path_has_symlink() (symlink traversal check). Returns false on any failure so
93+
* callers can treat a false return as "access denied" without distinguishing the cause.
94+
*
95+
* @param string $relativePath Path relative to the base storage directory.
96+
* @param bool $allowMissing When true, skip the file-existence check (used for paths
97+
* that are about to be created).
98+
* @return string|false Absolute verified path, or false if any check fails.
99+
*/
78100
public function get_verified_path(string $relativePath, bool $allowMissing = false): string|false
79101
{
80102
$fullPath = $this->resolve($relativePath);
@@ -536,6 +558,16 @@ public function get_disk_stats(): array
536558
];
537559
}
538560

561+
/**
562+
* Walk each segment of a path and return true if any segment is a symlink.
563+
*
564+
* A simple is_link($path) is insufficient because an intermediate directory in the
565+
* path could be a symlink pointing outside the base directory. This method iterates
566+
* from the base root down to the target path, stopping at the first symlink found.
567+
*
568+
* @param string $path Absolute path to inspect (must already pass verify_path()).
569+
* @return bool True if any segment of the path is a symlink, false if the path is clean.
570+
*/
539571
private function path_has_symlink(string $path): bool
540572
{
541573
$realBase = realpath($this->basePath);

includes/class-mstv-preview.php

Lines changed: 5 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
class MSTV_Preview
66
{
7+
use MSTV_Binary_Stream;
78
private MSTV_Storage $storage;
89
private MSTV_Repository_Files $filesRepo;
910
private MSTV_Auth $auth;
@@ -115,6 +116,8 @@ public function serve(int $fileId): void
115116
private function stream_preview(string $path, string $filename, string $mimeType, int $fileSize): void
116117
{
117118
if (!is_readable($path)) {
119+
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Server-side diagnostic; not exposed to users.
120+
error_log('TeamVault: file not readable for preview: ' . $path);
118121
wp_die(
119122
esc_html__('Unable to read the file.', 'mikesoft-teamvault'),
120123
esc_html__('Error', 'mikesoft-teamvault'),
@@ -138,6 +141,8 @@ private function stream_preview(string $path, string $filename, string $mimeType
138141
}
139142

140143
if (!$this->stream_absolute_file($path)) {
144+
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Server-side diagnostic; not exposed to users.
145+
error_log('TeamVault: stream failed for preview: ' . $path);
141146
wp_die(
142147
esc_html__('Unable to read the file.', 'mikesoft-teamvault'),
143148
esc_html__('Error', 'mikesoft-teamvault'),
@@ -148,35 +153,6 @@ private function stream_preview(string $path, string $filename, string $mimeType
148153
exit;
149154
}
150155

151-
private function stream_absolute_file(string $path): bool
152-
{
153-
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Authenticated binary previews need chunked streaming; WP_Filesystem::get_contents() loads full files into memory.
154-
$handle = @fopen($path, 'rb');
155-
156-
if ($handle === false) {
157-
return false;
158-
}
159-
160-
while (!feof($handle)) {
161-
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Authenticated binary previews need chunked streaming; WP_Filesystem::get_contents() loads full files into memory.
162-
$chunk = fread($handle, 1048576);
163-
if ($chunk === false) {
164-
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing a local stream opened only for chunked binary output.
165-
fclose($handle);
166-
return false;
167-
}
168-
169-
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Binary preview stream output must not be escaped.
170-
echo $chunk;
171-
flush();
172-
}
173-
174-
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing a local stream opened only for chunked binary output.
175-
fclose($handle);
176-
177-
return true;
178-
}
179-
180156
private function sanitize_filename(string $filename): string
181157
{
182158
return MSTV_Helpers::sanitize_archive_entry_segment($filename);

0 commit comments

Comments
 (0)