PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.10.01
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.10.01
2.10.0 2.10.01 2.9.1 2.9.0 2.8.1 2.8.0 2.7.7 2.7.5 2.7.0 2.6.01 2.6.0 2.5.0 2.4.01 trunk 1.0.90 1.0.91 1.0.92 1.0.93 1.0.94 1.0.95 1.0.96 1.0.97 1.0.98 1.0.99 1.1.0 All 77 releases
← All changes | Modules/Integrations/FluentPlayer/Bootstrap.php +288 -9 2.6.02.10.01 View file →
@@ -43,9 +43,9 @@
43 43 $this->app->addFilter('fluent_community/feed/new_feed_data', [$this, 'maybeAddFluentPlayerMedia'], 10, 2);
44 44 $this->app->addFilter('fluent_community/feed/update_feed_data', [$this, 'maybeUpdateFluentPlayerMedia'], 10, 2);
45 45 $this->app->addFilter('fluent_community/feed/uploaded_feed_medias', [$this, 'maybeUpdateUploadedMedia'], 10, 2);
46 46 }
47 -
47 +
48 48 /**
49 49 * Get FluentPlayer plugin status
50 50 *
51 51 * @return string 'active', 'installed', or 'not_installed'
@@ -78,8 +78,10 @@
78 78 'enable_fluent_player' => 'no',
79 79 'skin' => 'modern',
80 80 'brandColor' => '#4a90e2',
81 81 'controlBarColor' => '',
82 + 'playButtonColor' => '',
83 + 'playButtonBgColor' => '',
82 84 'controls' => [
83 85 'play' => true,
84 86 'volume' => true,
85 87 'progress_bar' => true,
@@ -101,14 +103,16 @@
101 103 'load_strategy' => 'visible'
102 104 ],
103 105 'video_upload' => 'no',
104 106 'video_upload_role' => 'admin',
105 - 'play_embedded_videos' => 'yes'
107 + 'play_embedded_videos' => 'yes',
108 + 'enable_audio' => 'no'
106 109 ];
107 110
108 111 $settings = Utility::getOption('_fluent_player_settings', $defaults);
109 112 $settings = wp_parse_args($settings, $defaults);
110 113 $settings['behaviors'] = wp_parse_args($settings['behaviors'], $defaults['behaviors']);
114 + $settings['enable_audio'] = $settings['enable_audio'] === 'yes' ? 'yes' : 'no';
111 115
112 116 return $settings;
113 117 }
114 118
@@ -118,13 +122,16 @@
118 122 'enable_fluent_player' => 'sanitize_text_field',
119 123 'skin' => 'sanitize_text_field',
120 124 'brandColor' => 'sanitize_text_field',
121 125 'controlBarColor' => 'sanitize_text_field',
126 + 'playButtonColor' => 'sanitize_text_field',
127 + 'playButtonBgColor' => 'sanitize_text_field',
122 128 'controls.*' => 'rest_sanitize_boolean',
123 129 'behaviors.*' => 'rest_sanitize_boolean',
124 130 'video_upload' => 'sanitize_text_field',
125 131 'video_upload_role' => 'sanitize_text_field',
126 - 'play_embedded_videos' => 'sanitize_text_field'
132 + 'play_embedded_videos' => 'sanitize_text_field',
133 + 'enable_audio' => 'sanitize_text_field'
127 134 ];
128 135
129 136 $prevSettings = self::getSettings();
130 137 $loadStrategy = sanitize_text_field(Arr::get($settings, 'behaviors.load_strategy', 'visible'));
@@ -133,8 +140,13 @@
133 140 $settings = Sanitizer::sanitize($settings, $sanitizerRules);
134 141
135 142 $allowedStrategies = ['eager', 'visible', 'idle', 'play'];
136 143 $settings['behaviors']['load_strategy'] = in_array($loadStrategy, $allowedStrategies) ? $loadStrategy : 'visible';
144 + $settings['enable_audio'] = Arr::get($settings, 'enable_audio') === 'yes' ? 'yes' : 'no';
145 + $settings['brandColor'] = self::sanitizeColor(Arr::get($settings, 'brandColor', ''));
146 + $settings['controlBarColor'] = self::sanitizeColor(Arr::get($settings, 'controlBarColor', ''));
147 + $settings['playButtonColor'] = self::sanitizeColor(Arr::get($settings, 'playButtonColor', ''));
148 + $settings['playButtonBgColor'] = self::sanitizeColor(Arr::get($settings, 'playButtonBgColor', ''));
137 149
138 150 Utility::updateOption('_fluent_player_settings', $settings);
139 151
140 152 return $settings;
@@ -139,8 +151,52 @@
139 151
140 152 return $settings;
141 153 }
142 154
155 + public static function sanitizeColor($value)
156 + {
157 + $value = trim((string) $value);
158 + if ($value === '') {
159 + return '';
160 + }
161 + $pattern = '/^(#[0-9a-fA-F]{3,8}|[a-zA-Z]+|(rgb|rgba|hsl|hsla)\([0-9a-zA-Z.,%\s\/]+\))$/';
162 + return preg_match($pattern, $value) ? $value : '';
163 + }
164 +
165 + public static function getAllowedMediaTypes($settings = null, $kind = null)
166 + {
167 + if ($settings === null) {
168 + $settings = self::getSettings();
169 + }
170 +
171 + $hasAudio = Arr::get($settings, 'enable_audio') === 'yes';
172 +
173 + $allowedVideoTypes = apply_filters('fluent_community/support_video_types', [
174 + 'video/mp4',
175 + 'video/webm',
176 + 'video/quicktime'
177 + ]);
178 +
179 + if ($kind === 'video') {
180 + return array_values(array_unique($allowedVideoTypes));
181 + }
182 +
183 + $allowedAudioTypes = $hasAudio ? apply_filters('fluent_community/support_audio_types', [
184 + 'audio/mpeg',
185 + 'audio/wav',
186 + 'audio/mp4',
187 + 'audio/aac',
188 + 'audio/ogg',
189 + 'audio/flac'
190 + ]) : [];
191 +
192 + if ($kind === 'audio') {
193 + return array_values(array_unique($allowedAudioTypes));
194 + }
195 +
196 + return array_values(array_unique(array_merge($allowedVideoTypes, $allowedAudioTypes)));
197 + }
198 +
143 199 public function getPortalVars($data)
144 200 {
145 201 if (!isset($data['features'])) {
146 202 $data['features'] = [];
@@ -157,14 +213,164 @@
157 213 $data['features']['fluent_player'] = [
158 214 'enable' => Arr::get($playerSettings, 'enable_fluent_player') === 'yes',
159 215 'has_video_upload' => Arr::get($playerSettings, 'video_upload') === 'yes',
160 216 'video_upload_role' => Arr::get($playerSettings, 'video_upload_role', 'admin'),
161 - 'play_embedded_videos' => Arr::get($playerSettings, 'play_embedded_videos', 'no') === 'yes'
217 + 'play_embedded_videos' => Arr::get($playerSettings, 'play_embedded_videos', 'no') === 'yes',
218 + 'has_audio_upload' => Arr::get($playerSettings, 'enable_audio') === 'yes',
219 + 'max_audios_per_post' => self::maxAudiosPerPost(),
220 + 'fallback' => apply_filters('fluent_community/fluent_player/fallback_timings', [
221 + 'content_timeout_ms' => 10000,
222 + 'script_timeout_ms' => 12000,
223 + 'script_grace_ms' => 2000,
224 + 'init_timeout_ms' => 3000,
225 + 'stall_timeout_ms' => 12000
226 + ])
162 227 ];
163 228 }
164 229 return $data;
165 230 }
166 231
232 + /**
233 + * Validate + cap the multi-audio array carried on a feed request.
234 + *
235 + * @return array list of fluent_player audio media entries (max N)
236 + */
237 + /**
238 + * From the submitted audio items, return the set of media ids the current user owns and may
239 + * attach: keyed by id for O(1) lookup. Eligible = own fluent_player media that is either an
240 + * unattached draft (is_active = 0) or already on the feed being edited.
241 + */
242 + private function eligibleAudioMediaIds($items, $requestData)
243 + {
244 + $candidateIds = [];
245 + foreach ((array) $items as $item) {
246 + if (is_array($item) && Arr::get($item, 'player') === 'fluent_player') {
247 + $id = intval(Arr::get($item, 'media_id'));
248 + if ($id) {
249 + $candidateIds[$id] = $id;
250 + }
251 + }
252 + }
253 + if (!$candidateIds) {
254 + return [];
255 + }
256 +
257 + $currentUserId = (int) get_current_user_id();
258 + $editingFeedId = intval(Arr::get($requestData, 'id', 0));
259 +
260 + $rows = $this->fetchOwnedAudioMediaRows(array_values($candidateIds), $currentUserId, $editingFeedId);
261 +
262 + $eligible = [];
263 + foreach ($rows as $row) {
264 + $eligible[(int) $row->id] = true;
265 + }
266 + return $eligible;
267 + }
268 +
269 + /**
270 + * Fetch the media rows the current user actually owns among the submitted ids: unattached
271 + * drafts (is_active = 0) or media already linked to the feed being edited. Isolated as a
272 + * protected seam so the sanitization logic can be unit-tested without a database.
273 + */
274 + protected function fetchOwnedAudioMediaRows(array $ids, $currentUserId, $editingFeedId)
275 + {
276 + return Media::whereIn('id', $ids)
277 + ->where('user_id', $currentUserId)
278 + ->where('media_type', 'fluent_player')
279 + ->where(function ($q) use ($editingFeedId) {
280 + $q->where('is_active', 0);
281 + if ($editingFeedId) {
282 + $q->orWhere('feed_id', $editingFeedId);
283 + }
284 + })
285 + ->get();
286 + }
287 +
288 + /**
289 + * The per-post audio cap, clamped to a positive hard ceiling that holds independent of the
290 + * filter output. A filter returning 0, a negative, or an absurdly large value can never widen
291 + * the bound that protects the sanitization/DB path (WHERE ... IN size, per-item PHP work).
292 + */
293 + public static function maxAudiosPerPost()
294 + {
295 + $filtered = (int) apply_filters('fluent_community/fluent_player/max_audios_per_post', 10);
296 + return max(1, min($filtered, 50));
297 + }
298 +
299 + private function sanitizeAudioMedias($requestData)
300 + {
301 + // Accept both the top-level key (create) and the nested meta key (edit round-trip).
302 + $items = Arr::get($requestData, 'audio_medias', Arr::get($requestData, 'meta.audio_medias', []));
303 + if (!is_array($items) || empty($items)) {
304 + return [];
305 + }
306 + $max = self::maxAudiosPerPost();
307 +
308 + // Truncate the submitted list to the hard ceiling BEFORE collecting candidate ids or
309 + // touching the database. Only up to $max entries can ever be persisted, so an
310 + // authenticated client padding the array with thousands of ids must not translate into a
311 + // giant WHERE ... IN clause or repeated per-item PHP work on each sanitization pass.
312 + $items = array_slice(array_values($items), 0, $max);
313 +
314 + // Resolve which submitted media the current user actually owns and may attach (an
315 + // unattached draft, or already on the feed being edited). Only these ids are persisted
316 + // to meta.audio_medias — a submitted id belonging to another user is dropped, so it can
317 + // never be stored or later loaded/rendered by the player endpoint.
318 + $eligibleIds = $this->eligibleAudioMediaIds($items, $requestData);
319 +
320 + $clean = [];
321 + foreach ($items as $item) {
322 + if (!is_array($item) || Arr::get($item, 'player') !== 'fluent_player') {
323 + continue;
324 + }
325 + $mediaId = intval(Arr::get($item, 'media_id'));
326 + if (!$mediaId || !isset($eligibleIds[$mediaId])) {
327 + continue;
328 + }
329 + // Build each stored entry from an explicit, per-field-sanitized allowlist rather
330 + // than persisting the raw client array.
331 + $settings = Arr::get($item, 'settings', []);
332 + $clean[] = array_filter([
333 + 'media_id' => $mediaId,
334 + 'player' => 'fluent_player',
335 + 'provider' => sanitize_text_field(Arr::get($item, 'provider', '')),
336 + 'content_type' => 'audio',
337 + 'url' => esc_url_raw(Arr::get($item, 'url', '')),
338 + 'title' => sanitize_text_field(Arr::get($item, 'title', '')),
339 + 'image' => esc_url_raw(Arr::get($item, 'image', '')),
340 + 'settings' => is_array($settings) ? array_filter([
341 + 'src' => esc_url_raw(Arr::get($settings, 'src', '')),
342 + 'title' => sanitize_text_field(Arr::get($settings, 'title', '')),
343 + 'posterSrc' => esc_url_raw(Arr::get($settings, 'posterSrc', '')),
344 + 'poster_is_custom' => filter_var(Arr::get($settings, 'poster_is_custom'), FILTER_VALIDATE_BOOLEAN),
345 + 'viewType' => 'audio',
346 + ], function ($value) {
347 + return $value !== '' && $value !== null;
348 + }) : [],
349 + ]);
350 + if (count($clean) >= $max) {
351 + break;
352 + }
353 + }
354 + return $clean;
355 + }
356 +
357 + /**
358 + * Extract the ?media_key= query arg from an uploaded-media URL (used to relink posters).
359 + */
360 + private function extractMediaKey($url)
361 + {
362 + if (!$url) {
363 + return '';
364 + }
365 + $query = wp_parse_url($url, PHP_URL_QUERY);
366 + if (!$query) {
367 + return '';
368 + }
369 + parse_str($query, $args);
370 + return isset($args['media_key']) ? sanitize_text_field($args['media_key']) : '';
371 + }
372 +
167 373 public function maybeAddFluentPlayerMedia($data, $requestData)
168 374 {
169 375 $media = Arr::get($requestData, 'meta.media_preview', []);
170 376 if ($newMedia = Arr::get($requestData, 'media')) {
@@ -173,10 +379,17 @@
173 379 if ($media && is_array($media) && Arr::get($media, 'player') == 'fluent_player') {
174 380 if (!isset($data['meta'])) {
175 381 $data['meta'] = [];
176 382 }
177 - $data['meta']['media_preview'] = array_filter($media);
383 + $data['meta']['media_preview'] = array_filter(self::sanitizeMediaHtml($media));
178 384 }
385 + $audioMedias = $this->sanitizeAudioMedias($requestData);
386 + if ($audioMedias) {
387 + if (!isset($data['meta'])) {
388 + $data['meta'] = [];
389 + }
390 + $data['meta']['audio_medias'] = $audioMedias;
391 + }
179 392 return $data;
180 393 }
181 394 public function maybeUpdateFluentPlayerMedia($data, $requestData)
182 395 {
@@ -184,13 +397,34 @@
184 397 if ($media && is_array($media) && Arr::get($media, 'player') == 'fluent_player') {
185 398 if (!isset($data['meta'])) {
186 399 $data['meta'] = [];
187 400 }
188 - $data['meta']['media_preview'] = array_filter($media);
401 + $data['meta']['media_preview'] = array_filter(self::sanitizeMediaHtml($media));
189 402 }
403 + $audioMedias = $this->sanitizeAudioMedias($requestData);
404 + if ($audioMedias) {
405 + if (!isset($data['meta'])) {
406 + $data['meta'] = [];
407 + }
408 + $data['meta']['audio_medias'] = $audioMedias;
409 + }
190 410 return $data;
191 411 }
192 412
413 + /**
414 + * media_preview.html is rendered with v-html in _MediaPreview.vue whenever the
415 + * player itself cannot handle the media, so request-supplied markup has to go
416 + * through the oembed allowlist before it is stored.
417 + */
418 + private static function sanitizeMediaHtml($media)
419 + {
420 + if (!empty($media['html'])) {
421 + $media['html'] = \FluentCommunity\App\Services\RemoteUrlParser::sanitizeOembedHtml($media['html']);
422 + }
423 +
424 + return $media;
425 + }
426 +
193 427 public function maybeUpdateUploadedMedia($uploadedMedias, $requestData)
194 428 {
195 429 $media = Arr::get($requestData, 'meta.media_preview', []);
196 430 if ($newMedia = Arr::get($requestData, 'media')) {
@@ -198,11 +432,56 @@
198 432 }
199 433 if ($media && is_array($media) && Arr::get($media, 'player') == 'fluent_player' && $mediaId = Arr::get($media, 'media_id')) {
200 434 $mediaId = intval($mediaId);
201 435 if ($mediaId) {
202 - $media = Media::find($mediaId);
203 - if ($media) {
204 - $uploadedMedias[] = $media;
436 + $currentUserId = (int) get_current_user_id();
437 + $mediaModel = Media::find($mediaId);
438 + // Owner, or a moderator/admin who can edit the media's feed — never cross-user.
439 + $canAttach = $mediaModel && $currentUserId && (
440 + (int) $mediaModel->user_id === $currentUserId
441 + || ($mediaModel->feed_id && $mediaModel->feed && $mediaModel->feed->hasEditAccess($currentUserId))
442 + );
443 + if ($canAttach) {
444 + $uploadedMedias[] = $mediaModel;
445 + }
446 + }
447 + }
448 + $audioMedias = $this->sanitizeAudioMedias($requestData);
449 + if ($audioMedias) {
450 + $currentUserId = (int) get_current_user_id();
451 + $editingFeedId = intval(Arr::get($requestData, 'id', 0));
452 + // Only the current user's own media may be attached, and only if it is an unattached
453 + // draft or already belongs to the feed being edited — never another user's row or a
454 + // row attached elsewhere (prevents cross-user media reassignment via sequential ids).
455 + $eligible = function ($query) use ($currentUserId, $editingFeedId) {
456 + $query->where('user_id', $currentUserId)
457 + ->where(function ($q) use ($editingFeedId) {
458 + $q->where('is_active', 0);
459 + if ($editingFeedId) {
460 + $q->orWhere('feed_id', $editingFeedId);
461 + }
462 + });
463 + return $query;
464 + };
465 +
466 + $audioIds = array_values(array_filter(array_map(function ($audio) {
467 + return intval(Arr::get($audio, 'media_id'));
468 + }, $audioMedias)));
469 + $posterKeys = array_values(array_filter(array_map(function ($audio) {
470 + return $this->extractMediaKey(Arr::get($audio, 'settings.posterSrc', ''));
471 + }, $audioMedias)));
472 +
473 + if ($audioIds) {
474 + $audioQuery = Media::whereIn('id', $audioIds)->where('media_type', 'fluent_player');
475 + foreach ($eligible($audioQuery)->get() as $audioModel) {
476 + $uploadedMedias[] = $audioModel;
477 + }
478 + }
479 + // Activate + link the custom poster media rows so they survive the draft GC.
480 + if ($posterKeys) {
481 + $posterQuery = Media::whereIn('media_key', $posterKeys);
482 + foreach ($eligible($posterQuery)->get() as $posterModel) {
483 + $uploadedMedias[] = $posterModel;
205 484 }
206 485 }
207 486 }
208 487 return $uploadedMedias;