PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.7.5
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.7.5
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 2.7.5, at Modules/Integrations/FluentPlayer/Bootstrap.php

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