PluginProbe
Video Dashboard / trunk
Video Dashboard vtrunk
trunk 1.0 1.1 1.1.1 1.2.0 1.2.1 1.2.1.1 2.0.0
video-dashboard / video-dashboard.php

video-dashboard.php in Video Dashboard trunk, at video-dashboard.php

603 lines 18.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Plugin Name: Video Dashboard
4 * Plugin URI: https://pagecrafter.com/embed-videos-in-dashboard-plugin/
5 * Description: Build a video training and resource library directly in the WordPress dashboard with drag-and-drop ordering, role controls, titles, descriptions, and support for popular video providers.
6 * Version: 2.0.0
7 * Author: PageCrafter
8 * Author URI: https://pagecrafter.com
9 * License: GPLv2 or later
10 * Text Domain: video-dashboard
11 */
12
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 define( 'VDB_VERSION', '2.0.0-beta5' );
18 define( 'VDB_PLUGIN_FILE', __FILE__ );
19 define( 'VDB_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
20 define( 'VDB_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
21
22 require_once VDB_PLUGIN_DIR . 'includes/admin-page.php';
23
24 register_activation_hook( __FILE__, 'vdb_activate' );
25 add_action( 'plugins_loaded', 'vdb_maybe_migrate_settings' );
26 add_action( 'wp_dashboard_setup', 'vdb_dashboard' );
27 add_action( 'admin_enqueue_scripts', 'vdb_load_scripts' );
28 add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), 'vdb_plugin_action_links' );
29
30 /**
31 * Add quick links to the Plugins screen so administrators can immediately
32 * configure the plugin or jump to the dashboard where videos are displayed.
33 */
34 function vdb_plugin_action_links( $links ) {
35 $quick_links = array(
36 '<a href="' . esc_url( admin_url( 'options-general.php?page=vdb-options' ) ) . '">' . esc_html__( 'Settings', 'video-dashboard' ) . '</a>',
37 '<a href="' . esc_url( admin_url( 'index.php' ) ) . '">' . esc_html__( 'View Videos', 'video-dashboard' ) . '</a>',
38 );
39
40 return array_merge( $quick_links, $links );
41 }
42
43 /**
44 * Create default settings for a brand-new installation.
45 * Existing installations are intentionally left untouched here.
46 */
47 function vdb_activate() {
48 if ( false === get_option( 'vdb_settings', false ) ) {
49 add_option(
50 'vdb_settings',
51 array(
52 'minimum_role' => 'administrator',
53 'videos' => array(),
54 'schema_version'=> 2,
55 )
56 );
57 }
58 }
59
60 /**
61 * Convert the legacy youtube_id1...youtube_id50 structure into a video array.
62 *
63 * Important compatibility behavior:
64 * - The original values are copied exactly as they were stored.
65 * - Legacy keys are NOT deleted.
66 * - Migration runs only when the new `videos` key does not exist.
67 * - Only videos within the legacy youtube_number are migrated, matching what
68 * the old plugin would actually have displayed.
69 */
70 function vdb_maybe_migrate_settings() {
71 $settings = get_option( 'vdb_settings', false );
72
73 if ( false === $settings || ! is_array( $settings ) ) {
74 return;
75 }
76
77 if ( array_key_exists( 'videos', $settings ) ) {
78 return;
79 }
80
81 $legacy_count = isset( $settings['youtube_number'] ) ? absint( $settings['youtube_number'] ) : 0;
82 $legacy_count = min( 50, $legacy_count );
83 $videos = array();
84
85 for ( $i = 1; $i <= $legacy_count; $i++ ) {
86 $key = 'youtube_id' . $i;
87
88 if ( ! array_key_exists( $key, $settings ) ) {
89 continue;
90 }
91
92 // Do not sanitize or normalize here. Preserving the exact old value is deliberate.
93 $legacy_value = is_scalar( $settings[ $key ] ) ? (string) $settings[ $key ] : '';
94
95 if ( '' === trim( $legacy_value ) ) {
96 continue;
97 }
98
99 $videos[] = array(
100 'url' => $legacy_value,
101 'title' => '',
102 'description' => '',
103 );
104 }
105
106 $settings['videos'] = $videos;
107 $settings['schema_version'] = 2;
108
109 update_option( 'vdb_settings', $settings );
110 }
111
112 /**
113 * Enqueue plugin assets only where they are needed.
114 */
115 function vdb_load_scripts( $hook ) {
116 if ( 'index.php' === $hook ) {
117 wp_enqueue_style(
118 'vdb-dashboard',
119 VDB_PLUGIN_URL . 'includes/video-dashboard.css',
120 array(),
121 VDB_VERSION
122 );
123 }
124
125 if ( 'settings_page_vdb-options' === $hook ) {
126 wp_enqueue_style(
127 'vdb-admin',
128 VDB_PLUGIN_URL . 'includes/admin-page.css',
129 array(),
130 VDB_VERSION
131 );
132
133 wp_enqueue_script( 'jquery-ui-sortable' );
134 wp_enqueue_script(
135 'vdb-admin',
136 VDB_PLUGIN_URL . 'includes/admin-page.js',
137 array( 'jquery', 'jquery-ui-sortable' ),
138 VDB_VERSION,
139 true
140 );
141
142 wp_localize_script(
143 'vdb-admin',
144 'vdbAdmin',
145 array(
146 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
147 'nonce' => wp_create_nonce( 'vdb_video_preview' ),
148 'previewAction' => 'vdb_get_video_preview',
149 'previewing' => __( 'Loading preview…', 'video-dashboard' ),
150 'previewError' => __( 'Preview unavailable. The video may still work on the dashboard.', 'video-dashboard' ),
151 'notFoundError' => __( 'Video not found. Check the URL or confirm the video is still available.', 'video-dashboard' ),
152 'videoLabel' => __( 'Video', 'video-dashboard' ),
153 'removeLabel' => __( 'Remove', 'video-dashboard' ),
154 )
155 );
156 }
157 }
158
159 /**
160 * Add the dashboard widget when the current user meets the configured role.
161 */
162 function vdb_dashboard() {
163 $settings = vdb_get_settings();
164
165 if ( ! vdb_current_user_can_view( $settings['minimum_role'] ) ) {
166 return;
167 }
168
169 wp_add_dashboard_widget(
170 'vdb_youtube_videos',
171 __( 'Videos', 'video-dashboard' ),
172 'vdb_display_videos'
173 );
174 }
175
176 /**
177 * Retrieve settings with safe defaults.
178 */
179 function vdb_get_settings() {
180 $settings = get_option( 'vdb_settings', array() );
181
182 if ( ! is_array( $settings ) ) {
183 $settings = array();
184 }
185
186 if ( empty( $settings['minimum_role'] ) ) {
187 $settings['minimum_role'] = 'administrator';
188 }
189
190 if ( ! isset( $settings['videos'] ) || ! is_array( $settings['videos'] ) ) {
191 $settings['videos'] = array();
192 }
193
194 return $settings;
195 }
196
197 /**
198 * Match the original plugin's minimum-role hierarchy without relying on a
199 * user's primary role or on a single capability that custom roles may inherit.
200 */
201 function vdb_current_user_can_view( $minimum_role ) {
202 $role_levels = array(
203 'subscriber' => 0,
204 'contributor' => 1,
205 'author' => 2,
206 'editor' => 3,
207 'administrator' => 4,
208 );
209
210 if ( ! isset( $role_levels[ $minimum_role ] ) ) {
211 $minimum_role = 'administrator';
212 }
213
214 $user = wp_get_current_user();
215
216 if ( ! $user || empty( $user->roles ) ) {
217 return false;
218 }
219
220 // Preserve the old behavior that administrators always see the widget.
221 if ( in_array( 'administrator', (array) $user->roles, true ) ) {
222 return true;
223 }
224
225 foreach ( (array) $user->roles as $role ) {
226 if ( isset( $role_levels[ $role ] ) && $role_levels[ $role ] >= $role_levels[ $minimum_role ] ) {
227 return true;
228 }
229 }
230
231 return false;
232 }
233
234 /**
235 * Display all configured videos.
236 */
237 function vdb_display_videos() {
238 $settings = vdb_get_settings();
239
240 if ( empty( $settings['videos'] ) ) {
241 return;
242 }
243
244 foreach ( $settings['videos'] as $index => $video ) {
245 if ( ! is_array( $video ) || empty( $video['url'] ) ) {
246 continue;
247 }
248
249 $value = (string) $video['url'];
250 $title = isset( $video['title'] ) ? trim( (string) $video['title'] ) : '';
251 $description = isset( $video['description'] ) ? trim( (string) $video['description'] ) : '';
252 $embed = vdb_get_embed_html( $value );
253
254 if ( '' === $embed ) {
255 printf(
256 '<p class="vdb-error">%s</p>',
257 esc_html(
258 sprintf(
259 /* translators: %d: video number. */
260 __( 'Error: Video #%d could not be embedded.', 'video-dashboard' ),
261 $index + 1
262 )
263 )
264 );
265 continue;
266 }
267
268 echo '<div class="vdb-video">';
269
270 if ( '' !== $title ) {
271 echo '<h3 class="vdb-video-title">' . esc_html( $title ) . '</h3>';
272 }
273
274 if ( '' !== $description ) {
275 echo '<div class="vdb-video-description">' . wpautop( wp_kses_post( $description ) ) . '</div>';
276 }
277
278 echo '<div class="video-container">' . $embed . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Embed HTML is generated/filtered by this plugin or WordPress oEmbed.
279 echo '</div>';
280 }
281 }
282
283 /**
284 * Resolve a stored value into safe embed HTML.
285 *
286 * Order is deliberate:
287 * 1. Local YouTube parser (works without a remote oEmbed request).
288 * 2. Local Vimeo parser, including common unlisted privacy hashes.
289 * 3. WordPress oEmbed/discovery for other providers.
290 * 4. The exact legacy parsing strategy as a final compatibility fallback.
291 */
292 function vdb_get_embed_html( $value ) {
293 $value = trim( (string) $value );
294
295 if ( '' === $value ) {
296 return '';
297 }
298
299 $youtube_id = vdb_extract_youtube_id( $value );
300 if ( $youtube_id ) {
301 return vdb_youtube_iframe( $youtube_id );
302 }
303
304 $vimeo = vdb_extract_vimeo_data( $value );
305 if ( $vimeo ) {
306 return vdb_vimeo_iframe( $vimeo['id'], $vimeo['hash'] );
307 }
308
309 $wistia_id = vdb_extract_wistia_id( $value );
310 if ( $wistia_id ) {
311 return vdb_wistia_iframe( $wistia_id );
312 }
313
314 $vidyard_id = vdb_extract_vidyard_id( $value );
315 if ( $vidyard_id ) {
316 return vdb_vidyard_embed( $vidyard_id );
317 }
318
319 if ( filter_var( $value, FILTER_VALIDATE_URL ) ) {
320 $oembed = wp_oembed_get(
321 $value,
322 array(
323 'discover' => true,
324 )
325 );
326
327 if ( $oembed ) {
328 return wp_kses(
329 $oembed,
330 vdb_allowed_embed_html()
331 );
332 }
333 }
334
335 // Last resort: preserve the old plugin's interpretation exactly enough
336 // that odd legacy values that worked before still have a chance to work.
337 return vdb_legacy_embed_html( $value );
338 }
339
340 /**
341 * Extract a YouTube ID from raw IDs and common current/legacy URL formats.
342 */
343 function vdb_extract_youtube_id( $value ) {
344 $value = trim( (string) $value );
345
346 if ( preg_match( '/^[A-Za-z0-9_-]{11}$/', $value ) ) {
347 return $value;
348 }
349
350 $parts = wp_parse_url( $value );
351 if ( ! is_array( $parts ) || empty( $parts['host'] ) ) {
352 return false;
353 }
354
355 $host = strtolower( preg_replace( '/^www\./', '', $parts['host'] ) );
356 $path = isset( $parts['path'] ) ? trim( $parts['path'], '/' ) : '';
357 $id = '';
358
359 if ( in_array( $host, array( 'youtu.be' ), true ) ) {
360 $segments = explode( '/', $path );
361 $id = isset( $segments[0] ) ? $segments[0] : '';
362 } elseif ( in_array( $host, array( 'youtube.com', 'm.youtube.com', 'music.youtube.com', 'youtube-nocookie.com' ), true ) ) {
363 if ( isset( $parts['query'] ) ) {
364 parse_str( $parts['query'], $query );
365 if ( ! empty( $query['v'] ) ) {
366 $id = $query['v'];
367 }
368 }
369
370 if ( '' === $id && '' !== $path ) {
371 $segments = explode( '/', $path );
372 if ( count( $segments ) >= 2 && in_array( $segments[0], array( 'embed', 'shorts', 'live', 'v' ), true ) ) {
373 $id = $segments[1];
374 }
375 }
376 }
377
378 if ( preg_match( '/^[A-Za-z0-9_-]{11}$/', $id ) ) {
379 return $id;
380 }
381
382 return false;
383 }
384
385 /**
386 * Extract Vimeo ID and, where present, an unlisted-video privacy hash.
387 */
388 function vdb_extract_vimeo_data( $value ) {
389 $parts = wp_parse_url( trim( (string) $value ) );
390
391 if ( ! is_array( $parts ) || empty( $parts['host'] ) ) {
392 return false;
393 }
394
395 $host = strtolower( preg_replace( '/^www\./', '', $parts['host'] ) );
396 if ( ! in_array( $host, array( 'vimeo.com', 'player.vimeo.com' ), true ) ) {
397 return false;
398 }
399
400 $path = isset( $parts['path'] ) ? trim( $parts['path'], '/' ) : '';
401 $segments = array_values( array_filter( explode( '/', $path ), 'strlen' ) );
402 $id = '';
403 $hash = '';
404 $id_index = null;
405
406 foreach ( $segments as $index => $segment ) {
407 if ( preg_match( '/^\d{6,12}$/', $segment ) ) {
408 $id = $segment;
409 $id_index = $index;
410 break;
411 }
412 }
413
414 if ( '' === $id ) {
415 return false;
416 }
417
418 if ( null !== $id_index && isset( $segments[ $id_index + 1 ] ) && preg_match( '/^[A-Za-z0-9]+$/', $segments[ $id_index + 1 ] ) ) {
419 $hash = $segments[ $id_index + 1 ];
420 }
421
422 if ( isset( $parts['query'] ) ) {
423 parse_str( $parts['query'], $query );
424 if ( ! empty( $query['h'] ) && preg_match( '/^[A-Za-z0-9]+$/', $query['h'] ) ) {
425 $hash = $query['h'];
426 }
427 }
428
429 return array(
430 'id' => $id,
431 'hash' => $hash,
432 );
433 }
434
435 /**
436 * Extract a Wistia media ID from common public and embed URL formats.
437 */
438 function vdb_extract_wistia_id( $value ) {
439 $parts = wp_parse_url( trim( (string) $value ) );
440
441 if ( ! is_array( $parts ) || empty( $parts['host'] ) ) {
442 return false;
443 }
444
445 $host = strtolower( preg_replace( '/^www\./', '', $parts['host'] ) );
446 if ( false === strpos( $host, 'wistia.com' ) && 'wi.st' !== $host && false === strpos( $host, 'wistia.net' ) ) {
447 return false;
448 }
449
450 $path = isset( $parts['path'] ) ? trim( $parts['path'], '/' ) : '';
451 if ( preg_match( '#(?:medias|embed/iframe)/([A-Za-z0-9]+)#', $path, $matches ) ) {
452 return $matches[1];
453 }
454
455 return false;
456 }
457
458 /**
459 * Extract a Vidyard player UUID from common sharing and player URLs.
460 */
461 function vdb_extract_vidyard_id( $value ) {
462 $parts = wp_parse_url( trim( (string) $value ) );
463
464 if ( ! is_array( $parts ) || empty( $parts['host'] ) ) {
465 return false;
466 }
467
468 $host = strtolower( preg_replace( '/^www\./', '', $parts['host'] ) );
469 if ( false === strpos( $host, 'vidyard.com' ) ) {
470 return false;
471 }
472
473 $path = isset( $parts['path'] ) ? trim( $parts['path'], '/' ) : '';
474 $segments = array_values( array_filter( explode( '/', $path ), 'strlen' ) );
475
476 if ( 'share.vidyard.com' === $host && count( $segments ) >= 2 && 'watch' === $segments[0] ) {
477 return preg_match( '/^[A-Za-z0-9_-]+$/', $segments[1] ) ? $segments[1] : false;
478 }
479
480 if ( in_array( $host, array( 'play.vidyard.com', 'embed.vidyard.com' ), true ) && ! empty( $segments ) ) {
481 $candidate = end( $segments );
482 $candidate = preg_replace( '/\.(?:jpg|jpeg|png|html|js)$/i', '', $candidate );
483 return preg_match( '/^[A-Za-z0-9_-]+$/', $candidate ) ? $candidate : false;
484 }
485
486 return false;
487 }
488
489 /**
490 * Build a Wistia iframe using the provider's documented iframe URL format.
491 */
492 function vdb_wistia_iframe( $media_id ) {
493 $src = 'https://fast.wistia.net/embed/iframe/' . rawurlencode( $media_id );
494
495 return sprintf(
496 '<iframe src="%s" title="%s" frameborder="0" allow="autoplay; fullscreen" allowtransparency="true" scrolling="no" allowfullscreen></iframe>',
497 esc_url( $src ),
498 esc_attr__( 'Wistia video player', 'video-dashboard' )
499 );
500 }
501
502 /**
503 * Build Vidyard's current responsive inline player markup from a known player ID.
504 */
505 function vdb_vidyard_embed( $player_id ) {
506 $id = preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $player_id );
507 if ( '' === $id ) {
508 return '';
509 }
510
511 return sprintf(
512 '<script type="text/javascript" async src="https://play.vidyard.com/embed/v4.js"></script><img class="vidyard-player-embed" style="width:100%%;margin:auto;display:block;" src="https://play.vidyard.com/%1$s.jpg" data-uuid="%1$s" data-v="4" data-type="inline" alt="%2$s">',
513 esc_attr( $id ),
514 esc_attr__( 'Vidyard video player', 'video-dashboard' )
515 );
516 }
517
518 /**
519 * Build a YouTube iframe.
520 */
521 function vdb_youtube_iframe( $video_id ) {
522 $src = 'https://www.youtube.com/embed/' . rawurlencode( $video_id );
523
524 return sprintf(
525 '<iframe src="%s" title="%s" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>',
526 esc_url( $src ),
527 esc_attr__( 'YouTube video player', 'video-dashboard' )
528 );
529 }
530
531 /**
532 * Build a Vimeo iframe.
533 */
534 function vdb_vimeo_iframe( $video_id, $hash = '' ) {
535 $src = 'https://player.vimeo.com/video/' . rawurlencode( $video_id );
536
537 if ( '' !== $hash ) {
538 $src = add_query_arg( 'h', rawurlencode( $hash ), $src );
539 }
540
541 return sprintf(
542 '<iframe src="%s" title="%s" frameborder="0" allow="autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>',
543 esc_url( $src ),
544 esc_attr__( 'Vimeo video player', 'video-dashboard' )
545 );
546 }
547
548 /**
549 * Reproduce the original plugin's parsing logic as a final fallback.
550 */
551 function vdb_legacy_embed_html( $legacy_value ) {
552 $legacy_value = (string) $legacy_value;
553 $video_id = $legacy_value;
554
555 if ( false !== strpos( $legacy_value, 'v=' ) ) {
556 $query = wp_parse_url( $legacy_value, PHP_URL_QUERY );
557 if ( is_string( $query ) ) {
558 parse_str( $query, $vars );
559 if ( isset( $vars['v'] ) ) {
560 $video_id = (string) $vars['v'];
561 }
562 }
563 }
564
565 if ( 11 === strlen( $video_id ) ) {
566 // Old code accepted any 11-character string. Keep that behavior, but
567 // constrain it before output so it cannot create an unsafe iframe URL.
568 if ( preg_match( '/^[A-Za-z0-9_-]{11}$/', $video_id ) ) {
569 return vdb_youtube_iframe( $video_id );
570 }
571 return '';
572 }
573
574 if ( preg_match( '/vimeo\.com\/([0-9]{1,10})/', $legacy_value, $matches ) && ! empty( $matches[1] ) ) {
575 return vdb_vimeo_iframe( $matches[1] );
576 }
577
578 return '';
579 }
580
581 /**
582 * Allowed HTML for WordPress oEmbed output.
583 */
584 function vdb_allowed_embed_html() {
585 $allowed = wp_kses_allowed_html( 'post' );
586
587 $allowed['iframe'] = array(
588 'src' => true,
589 'width' => true,
590 'height' => true,
591 'frameborder' => true,
592 'allow' => true,
593 'allowfullscreen' => true,
594 'title' => true,
595 'loading' => true,
596 'referrerpolicy' => true,
597 'style' => true,
598 'class' => true,
599 );
600
601 return $allowed;
602 }
603