array( 'application/pdf' ), 'jpg' => array( 'image/jpeg' ), 'jpeg' => array( 'image/jpeg' ), 'png' => array( 'image/png' ), 'gif' => array( 'image/gif' ), 'webp' => array( 'image/webp' ), 'heic' => array( 'image/heic', 'image/heif' ), 'doc' => array( 'application/msword', 'application/vnd.ms-office', 'application/cdfv2', 'application/x-ole-storage' ), 'docx' => array( 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/zip' ), 'xls' => array( 'application/vnd.ms-excel', 'application/vnd.ms-office', 'application/cdfv2', 'application/x-ole-storage' ), 'xlsx' => array( 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/zip' ), 'csv' => array( 'text/csv', 'text/plain', 'application/csv' ), 'txt' => array( 'text/plain' ), ); /** * Largest accepted file, in bytes. * * @return int */ public static function max_bytes() { $default = min( (int) wp_max_upload_size(), 20 * MB_IN_BYTES ); return max( 1, (int) apply_filters( 'docsino_max_upload_bytes', $default ) ); } /** * Most files a client can upload to one item. * * @return int */ public static function max_files_per_item() { return max( 1, (int) apply_filters( 'docsino_max_files_per_item', 10 ) ); } /** * Extensions accepted for an item: its own list, limited to the allow-list. * * @param object $item Checklist item. * @return string[] */ public static function allowed_extensions( $item ) { $exts = array_map( 'trim', explode( ',', strtolower( (string) $item->file_types ) ) ); return array_values( array_intersect( $exts, array_keys( self::ALLOWED ) ) ); } /** * Handle a client upload to one item. * * @param object $checklist Checklist the client's link opened. * @param int $item_id Item ID from the form. * @param array $files The `$_FILES['docsino_files']` entry. * @return string Result code: uploaded, no_file, too_large, bad_type, too_many, item_locked, server_error. */ public static function handle( $checklist, $item_id, $files ) { $item = self::find_item( (int) $checklist->id, (int) $item_id ); if ( ! $item || 'approved' === $item->status || 'active' !== $checklist->status ) { return 'item_locked'; } $files = self::normalize( $files ); if ( empty( $files ) ) { return 'no_file'; } $existing = count( self::for_item( (int) $item->id ) ); if ( $existing + count( $files ) > self::max_files_per_item() ) { return 'too_many'; } $allowed = self::allowed_extensions( $item ); $checked = array(); // Validate everything first, so a bad file doesn't leave half an upload behind. foreach ( $files as $file ) { $result = self::validate( $file, $allowed ); if ( is_string( $result ) ) { return $result; } $checked[] = $result; } if ( ! DOCSINO_Storage::ensure_protected() ) { return 'server_error'; } foreach ( $checked as $file ) { $upload_id = self::store( $checklist, $item, $file ); if ( ! $upload_id ) { return 'server_error'; } DOCSINO_Activity::log( (int) $checklist->id, 'uploaded', array( 'actor' => 'client', 'item_id' => (int) $item->id, 'details' => sanitize_file_name( $file['name'] ), ) ); /** * Fires after a client file is stored. Pro uses this to notify the firm. * * @param int $upload_id Upload ID. * @param object $item Checklist item. * @param object $checklist Checklist. */ do_action( 'docsino_file_uploaded', $upload_id, $item, $checklist ); } self::set_item_status( (int) $item->id, 'uploaded' ); return 'uploaded'; } /** * Check one file. * * @param array $file Normalised file: name, tmp_name, error, size. * @param string[] $allowed Allowed extensions for the item. * @return array|string Checked file (adds ext, mime) or an error code. */ public static function validate( array $file, array $allowed ) { if ( UPLOAD_ERR_INI_SIZE === $file['error'] || UPLOAD_ERR_FORM_SIZE === $file['error'] ) { return 'too_large'; } if ( UPLOAD_ERR_NO_FILE === $file['error'] ) { return 'no_file'; } if ( UPLOAD_ERR_OK !== $file['error'] || ! self::is_uploaded_file( $file['tmp_name'] ) ) { return 'server_error'; } $size = (int) filesize( $file['tmp_name'] ); if ( $size <= 0 ) { return 'no_file'; } if ( $size > self::max_bytes() ) { return 'too_large'; } $ext = strtolower( (string) pathinfo( (string) $file['name'], PATHINFO_EXTENSION ) ); if ( '' === $ext || ! in_array( $ext, $allowed, true ) || ! isset( self::ALLOWED[ $ext ] ) ) { return 'bad_type'; } $mime = self::detect_mime( $file['tmp_name'] ); if ( '' === $mime || ! in_array( $mime, self::ALLOWED[ $ext ], true ) ) { return 'bad_type'; } $file['ext'] = $ext; $file['mime'] = $mime; $file['size'] = $size; return $file; } /** * Uploads grouped by item ID, oldest first. * * @param int $checklist_id Checklist ID. * @return array */ public static function for_checklist( $checklist_id ) { global $wpdb; $table = docsino_table( 'uploads' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE checklist_id = %d ORDER BY uploaded_at ASC, id ASC", $table, $checklist_id ) ); $grouped = array(); foreach ( $rows as $row ) { $grouped[ (int) $row->item_id ][] = $row; } return $grouped; } /** * Uploads of one item. * * @param int $item_id Item ID. * @return object[] */ public static function for_item( $item_id ) { global $wpdb; $table = docsino_table( 'uploads' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. return $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE item_id = %d", $table, $item_id ) ); } /** * One upload by ID. * * @param int $id Upload ID. * @return object|null */ public static function get( $id ) { global $wpdb; $table = docsino_table( 'uploads' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM %i WHERE id = %d", $table, $id ) ); } /** * Absolute path of a stored file, only if it is inside the storage folder. * * @param object $upload Upload row. * @return string|null */ public static function safe_path( $upload ) { $base = realpath( DOCSINO_Storage::get_dir() ); $path = realpath( DOCSINO_Storage::get_dir() . ltrim( (string) $upload->file_path, '/' ) ); if ( ! $base || ! $path || 0 !== strpos( $path, trailingslashit( $base ) ) || ! is_file( $path ) ) { return null; } return $path; } /** * Whether a stored file can be shown in the browser: PDFs and common * image types only (HEIC and office files download instead). * * @param object $upload Upload row. * @return bool */ public static function is_viewable( $upload ) { return in_array( (string) $upload->mime_type, array( 'application/pdf', 'image/jpeg', 'image/png', 'image/gif', 'image/webp' ), true ); } /** * Human-readable file size. * * @param int $bytes Size in bytes. * @return string */ public static function format_size( $bytes ) { return size_format( (int) $bytes, $bytes >= MB_IN_BYTES ? 1 : 0 ); } /** * Turn PHP's multi-file $_FILES layout into a list of files. * * @param mixed $files `$_FILES['docsino_files']`. * @return array[] Each with name, tmp_name, error, size. */ public static function normalize( $files ) { if ( ! is_array( $files ) || ! isset( $files['name'], $files['tmp_name'], $files['error'] ) ) { return array(); } $list = array(); foreach ( (array) $files['name'] as $i => $name ) { $error = (int) ( (array) $files['error'] )[ $i ]; if ( UPLOAD_ERR_NO_FILE === $error ) { continue; } $list[] = array( 'name' => (string) $name, 'tmp_name' => (string) ( (array) $files['tmp_name'] )[ $i ], 'error' => $error, 'size' => isset( $files['size'] ) ? (int) ( (array) $files['size'] )[ $i ] : 0, ); } return $list; } /** * Real content type of a file, read from its bytes. * * @param string $path File path. * @return string Lowercase MIME type, or '' if unknown. */ public static function detect_mime( $path ) { if ( ! function_exists( 'finfo_open' ) ) { return ''; } $finfo = finfo_open( FILEINFO_MIME_TYPE ); $mime = $finfo ? finfo_file( $finfo, $path ) : false; if ( $finfo ) { finfo_close( $finfo ); } return $mime ? strtolower( $mime ) : ''; } /** * Whether a path is a genuine HTTP upload. Filterable for tests. * * @param string $path Temporary path. * @return bool */ private static function is_uploaded_file( $path ) { return (bool) apply_filters( 'docsino_is_uploaded_file', is_uploaded_file( $path ), $path ); } /** * Move a checked upload into a private folder using WordPress's own * upload handling (wp_handle_upload), pointed at our folder instead of * the public Media Library. * * A dedicated action name keeps other plugins' upload filters (such as * image resizers) away from client documents. The file has already * passed is_uploaded_file() and our type checks in validate(). * * @param array $file Checked file (tmp_name, mime, size). * @param string $folder Absolute destination folder. * @param string $name File name to store it under. * @return string|false Absolute path of the stored file, or false. */ private static function move_into_storage( array $file, $folder, $name ) { $target = trailingslashit( $folder ) . $name; // Test seam: lets the automated tests stand in for a real HTTP upload. $moved = apply_filters( 'docsino_move_uploaded_file', null, $file['tmp_name'], $target ); if ( null !== $moved ) { return $moved ? $target : false; } if ( ! function_exists( 'wp_handle_upload' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } $use_folder = static function ( $dirs ) use ( $folder ) { $dirs['path'] = untrailingslashit( $folder ); $dirs['url'] = ''; $dirs['subdir'] = ''; $dirs['error'] = false; return $dirs; }; add_filter( 'upload_dir', $use_folder, PHP_INT_MAX ); $upload = array( 'name' => $name, 'type' => $file['mime'], 'tmp_name' => $file['tmp_name'], 'error' => UPLOAD_ERR_OK, 'size' => $file['size'], ); $result = wp_handle_upload( $upload, array( 'action' => 'docsino_handle_upload', 'test_form' => false, 'test_type' => false, // Already checked against the file's real content in validate(). ) ); remove_filter( 'upload_dir', $use_folder, PHP_INT_MAX ); return ( is_array( $result ) && empty( $result['error'] ) && ! empty( $result['file'] ) ) ? $result['file'] : false; } /** * Move a checked file into storage and record it. * * @param object $checklist Checklist. * @param object $item Item. * @param array $file Checked file. * @return int Upload ID, or 0 on failure. */ private static function store( $checklist, $item, array $file ) { global $wpdb; $folder = DOCSINO_Storage::get_dir() . (int) $checklist->id; $name = bin2hex( random_bytes( 16 ) ) . '.' . $file['ext']; if ( ! wp_mkdir_p( $folder ) ) { return 0; } $target = self::move_into_storage( $file, $folder, $name ); if ( ! $target ) { return 0; } $relative = (int) $checklist->id . '/' . wp_basename( $target ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table. $wpdb->insert( docsino_table( 'uploads' ), array( 'item_id' => (int) $item->id, 'checklist_id' => (int) $checklist->id, 'file_name' => sanitize_file_name( $file['name'] ), 'file_path' => $relative, 'storage_location' => 'local', 'mime_type' => $file['mime'], 'file_size' => $file['size'], 'uploaded_at' => current_time( 'mysql', true ), ) ); return (int) $wpdb->insert_id; } /** * Find an item that belongs to a checklist. * * @param int $checklist_id Checklist ID. * @param int $item_id Item ID. * @return object|null */ private static function find_item( $checklist_id, $item_id ) { global $wpdb; $table = docsino_table( 'items' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM %i WHERE id = %d AND checklist_id = %d", $table, $item_id, $checklist_id ) ); } /** * Set an item's status. * * @param int $item_id Item ID. * @param string $status New status. */ private static function set_item_status( $item_id, $status ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. $wpdb->update( docsino_table( 'items' ), array( 'status' => $status, 'updated_at' => current_time( 'mysql', true ), ), array( 'id' => (int) $item_id ) ); } }