| @@ -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; |