'list' === $a ) ); } add_action( 'admin_init', array( $this, 'ensure_root' ) ); } /* ---------------- settings ---------------- */ public function defaults( $d ) { $d['files'] = array( 'visibility' => 'public', 'protected' => 0, 'max_zip_mb' => 200 ); return $d; } public function schema( $s ) { $s['files'] = array( 'label' => __( 'Files', 'frontglut' ), 'order' => 52, 'fields' => array( 'files.visibility' => array( 'type' => 'select', 'label' => __( 'Who can browse & download the explorer', 'frontglut' ), 'options' => array( 'public' => 'Everyone', 'logged_in' => 'Logged-in users only' ) ), 'files.protected' => array( 'type' => 'checkbox', 'label' => __( 'Protect files (block direct URLs, serve through PHP so visibility rules apply)', 'frontglut' ) ), 'files.max_zip_mb' => array( 'type' => 'number', 'label' => __( 'Max ZIP download size (MB)', 'frontglut' ), 'min' => 1 ), ) ); return $s; } /* ---------------- storage ---------------- */ public static function root() { $u = wp_upload_dir(); return trailingslashit( $u['basedir'] ) . self::DIR; } public static function root_url() { $u = wp_upload_dir(); return trailingslashit( $u['baseurl'] ) . self::DIR; } /** * WordPress filesystem API for the explorer folder. It lives inside wp-content/uploads, which * WordPress itself always writes to directly, so the direct method is used. */ private static function fs() { static $fs = null; if ( null === $fs ) { require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-base.php'; require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-direct.php'; $fs = new \WP_Filesystem_Direct( null ); } return $fs; } /** Delete one file (or symlink) with WordPress' wp_delete_file(). */ private static function delete_file( $path ) { wp_delete_file( $path ); return ! file_exists( $path ) && ! is_link( $path ); } public function ensure_root() { $r = self::root(); if ( ! is_dir( $r ) ) { wp_mkdir_p( $r ); } if ( is_dir( $r ) ) { if ( ! file_exists( $r . '/index.php' ) ) { self::fs()->put_contents( $r . '/index.php', "\nRequire all denied\n\n" . ( Settings::get( 'files.protected' ) ? "Require all denied\n" : '' ); if ( ! file_exists( $r . '/.htaccess' ) || md5_file( $r . '/.htaccess' ) !== md5( $ht ) ) { self::fs()->put_contents( $r . '/.htaccess', $ht, 0644 ); } } } /** Normalise a relative path: no traversal, no control chars, no hidden files. */ public static function clean_rel( $rel ) { $rel = str_replace( '\\', '/', (string) $rel ); if ( preg_match( '/[\x00-\x1f]/', $rel ) ) { return false; } $parts = array(); foreach ( explode( '/', $rel ) as $p ) { if ( '' === $p || '.' === $p ) { continue; } if ( '..' === $p || '.' === $p[0] ) { return false; } $parts[] = $p; } return implode( '/', $parts ); } /** @return string|false absolute path inside root (may not exist yet), or false */ public static function resolve( $rel ) { $rel = self::clean_rel( $rel ); if ( false === $rel ) { return false; } $root = realpath( self::root() ); if ( ! $root ) { wp_mkdir_p( self::root() ); $root = realpath( self::root() ); } if ( ! $root ) { return false; } $abs = $root . ( $rel ? '/' . $rel : '' ); $real = file_exists( $abs ) ? realpath( $abs ) : realpath( dirname( $abs ) ); if ( ! $real ) { return false; } $check = file_exists( $abs ) ? $real : $real . '/' . basename( $abs ); if ( $real !== $root && 0 !== strpos( $real . '/', $root . '/' ) ) { return false; } return $check; } private static function sign( $root ) { return substr( wp_hash( 'frontglut_ex|' . $root ), 0, 24 ); } /** Verify the signed explorer root coming from the client. */ private function request_root( array $req ) { $root = self::clean_rel( $req['root'] ?? '' ); if ( false === $root || ! hash_equals( self::sign( $root ), (string) ( $req['sig'] ?? '' ) ) ) { Ajax::fail( __( 'Invalid explorer.', 'frontglut' ), 403 ); } return $root; } private function can_browse() { return 'public' === Settings::get( 'files.visibility', 'public' ) || is_user_logged_in(); } private function can_manage() { return is_user_logged_in() && ( Caps::can( 'frontglut_manage_files' ) || current_user_can( 'upload_files' ) ); } private function manage_or_fail() { if ( ! $this->can_manage() ) { Ajax::fail( __( 'You are not allowed to manage files.', 'frontglut' ), 403 ); } } /** Absolute path for (root + path) within jail. */ private function path( $root, $path ) { $p = self::clean_rel( $path ); if ( false === $p ) { Ajax::fail( __( 'Invalid path.', 'frontglut' ) ); } $full = trim( $root . '/' . $p, '/' ); $abs = self::resolve( $full ); if ( false === $abs ) { Ajax::fail( __( 'Invalid path.', 'frontglut' ) ); } return array( $abs, $full ); } private static function conflict_mode( array $req ) { $m = isset( $req['on_conflict'] ) ? $req['on_conflict'] : 'ask'; return in_array( $m, array( 'ask', 'skip', 'replace', 'both' ), true ) ? $m : 'ask'; } private static function safe_name( $name ) { $name = wp_basename( str_replace( '\\', '/', (string) $name ) ); $name = sanitize_file_name( $name ); return ltrim( $name, '.' ); } /** * Normalise a (possibly multiple) $_FILES entry into a list of single files with sanitised names. * Ajax::file() checks the AJAX nonce; the capability is checked by the AJAX layer and manage_or_fail(). */ private static function files_array( $key ) { $raw = Ajax::file( $key ); if ( ! $raw ) { return array(); } $out = array(); $keys = array( 'name', 'type', 'tmp_name', 'error', 'size' ); foreach ( $keys as $k ) { $raw[ $k ] = isset( $raw[ $k ] ) ? (array) $raw[ $k ] : array(); } foreach ( $raw['name'] as $i => $n ) { $out[] = array( 'name' => self::safe_name( is_string( $n ) ? wp_unslash( $n ) : '' ), 'type' => isset( $raw['type'][ $i ] ) ? sanitize_mime_type( (string) $raw['type'][ $i ] ) : '', 'tmp_name' => isset( $raw['tmp_name'][ $i ] ) ? (string) $raw['tmp_name'][ $i ] : '', 'error' => isset( $raw['error'][ $i ] ) ? (int) $raw['error'][ $i ] : UPLOAD_ERR_NO_FILE, 'size' => isset( $raw['size'][ $i ] ) ? absint( $raw['size'][ $i ] ) : 0, ); } return $out; } private function file_url( $root, $rel ) { if ( Settings::get( 'files.protected' ) || 'logged_in' === Settings::get( 'files.visibility' ) ) { return add_query_arg( array( 'action' => 'frontglut_dl', 'root' => $root, 'sig' => self::sign( $root ), 'p' => $rel ), admin_url( 'admin-ajax.php' ) ); } return self::root_url() . '/' . implode( '/', array_map( 'rawurlencode', explode( '/', ( $root ? $root . '/' : '' ) . $rel ) ) ); } /* ---------------- list ---------------- */ public function ajax_list( $req ) { if ( ! $this->can_browse() ) { Ajax::fail( __( 'Please log in to browse files.', 'frontglut' ), 401 ); } $root = $this->request_root( $req ); list( $abs, $full ) = $this->path( $root, $req['path'] ?? '' ); if ( ! is_dir( $abs ) ) { wp_mkdir_p( $abs ); if ( ! is_dir( $abs ) ) { Ajax::fail( __( 'Folder not found.', 'frontglut' ), 404 ); } } $rel = trim( substr( $full, strlen( $root ) ), '/' ); $sort = in_array( $req['sort'] ?? '', array( 'name', 'size', 'date' ), true ) ? $req['sort'] : 'name'; $order = 'desc' === ( $req['order'] ?? '' ) ? 'desc' : 'asc'; $q = strtolower( sanitize_text_field( $req['q'] ?? '' ) ); $items = array(); foreach ( (array) scandir( $abs ) as $n ) { if ( '.' === $n[0] || 'index.php' === $n ) { continue; } $p = $abs . '/' . $n; if ( is_link( $p ) || ( $q && false === strpos( strtolower( $n ), $q ) ) ) { continue; } $isdir = is_dir( $p ); $crel = ( $rel ? $rel . '/' : '' ) . $n; $items[] = array( 'name' => $n, 'type' => $isdir ? 'dir' : 'file', 'size' => $isdir ? 0 : (int) filesize( $p ), 'date' => (int) filemtime( $p ), 'path' => $crel, 'ext' => $isdir ? '' : strtolower( pathinfo( $n, PATHINFO_EXTENSION ) ), 'url' => $isdir ? '' : $this->file_url( $root, $crel ) ); } usort( $items, function ( $a, $b ) use ( $sort, $order ) { if ( $a['type'] !== $b['type'] ) { return 'dir' === $a['type'] ? -1 : 1; } $r = 'size' === $sort ? $a['size'] <=> $b['size'] : ( 'date' === $sort ? $a['date'] <=> $b['date'] : strnatcasecmp( $a['name'], $b['name'] ) ); return 'desc' === $order ? -$r : $r; } ); $total = count( $items ); $pg = max( 1, (int) ( $req['page'] ?? 1 ) ); $items = array_slice( $items, ( $pg - 1 ) * self::PER_PAGE, self::PER_PAGE ); $crumbs = array(); $acc = ''; foreach ( array_filter( explode( '/', $rel ) ) as $part ) { $acc .= ( $acc ? '/' : '' ) . $part; $crumbs[] = array( 'name' => $part, 'path' => $acc ); } return array( 'items' => $items, 'total' => $total, 'page' => $pg, 'pages' => (int) ceil( $total / self::PER_PAGE ), 'path' => $rel, 'crumbs' => $crumbs, 'can_manage' => $this->can_manage(), 'zip' => $this->zip_url( $root, $rel ) ); } private function zip_url( $root, $rel ) { return add_query_arg( array( 'action' => 'frontglut_dl', 'root' => $root, 'sig' => self::sign( $root ), 'p' => $rel, 'zip' => 1 ), admin_url( 'admin-ajax.php' ) ); } /* ---------------- mutations ---------------- */ private function rel_names( array $paths, $root ) { $out = array(); foreach ( $paths as $p ) { $rel = self::clean_rel( $p ); if ( false === $rel || '' === $rel ) { continue; } $abs = self::resolve( trim( $root . '/' . $rel, '/' ) ); if ( $abs && file_exists( $abs ) && ! is_link( $abs ) ) { $out[ $rel ] = $abs; } } return $out; } public function ajax_mkdir( $req ) { $this->manage_or_fail(); $root = $this->request_root( $req ); list( $abs, $full ) = $this->path( $root, $req['path'] ?? '' ); $name = self::safe_name( $req['name'] ?? '' ); if ( '' === $name || false !== strpos( $name, '.' ) && '.' === $name ) { Ajax::fail( __( 'Enter a folder name.', 'frontglut' ) ); } if ( ! is_dir( $abs ) ) { Ajax::fail( __( 'Folder not found.', 'frontglut' ) ); } $new = $abs . '/' . $name; if ( file_exists( $new ) ) { Ajax::fail( __( 'Something with that name already exists.', 'frontglut' ) ); } if ( ! wp_mkdir_p( $new ) ) { Ajax::fail( __( 'Could not create the folder.', 'frontglut' ) ); } Audit::log( 'fe_mkdir', $full . '/' . $name ); return array( 'message' => __( 'Folder created.', 'frontglut' ) ); } /** Validate and move an uploaded temp file into $dir. @return string|\WP_Error final name */ /** * Store one uploaded file in the explorer folder $dir through WordPress' own upload handler * (wp_handle_upload), so core checks and the wp_handle_upload_prefilter hooks used by security plugins apply. * * @param array $file One file from $_FILES: name, type, tmp_name, error, size. * @param string $dir Absolute, already validated target folder. * @param string $mode Conflict mode: replace, both, skip. * @return string|\WP_Error Stored file name. */ private function save_upload( array $file, $dir, $mode ) { $orig = (string) $file['name']; $name = self::safe_name( $orig ); $ext = strtolower( pathinfo( $name, PATHINFO_EXTENSION ) ); if ( '' === $name || ! in_array( $ext, Uploads::allowed_exts(), true ) ) { /* translators: %s: value. */ return new \WP_Error( 'type', sprintf( __( '"%s": this file type is not allowed.', 'frontglut' ), $name ) ); } $chk = wp_check_filetype_and_ext( $file['tmp_name'], $name, $this->mimes() ); if ( empty( $chk['ext'] ) ) { /* translators: %s: value. */ return new \WP_Error( 'mime', sprintf( __( '"%s": content does not match its extension.', 'frontglut' ), $name ) ); } if ( ! apply_filters( 'frontglut_file_allowed', true, $file['tmp_name'], $name ) ) { /* translators: %s: value. */ return new \WP_Error( 'scan', sprintf( __( '"%s" was rejected by a security check.', 'frontglut' ), $name ) ); } if ( file_exists( $dir . '/' . $name ) && 'skip' === $mode ) { return new \WP_Error( 'skip', '' ); } require_once ABSPATH . 'wp-admin/includes/file.php'; $file['name'] = $name; $base_url = self::root_url(); // Resolved before the filter below is added (it calls wp_upload_dir()). $target = function ( $u ) use ( $dir, $base_url ) { $u['path'] = $dir; $u['subdir'] = ''; $u['url'] = $base_url; $u['error'] = false; return $u; }; $overrides = array( 'test_form' => false, 'mimes' => $this->mimes() ); if ( 'replace' === $mode ) { // Keep the original name so the existing file is replaced. $overrides['unique_filename_callback'] = function ( $d, $n ) { return $n; }; } add_filter( 'upload_dir', $target, 999 ); // FRONTGLUT_TESTING lets the CLI test-suite feed plain (non-HTTP) files through the sideload variant; never define it in production. $res = defined( 'FRONTGLUT_TESTING' ) && FRONTGLUT_TESTING ? wp_handle_sideload( $file, $overrides ) : wp_handle_upload( $file, $overrides ); remove_filter( 'upload_dir', $target, 999 ); if ( ! is_array( $res ) || ! empty( $res['error'] ) || empty( $res['file'] ) ) { /* translators: 1: file name, 2: error message. */ return new \WP_Error( 'move', sprintf( __( '"%1$s" could not be saved. %2$s', 'frontglut' ), $name, is_array( $res ) && ! empty( $res['error'] ) ? $res['error'] : '' ) ); } return wp_basename( $res['file'] ); } private function mimes() { $all = get_allowed_mime_types(); $ok = array(); foreach ( $all as $exts => $mime ) { foreach ( explode( '|', $exts ) as $e ) { if ( in_array( $e, Uploads::allowed_exts(), true ) ) { $ok[ $exts ] = $mime; break; } } } return $ok; } public function ajax_upload( $req ) { $this->manage_or_fail(); $root = $this->request_root( $req ); list( $abs, $full ) = $this->path( $root, $req['path'] ?? '' ); if ( ! is_dir( $abs ) || null === Ajax::file( 'files' ) ) { Ajax::fail( __( 'Nothing to upload.', 'frontglut' ) ); } if ( ! RateLimiter::hit( 'feup', 200, HOUR_IN_SECONDS, 'u' . get_current_user_id() ) ) { Ajax::fail( __( 'Upload limit reached.', 'frontglut' ), 429 ); } $mode = self::conflict_mode( $req ); $f = self::files_array( 'files' ); $max = (float) Settings::get( 'uploads.max_mb', 8 ) * 1048576; $conf = array(); if ( 'ask' === $mode ) { foreach ( $f as $one ) { if ( file_exists( $abs . '/' . $one['name'] ) ) { $conf[] = $one['name']; } } if ( $conf ) { return array( 'conflicts' => $conf ); } } $ok = 0; $errs = array(); foreach ( $f as $one ) { if ( UPLOAD_ERR_OK !== $one['error'] || $one['size'] > $max ) { /* translators: %s: value. */ $errs[] = sprintf( __( '"%s" failed (too large or upload error).', 'frontglut' ), $one['name'] ); continue; } $r = $this->save_upload( $one, $abs, $mode ); if ( is_wp_error( $r ) ) { if ( 'skip' !== $r->get_error_code() ) { $errs[] = $r->get_error_message(); } continue; } $ok++; } Audit::log( 'fe_upload', $full, (string) $ok ); if ( ! $ok && $errs ) { Ajax::fail( implode( ' ', $errs ) ); } /* translators: %s: value. */ return array( 'message' => sprintf( _n( '%d file uploaded.', '%d files uploaded.', $ok, 'frontglut' ), $ok ) . ( $errs ? ' ' . implode( ' ', $errs ) : '' ) ); } public function ajax_rename( $req ) { $this->manage_or_fail(); $root = $this->request_root( $req ); list( $abs ) = $this->path( $root, $req['path'] ?? '' ); if ( ! file_exists( $abs ) || realpath( $abs ) === realpath( self::root() . '/' . $root ) && '' === $root ) { Ajax::fail( __( 'Not found.', 'frontglut' ), 404 ); } $name = self::safe_name( $req['name'] ?? '' ); if ( '' === $name ) { Ajax::fail( __( 'Enter a name.', 'frontglut' ) ); } if ( is_file( $abs ) ) { $ext = strtolower( pathinfo( $name, PATHINFO_EXTENSION ) ); if ( ! in_array( $ext, Uploads::allowed_exts(), true ) ) { Ajax::fail( __( 'That file type is not allowed.', 'frontglut' ) ); } } $new = dirname( $abs ) . '/' . $name; if ( file_exists( $new ) ) { Ajax::fail( __( 'Something with that name already exists.', 'frontglut' ) ); } if ( ! self::fs()->move( $abs, $new, false ) ) { Ajax::fail( __( 'Could not rename.', 'frontglut' ) ); } Audit::log( 'fe_rename', $req['path'] ?? '', $name ); return array( 'message' => __( 'Renamed.', 'frontglut' ) ); } private static function rrmdir( $dir ) { foreach ( (array) scandir( $dir ) as $n ) { if ( '.' === $n || '..' === $n ) { continue; } $p = $dir . '/' . $n; if ( is_link( $p ) || ! is_dir( $p ) ) { self::delete_file( $p ); // Symlinks are removed, never followed. } else { self::rrmdir( $p ); } } return self::fs()->rmdir( $dir ); } public function ajax_delete( $req ) { $this->manage_or_fail(); $root = $this->request_root( $req ); $items = $this->rel_names( (array) ( $req['paths'] ?? array() ), $root ); $n = 0; foreach ( $items as $rel => $abs ) { if ( is_dir( $abs ) && ! is_link( $abs ) ? self::rrmdir( $abs ) : self::delete_file( $abs ) ) { $n++; } } Audit::log( 'fe_delete', $root, (string) $n ); /* translators: %s: value. */ return array( 'message' => sprintf( _n( '%d item deleted.', '%d items deleted.', $n, 'frontglut' ), $n ) ); } private static function rcopy( $src, $dst ) { if ( is_dir( $src ) ) { wp_mkdir_p( $dst ); foreach ( (array) scandir( $src ) as $n ) { if ( '.' !== $n && '..' !== $n && ! is_link( $src . '/' . $n ) ) { self::rcopy( $src . '/' . $n, $dst . '/' . $n ); } } return true; } return self::fs()->copy( $src, $dst, true ); } private function transfer( array $req, $move ) { $this->manage_or_fail(); $root = $this->request_root( $req ); $items = $this->rel_names( (array) ( $req['paths'] ?? array() ), $root ); list( $dest ) = $this->path( $root, $req['dest'] ?? '' ); if ( ! is_dir( $dest ) ) { Ajax::fail( __( 'Destination not found.', 'frontglut' ) ); } $mode = self::conflict_mode( $req ); $conf = array(); foreach ( $items as $rel => $abs ) { if ( dirname( $abs ) === $dest && $move ) { continue; } if ( is_dir( $abs ) && 0 === strpos( $dest . '/', $abs . '/' ) ) { Ajax::fail( __( 'You cannot move a folder into itself.', 'frontglut' ) ); } if ( file_exists( $dest . '/' . basename( $abs ) ) && ( dirname( $abs ) !== $dest || ! $move ) ) { $conf[] = basename( $abs ); } } if ( $conf && 'ask' === $mode ) { return array( 'conflicts' => $conf ); } $n = 0; foreach ( $items as $rel => $abs ) { if ( $move && dirname( $abs ) === $dest ) { continue; } $base = basename( $abs ); $to = $dest . '/' . $base; if ( file_exists( $to ) ) { if ( 'skip' === $mode ) { continue; } if ( 'both' === $mode ) { $i = 1; $info = pathinfo( $base ); do { $to = $dest . '/' . $info['filename'] . ' (' . $i++ . ')' . ( isset( $info['extension'] ) && is_file( $abs ) ? '.' . $info['extension'] : '' ); } while ( file_exists( $to ) ); } elseif ( 'replace' === $mode ) { is_dir( $to ) && ! is_link( $to ) ? self::rrmdir( $to ) : self::delete_file( $to ); } } if ( $move ? self::fs()->move( $abs, $to, false ) : self::rcopy( $abs, $to ) ) { $n++; } } Audit::log( $move ? 'fe_move' : 'fe_copy', $root, (string) $n ); /* translators: %s: value. */ return array( 'message' => sprintf( $move ? _n( '%d item moved.', '%d items moved.', $n, 'frontglut' ) : _n( '%d item copied.', '%d items copied.', $n, 'frontglut' ), $n ) ); } public function ajax_move( $req ) { return $this->transfer( $req, true ); } public function ajax_copy( $req ) { return $this->transfer( $req, false ); } public function ajax_tree( $req ) { $this->manage_or_fail(); $root = $this->request_root( $req ); list( $abs ) = $this->path( $root, '' ); $out = array( array( 'path' => '', 'label' => '/' ) ); $walk = function ( $dir, $rel, $depth ) use ( &$walk, &$out ) { if ( $depth > 6 || count( $out ) > 400 ) { return; } foreach ( (array) scandir( $dir ) as $n ) { if ( '.' !== $n[0] && is_dir( $dir . '/' . $n ) && ! is_link( $dir . '/' . $n ) ) { $r = ( $rel ? $rel . '/' : '' ) . $n; $out[] = array( 'path' => $r, 'label' => str_repeat( '— ', $depth ) . $n ); $walk( $dir . '/' . $n, $r, $depth + 1 ); } } }; if ( is_dir( $abs ) ) { $walk( $abs, '', 0 ); } return array( 'folders' => $out ); } public function ajax_import_media( $req ) { $this->manage_or_fail(); if ( ! current_user_can( 'upload_files' ) ) { Ajax::fail( __( 'Not allowed.', 'frontglut' ), 403 ); } $root = $this->request_root( $req ); list( $abs ) = $this->path( $root, $req['path'] ?? '' ); $n = 0; foreach ( array_filter( array_map( 'absint', explode( ',', (string) ( $req['ids'] ?? '' ) ) ) ) as $id ) { $file = get_attached_file( $id ); if ( ! $file || ! file_exists( $file ) || ! current_user_can( 'edit_post', $id ) ) { continue; } $ext = strtolower( pathinfo( $file, PATHINFO_EXTENSION ) ); if ( ! in_array( $ext, Uploads::allowed_exts(), true ) ) { continue; } $name = wp_unique_filename( $abs, self::safe_name( basename( $file ) ) ); if ( self::fs()->copy( $file, $abs . '/' . $name, false ) ) { $n++; } } /* translators: %s: value. */ return array( 'message' => sprintf( _n( '%d file imported from the Media Library.', '%d files imported from the Media Library.', $n, 'frontglut' ), $n ) ); } /** Media Library picker data for the import dialog (current user's recent files). */ public static function media_choices() { $out = array(); foreach ( get_posts( array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'numberposts' => 60, 'author' => Caps::can( 'manage_options' ) ? 0 : get_current_user_id(), 'orderby' => 'date' ) ) as $a ) { $out[] = array( 'id' => $a->ID, 'name' => wp_basename( (string) get_attached_file( $a->ID ) ) ); } return $out; } /* ---------------- download / zip ---------------- */ public function download() { if ( ! $this->can_browse() ) { status_header( 403 ); exit( 'Forbidden' ); } // Read-only download link: authorised by the signed root (HMAC) and can_browse(), not by a nonce, so links keep working. $root = self::clean_rel( isset( $_GET['root'] ) ? sanitize_text_field( wp_unslash( $_GET['root'] ) ) : '' ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $sig = isset( $_GET['sig'] ) ? preg_replace( '/[^a-f0-9]/', '', sanitize_text_field( wp_unslash( $_GET['sig'] ) ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( false === $root || ! hash_equals( self::sign( $root ), $sig ) ) { status_header( 403 ); exit( 'Forbidden' ); } $rel = self::clean_rel( isset( $_GET['p'] ) ? sanitize_text_field( wp_unslash( $_GET['p'] ) ) : '' ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $abs = false === $rel ? false : self::resolve( trim( $root . '/' . $rel, '/' ) ); if ( ! $abs || ! file_exists( $abs ) || is_link( $abs ) ) { status_header( 404 ); exit( 'Not found' ); } nocache_headers(); header( 'X-Content-Type-Options: nosniff' ); if ( ! empty( $_GET['zip'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- signed download link, verified above. $this->send_zip( $abs, $rel ); } if ( is_dir( $abs ) ) { status_header( 404 ); exit; } $ext = strtolower( pathinfo( $abs, PATHINFO_EXTENSION ) ); if ( ! in_array( $ext, Uploads::allowed_exts(), true ) ) { status_header( 403 ); exit( 'Forbidden' ); } $mime = wp_check_filetype( $abs, $this->mimes() ); $inline = in_array( $ext, array( 'jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf', 'mp3', 'mp4', 'txt' ), true ); header( 'Content-Type: ' . ( $mime['type'] ? $mime['type'] : 'application/octet-stream' ) ); header( 'Content-Length: ' . filesize( $abs ) ); header( 'Content-Disposition: ' . ( $inline ? 'inline' : 'attachment' ) . '; filename="' . str_replace( '"', '', basename( $abs ) ) . '"' ); readfile( $abs ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- streams the download without loading it into memory (files can be large). exit; } /** Build a ZIP of a file/folder. @return array [ tmp path|null, total bytes, file count ] */ public function build_zip( $abs ) { require_once ABSPATH . 'wp-admin/includes/file.php'; $max = (int) Settings::get( 'files.max_zip_mb', 200 ) * 1048576; $tmp = wp_tempnam( 'frontglutzip' ); $zip = new \ZipArchive(); $zip->open( $tmp, \ZipArchive::OVERWRITE ); $total = 0; $count = 0; $base = is_dir( $abs ) ? $abs : dirname( $abs ); $add = function ( $path ) use ( &$add, $zip, $base, &$total, &$count, $max ) { if ( is_link( $path ) || $total > $max || $count > 3000 ) { return; } if ( is_dir( $path ) ) { foreach ( (array) scandir( $path ) as $n ) { if ( '.' !== $n[0] ) { $add( $path . '/' . $n ); } } return; } $total += filesize( $path ); $count++; if ( $total <= $max && $count <= 3000 ) { $zip->addFile( $path, ltrim( substr( $path, strlen( $base ) ), '/' ) ); } }; $add( $abs ); $zip->close(); if ( $total > $max || $count > 3000 ) { if ( file_exists( $tmp ) ) { wp_delete_file( $tmp ); } return array( null, $total, $count ); } return array( $tmp, $total, $count ); } private function send_zip( $abs, $rel ) { if ( ! class_exists( 'ZipArchive' ) ) { status_header( 501 ); exit( 'ZIP is not available on this server.' ); } list( $tmp ) = $this->build_zip( $abs ); if ( ! $tmp ) { status_header( 413 ); exit( 'Folder is too large to zip.' ); } header( 'Content-Type: application/zip' ); header( 'Content-Length: ' . filesize( $tmp ) ); header( 'Content-Disposition: attachment; filename="' . sanitize_file_name( ( basename( $abs ) ?: 'files' ) . '.zip' ) . '"' ); readfile( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- streams the ZIP (up to the configured size) without loading it into memory. wp_delete_file( $tmp ); exit; } /* ---------------- shortcodes ---------------- */ public function sc_explorer( $atts ) { $a = shortcode_atts( array( 'folder' => '' ), $atts ); $root = self::clean_rel( $a['folder'] ); if ( false === $root ) { return ''; } Assets::need(); wp_enqueue_script( 'frontglut-explorer', FRONTGLUT_URL . 'assets/js/explorer.js', array( 'frontglut' ), FRONTGLUT_VERSION, true ); if ( ! $this->can_browse() ) { return '
| ' . esc_html__( 'File', 'frontglut' ) . ' | ' . esc_html__( 'Uploader', 'frontglut' ) . ' | ' . esc_html__( 'Date', 'frontglut' ) . ' | |
|---|---|---|---|
| ' . esc_html( wp_basename( (string) get_attached_file( $a->ID ) ) ) . ' | ' . esc_html( $u ? $u->user_login : __( 'Guest', 'frontglut' ) ) . ' | ' . esc_html( get_the_date( '', $a ) ) . ' | ' . esc_html__( 'Delete', 'frontglut' ) . ' |
| ' . esc_html__( 'No uploads yet.', 'frontglut' ) . ' | |||