PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / trunk
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses vtrunk
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
fluent-community / Modules / Integrations / FluentPlayer / Bootstrap.php

Bootstrap.php in FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses trunk, at Modules/Integrations/FluentPlayer/Bootstrap.php

490 lines 19.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCommunity\Modules\Integrations\FluentPlayer;
4
5 use FluentCommunity\App\Functions\Utility;
6 use FluentCommunity\Framework\Support\Sanitizer;
7 use FluentCommunity\Framework\Support\Arr;
8 use FluentCommunity\App\Models\Media;
9
10 class Bootstrap
11 {
12 protected $app = null;
13
14 /**
15 * Cached plugin status to avoid repeated file system checks
16 */
17 private static $status = null;
18
19 public function register($app)
20 {
21 $app->router->group(function ($router) {
22 require_once __DIR__ . '/Http/player_api.php';
23 });
24
25 $this->app = $app;
26 $this->init();
27
28 }
29
30 public function init()
31 {
32 // Always register portal vars hook to provide plugin status
33 $this->app->addFilter('fluent_community/portal_vars', [$this, 'getPortalVars']);
34
35 // Only register feed functionality if plugin is active
36 if (defined('FLUENT_PLAYER_VERSION')) {
37 $this->registerFeedHooks();
38 }
39 }
40
41 private function registerFeedHooks()
42 {
43 $this->app->addFilter('fluent_community/feed/new_feed_data', [$this, 'maybeAddFluentPlayerMedia'], 10, 2);
44 $this->app->addFilter('fluent_community/feed/update_feed_data', [$this, 'maybeUpdateFluentPlayerMedia'], 10, 2);
45 $this->app->addFilter('fluent_community/feed/uploaded_feed_medias', [$this, 'maybeUpdateUploadedMedia'], 10, 2);
46 }
47
48 /**
49 * Get FluentPlayer plugin status
50 *
51 * @return string 'active', 'installed', or 'not_installed'
52 */
53 public static function getPluginStatus()
54 {
55 if (self::$status !== null) {
56 return self::$status;
57 }
58
59 if (defined('FLUENT_PLAYER_VERSION')) {
60 return self::$status = 'active';
61 }
62
63 if (file_exists(WP_PLUGIN_DIR . '/fluent-player/fluent-player.php')) {
64 return self::$status = 'installed';
65 }
66
67 return self::$status = 'not_installed';
68 }
69
70 public static function getSettings()
71 {
72 static $settings = null;
73 if ($settings) {
74 return $settings;
75 }
76
77 $defaults = [
78 'enable_fluent_player' => 'no',
79 'skin' => 'modern',
80 'brandColor' => '#4a90e2',
81 'controlBarColor' => '',
82 'playButtonColor' => '',
83 'playButtonBgColor' => '',
84 'controls' => [
85 'play' => true,
86 'volume' => true,
87 'progress_bar' => true,
88 'current_time' => true,
89 'captions_toggle' => true,
90 'playback_speed' => true,
91 'settings' => true,
92 'pip' => true,
93 'fullscreen' => true,
94 'backward' => true,
95 'forward' => true
96 ],
97 'behaviors' => [
98 'muted_autoplay' => false,
99 'save_play_position' => false,
100 'hide_top_controls' => false,
101 'hide_center_controls' => false,
102 'hide_bottom_controls' => false,
103 'load_strategy' => 'visible'
104 ],
105 'video_upload' => 'no',
106 'video_upload_role' => 'admin',
107 'play_embedded_videos' => 'yes',
108 'enable_audio' => 'no'
109 ];
110
111 $settings = Utility::getOption('_fluent_player_settings', $defaults);
112 $settings = wp_parse_args($settings, $defaults);
113 $settings['behaviors'] = wp_parse_args($settings['behaviors'], $defaults['behaviors']);
114 $settings['enable_audio'] = $settings['enable_audio'] === 'yes' ? 'yes' : 'no';
115
116 return $settings;
117 }
118
119 public static function updateSettings($settings)
120 {
121 $sanitizerRules = [
122 'enable_fluent_player' => 'sanitize_text_field',
123 'skin' => 'sanitize_text_field',
124 'brandColor' => 'sanitize_text_field',
125 'controlBarColor' => 'sanitize_text_field',
126 'playButtonColor' => 'sanitize_text_field',
127 'playButtonBgColor' => 'sanitize_text_field',
128 'controls.*' => 'rest_sanitize_boolean',
129 'behaviors.*' => 'rest_sanitize_boolean',
130 'video_upload' => 'sanitize_text_field',
131 'video_upload_role' => 'sanitize_text_field',
132 'play_embedded_videos' => 'sanitize_text_field',
133 'enable_audio' => 'sanitize_text_field'
134 ];
135
136 $prevSettings = self::getSettings();
137 $loadStrategy = sanitize_text_field(Arr::get($settings, 'behaviors.load_strategy', 'visible'));
138 $settings = Arr::only($settings, array_keys($prevSettings));
139 $settings = wp_parse_args($settings, $prevSettings);
140 $settings = Sanitizer::sanitize($settings, $sanitizerRules);
141
142 $allowedStrategies = ['eager', 'visible', 'idle', 'play'];
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', ''));
149
150 Utility::updateOption('_fluent_player_settings', $settings);
151
152 return $settings;
153 }
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
199 public function getPortalVars($data)
200 {
201 if (!isset($data['features'])) {
202 $data['features'] = [];
203 }
204
205 $status = self::getPluginStatus();
206
207 $data['features']['fluent_player_status'] = $status;
208 $data['features']['has_fluent_player'] = ($status === 'active');
209
210 if ($status === 'active') {
211 $playerSettings = self::getSettings();
212 $data['features']['enable_fluent_player'] = $playerSettings['enable_fluent_player'];
213 $data['features']['fluent_player'] = [
214 'enable' => Arr::get($playerSettings, 'enable_fluent_player') === 'yes',
215 'has_video_upload' => Arr::get($playerSettings, 'video_upload') === 'yes',
216 'video_upload_role' => Arr::get($playerSettings, 'video_upload_role', 'admin'),
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 ])
227 ];
228 }
229 return $data;
230 }
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
373 public function maybeAddFluentPlayerMedia($data, $requestData)
374 {
375 $media = Arr::get($requestData, 'meta.media_preview', []);
376 if ($newMedia = Arr::get($requestData, 'media')) {
377 $media = $newMedia;
378 }
379 if ($media && is_array($media) && Arr::get($media, 'player') == 'fluent_player') {
380 if (!isset($data['meta'])) {
381 $data['meta'] = [];
382 }
383 $data['meta']['media_preview'] = array_filter(self::sanitizeMediaHtml($media));
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 }
392 return $data;
393 }
394 public function maybeUpdateFluentPlayerMedia($data, $requestData)
395 {
396 $media = Arr::get($requestData, 'media');
397 if ($media && is_array($media) && Arr::get($media, 'player') == 'fluent_player') {
398 if (!isset($data['meta'])) {
399 $data['meta'] = [];
400 }
401 $data['meta']['media_preview'] = array_filter(self::sanitizeMediaHtml($media));
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 }
410 return $data;
411 }
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
427 public function maybeUpdateUploadedMedia($uploadedMedias, $requestData)
428 {
429 $media = Arr::get($requestData, 'meta.media_preview', []);
430 if ($newMedia = Arr::get($requestData, 'media')) {
431 $media = $newMedia;
432 }
433 if ($media && is_array($media) && Arr::get($media, 'player') == 'fluent_player' && $mediaId = Arr::get($media, 'media_id')) {
434 $mediaId = intval($mediaId);
435 if ($mediaId) {
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;
484 }
485 }
486 }
487 return $uploadedMedias;
488 }
489 }
490