PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.10.0
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.10.0
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 +280 -8 2.7.02.10.0 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'] = [];
@@ -158,8 +214,10 @@
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 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(),
162 220 'fallback' => apply_filters('fluent_community/fluent_player/fallback_timings', [
163 221 'content_timeout_ms' => 10000,
164 222 'script_timeout_ms' => 12000,
165 223 'script_grace_ms' => 2000,
@@ -170,8 +228,149 @@
170 228 }
171 229 return $data;
172 230 }
173 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 +
174 373 public function maybeAddFluentPlayerMedia($data, $requestData)
175 374 {
176 375 $media = Arr::get($requestData, 'meta.media_preview', []);
177 376 if ($newMedia = Arr::get($requestData, 'media')) {
@@ -180,10 +379,17 @@
180 379 if ($media && is_array($media) && Arr::get($media, 'player') == 'fluent_player') {
181 380 if (!isset($data['meta'])) {
182 381 $data['meta'] = [];
183 382 }
184 - $data['meta']['media_preview'] = array_filter($media);
383 + $data['meta']['media_preview'] = array_filter(self::sanitizeMediaHtml($media));
185 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 + }
186 392 return $data;
187 393 }
188 394 public function maybeUpdateFluentPlayerMedia($data, $requestData)
189 395 {
@@ -191,13 +397,34 @@
191 397 if ($media && is_array($media) && Arr::get($media, 'player') == 'fluent_player') {
192 398 if (!isset($data['meta'])) {
193 399 $data['meta'] = [];
194 400 }
195 - $data['meta']['media_preview'] = array_filter($media);
401 + $data['meta']['media_preview'] = array_filter(self::sanitizeMediaHtml($media));
196 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 + }
197 410 return $data;
198 411 }
199 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 +
200 427 public function maybeUpdateUploadedMedia($uploadedMedias, $requestData)
201 428 {
202 429 $media = Arr::get($requestData, 'meta.media_preview', []);
203 430 if ($newMedia = Arr::get($requestData, 'media')) {
@@ -205,11 +432,56 @@
205 432 }
206 433 if ($media && is_array($media) && Arr::get($media, 'player') == 'fluent_player' && $mediaId = Arr::get($media, 'media_id')) {
207 434 $mediaId = intval($mediaId);
208 435 if ($mediaId) {
209 - $media = Media::find($mediaId);
210 - if ($media) {
211 - $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;
212 484 }
213 485 }
214 486 }
215 487 return $uploadedMedias;