'name', 'opportunity' => 'name', 'contact' => 'full_name', 'lead' => 'full_name', 'task' => 'name', ); public function __construct( private readonly DocumentLibraryService $service, private readonly FolderService $folders, private readonly DocumentLinkRepository $links, private readonly DocumentLibraryRepository $documents, private readonly RecordLoader $records ) {} protected function entity_type(): string { return 'library_document'; } /** * @param WP_REST_Request $request The request. * * @return array|null */ protected function record_for( WP_REST_Request $request ): ?array { $id = (int) ( $request['id'] ?? 0 ); return $id > 0 ? $this->documents->find( $id, true ) : null; } /** * Read authorisation for the reciprocal panel — against the PARENT record, not the document. * * Listing the documents on an account is reading that account; a user who cannot see the account * must not learn what is filed against it. * * @param WP_REST_Request $request The request. */ public function can_read_parent( WP_REST_Request $request ): bool|WP_Error { $entity_type = (string) ( $request['entity_type'] ?? '' ); $entity_id = (int) ( $request['entity_id'] ?? 0 ); if ( ! in_array( $entity_type, self::LINKABLE, true ) ) { return $this->fail( 'plugixa_crm_unknown_entity', __( 'There is no such record.', 'plugixa-crm' ), 404 ); } $record = $this->records->load( $entity_type, $entity_id ); if ( null === $record ) { return $this->fail( 'plugixa_crm_not_found', __( 'That record does not exist.', 'plugixa-crm' ), 404 ); } return Capabilities::user_can( 'read', $entity_type, $record ) ? true : $this->forbidden(); } /** * Permission callback for linking or unlinking: edit on the document AND on the record at the * other end (named in the body for a link, in the URL for an unlink). A link changes that record's * relationships, and its label would show the record's name. A record that no longer exists is * left to the handler: a link to it may still be cleared. * * @param WP_REST_Request $request The request. */ public function can_edit_link( WP_REST_Request $request ): bool|WP_Error { $document = $this->can_edit( $request ); if ( true !== $document ) { return $document; } $body = (array) $request->get_json_params(); $entity_type = sanitize_key( (string) ( $request['entity_type'] ?? ( $body['entity_type'] ?? '' ) ) ); $entity_id = (int) ( $request['entity_id'] ?? ( $body['entity_id'] ?? 0 ) ); if ( ! in_array( $entity_type, self::LINKABLE, true ) ) { return $this->fail( 'plugixa_crm_unknown_entity', __( 'A document cannot be linked to that.', 'plugixa-crm' ), 422 ); } if ( ! Capabilities::user_can( 'edit', $entity_type ) ) { return $this->forbidden(); } $record = $this->records->load( $entity_type, $entity_id ); return null === $record || Capabilities::user_can( 'edit', $entity_type, $record ) ? true : $this->forbidden(); } public function register_routes(): void { // Collection. register_rest_route( $this->namespace, '/library_documents', array( array( 'methods' => 'GET', 'callback' => array( $this, 'index' ), 'permission_callback' => array( $this, 'can_read' ), 'args' => $this->get_collection_params(), ), array( 'methods' => 'POST', 'callback' => array( $this, 'store' ), 'permission_callback' => array( $this, 'can_create' ), ), ) ); // Folders — registered before the numeric-id routes so "folders" is never read as an id. register_rest_route( $this->namespace, '/library_documents/folders', array( array( 'methods' => 'GET', 'callback' => array( $this, 'folders_index' ), 'permission_callback' => array( $this, 'can_read' ), ), array( 'methods' => 'POST', 'callback' => array( $this, 'folder_create' ), 'permission_callback' => array( $this, 'can_create' ), ), ) ); register_rest_route( $this->namespace, '/library_documents/folders/(?P\d+)', array( array( 'methods' => 'PUT, PATCH', 'callback' => array( $this, 'folder_update' ), 'permission_callback' => array( $this, 'can_create' ), ), array( 'methods' => 'DELETE', 'callback' => array( $this, 'folder_delete' ), 'permission_callback' => array( $this, 'can_create' ), ), ) ); // The reciprocal panel: documents linked to a given record. register_rest_route( $this->namespace, '/library_documents/links/(?P[a-z_]+)/(?P\d+)', array( array( 'methods' => 'GET', 'callback' => array( $this, 'record_documents' ), 'permission_callback' => array( $this, 'can_read_parent' ), ), ) ); // Links owned by one document. register_rest_route( $this->namespace, '/library_documents/(?P\d+)/links', array( array( 'methods' => 'GET', 'callback' => array( $this, 'document_links' ), 'permission_callback' => array( $this, 'can_read' ), ), array( 'methods' => 'POST', 'callback' => array( $this, 'link' ), 'permission_callback' => array( $this, 'can_edit_link' ), ), ) ); register_rest_route( $this->namespace, '/library_documents/(?P\d+)/links/(?P[a-z_]+)/(?P\d+)', array( array( 'methods' => 'DELETE', 'callback' => array( $this, 'unlink' ), 'permission_callback' => array( $this, 'can_edit_link' ), ), ) ); // Replace the file behind a document. register_rest_route( $this->namespace, '/library_documents/(?P\d+)/file', array( array( 'methods' => 'POST', 'callback' => array( $this, 'replace_file' ), 'permission_callback' => array( $this, 'can_edit' ), ), ) ); register_rest_route( $this->namespace, '/library_documents/(?P\d+)/download', array( array( 'methods' => 'GET', 'callback' => array( $this, 'download' ), 'permission_callback' => array( $this, 'can_read' ), ), ) ); register_rest_route( $this->namespace, '/library_documents/(?P\d+)/restore', array( array( 'methods' => 'POST', 'callback' => array( $this, 'restore' ), 'permission_callback' => array( $this, 'can_delete' ), ), ) ); register_rest_route( $this->namespace, '/library_documents/(?P\d+)/purge', array( array( 'methods' => 'DELETE', 'callback' => array( $this, 'purge' ), 'permission_callback' => array( $this, 'can_delete' ), ), ) ); register_rest_route( $this->namespace, '/library_documents/(?P\d+)', array( array( 'methods' => 'GET', 'callback' => array( $this, 'show' ), 'permission_callback' => array( $this, 'can_read' ), ), array( 'methods' => 'PUT, PATCH', 'callback' => array( $this, 'update' ), 'permission_callback' => array( $this, 'can_edit' ), ), array( 'methods' => 'DELETE', 'callback' => array( $this, 'trash' ), 'permission_callback' => array( $this, 'can_delete' ), ), ) ); } /** * @param WP_REST_Request $request The request. */ public function index( WP_REST_Request $request ): WP_REST_Response { $args = $this->list_args( $request ); $query = $this->documents->query(); if ( 'trash' === $request->get_param( 'view' ) ) { $query->only_trashed(); } if ( '' !== $args['search'] ) { $like = '%' . $GLOBALS['wpdb']->esc_like( $args['search'] ) . '%'; $query->where( '(t.name LIKE %s OR t.original_name LIKE %s)', array( $like, $like ) ); } /* * folder absent = all; '0' = Top Level (unfiled); a positive id = that folder AND EVERY FOLDER * INSIDE IT. * * The sub-folders are the change. This used to match `folder_id` exactly, which was * indistinguishable from correct while the tree was flat and became wrong the moment anyone * nested anything: opening "Legal" would show nothing, because every document was one level down * in "Legal / Contracts". EspoCRM resolves the same filter through its folder Path table, which * is what the closure table here is. * * EXISTS rather than a join, so `count()` above still counts documents rather than path rows. */ $folder = $request->get_param( 'folder' ); if ( null !== $folder && '' !== $folder ) { $folder_id = (int) $folder; if ( 0 === $folder_id ) { $query->where( 't.folder_id IS NULL' ); } else { $paths = Schema::table( CategorySchema::path_table( 'document_folders' ) ); $query->where( 'EXISTS ( SELECT 1 FROM %i p WHERE p.descendant_id = t.folder_id AND p.ancestor_id = %d )', array( $paths, $folder_id ) ); } } $total = $query->count(); $rows = $query ->order_by_column( $this->documents->safe_sort_column( $args['orderby'] ), $args['order'] ) ->paginate( $args['limit'], $args['offset'] ) ->get(); return $this->ok( array_map( array( $this->documents, 'cast' ), $rows ), array( 'total' => $total, 'pages' => (int) ceil( $total / max( 1, $args['limit'] ) ), ) ); } /** * @param WP_REST_Request $request The request. */ public function store( WP_REST_Request $request ): WP_REST_Response|WP_Error { $source = $this->file_source( $request ); if ( null === $source ) { return $this->fail( 'plugixa_crm_no_file', __( 'A document needs a file.', 'plugixa-crm' ), 422 ); } $id = $this->service->create( (array) $request->get_body_params(), $source ); if ( $id instanceof WP_Error ) { return $id; } return $this->ok( $this->service->get( $id ), array(), 201 ); } /** * The file a request brings: an upload under `file`, or `media_id`, a media library item to copy. * An upload wins when both are sent. Null when neither is. * * @param WP_REST_Request $request The request. * * @return array|int|null */ private function file_source( WP_REST_Request $request ): array|int|null { $file = $request->get_file_params()['file'] ?? null; if ( is_array( $file ) ) { return $file; } $media_id = absint( $request->get_param( 'media_id' ) ); return $media_id > 0 ? $media_id : null; } /** * @param WP_REST_Request $request The request. */ public function show( WP_REST_Request $request ): WP_REST_Response|WP_Error { $document = $this->service->get( (int) $request['id'] ); if ( null === $document ) { return $this->fail( 'plugixa_crm_not_found', __( 'Not found.', 'plugixa-crm' ), 404 ); } return $this->ok( $document ); } /** * @param WP_REST_Request $request The request. */ public function update( WP_REST_Request $request ): WP_REST_Response|WP_Error { $id = (int) $request['id']; $result = $this->service->update( $id, (array) $request->get_json_params() ); return $result instanceof WP_Error ? $result : $this->ok( $this->service->get( $id ) ); } /** * @param WP_REST_Request $request The request. */ public function replace_file( WP_REST_Request $request ): WP_REST_Response|WP_Error { $source = $this->file_source( $request ); if ( null === $source ) { return $this->fail( 'plugixa_crm_no_file', __( 'No file was sent.', 'plugixa-crm' ), 422 ); } $id = (int) $request['id']; $result = $this->service->replace_file( $id, $source ); return $result instanceof WP_Error ? $result : $this->ok( $this->service->get( $id ) ); } /** * @param WP_REST_Request $request The request. */ public function trash( WP_REST_Request $request ): WP_REST_Response|WP_Error { return $this->documents->delete( (int) $request['id'] ) ? $this->ok( array( 'trashed' => true ) ) : $this->fail( 'plugixa_crm_not_found', __( 'Not found.', 'plugixa-crm' ), 404 ); } /** * @param WP_REST_Request $request The request. */ public function restore( WP_REST_Request $request ): WP_REST_Response|WP_Error { return $this->documents->restore( (int) $request['id'] ) ? $this->ok( array( 'restored' => true ) ) : $this->fail( 'plugixa_crm_not_found', __( 'Not found in the trash.', 'plugixa-crm' ), 404 ); } /** * The purge cascade (Module::register_cascade) removes the bytes and the links; this only asks for it. * * @param WP_REST_Request $request The request. */ public function purge( WP_REST_Request $request ): WP_REST_Response|WP_Error { return $this->documents->purge( (int) $request['id'] ) ? $this->ok( array( 'purged' => true ) ) : $this->fail( 'plugixa_crm_not_found', __( 'Not found.', 'plugixa-crm' ), 404 ); } /** * The folder tree, flat. The client nests it. * * @param WP_REST_Request $request The request. */ public function folders_index( WP_REST_Request $request ): WP_REST_Response { unset( $request ); return $this->ok( $this->folders->all() ); } /** * @param WP_REST_Request $request The request. */ public function folder_create( WP_REST_Request $request ): WP_REST_Response|WP_Error { $id = $this->folders->create( (array) $request->get_json_params() ); return $id instanceof WP_Error ? $id : $this->ok( array( 'id' => $id ), array(), 201 ); } /** * @param WP_REST_Request $request The request. */ public function folder_update( WP_REST_Request $request ): WP_REST_Response|WP_Error { $result = $this->folders->update( (int) $request['id'], (array) $request->get_json_params() ); return $result instanceof WP_Error ? $result : $this->ok( array( 'updated' => true ) ); } /** * @param WP_REST_Request $request The request. */ public function folder_delete( WP_REST_Request $request ): WP_REST_Response|WP_Error { return $this->folders->delete( (int) $request['id'] ) ? $this->ok( array( 'deleted' => true ) ) : $this->fail( 'plugixa_crm_not_found', __( 'That folder does not exist.', 'plugixa-crm' ), 404 ); } /** * The records a document is linked to, each with a display label. * * A link whose record the caller may not READ is omitted entirely — the `label` is that record's * name, and returning it would leak the name of a record they cannot otherwise see. So the panel * shows a viewer only the associations they are entitled to, even for links some other user created. * * @param WP_REST_Request $request The request. */ public function document_links( WP_REST_Request $request ): WP_REST_Response { $out = array(); foreach ( $this->links->for_document( (int) $request['id'] ) as $link ) { $record = $this->records->load( $link['entity_type'], $link['entity_id'] ); if ( null === $record || ! Capabilities::user_can( 'read', $link['entity_type'], $record ) ) { continue; } $out[] = array( 'entity_type' => $link['entity_type'], 'entity_id' => $link['entity_id'], 'label' => $this->label_for( $link['entity_type'], $record ), ); } return $this->ok( $out ); } /** * Link a document to a record. * * `can_edit` on the ROUTE authorises the document. This additionally authorises the TARGET: a link * writes to that record's associations and exposes the document's contents against it, so it takes * the same edit right that attaching a file to the record does. Without this a user who may edit a * document could link it to a record they cannot see and read that record's name back out of the * link's label — an IDOR. The check mirrors `can_read_parent`, one action stronger. * * @param WP_REST_Request $request The request. */ public function link( WP_REST_Request $request ): WP_REST_Response|WP_Error { $body = (array) $request->get_json_params(); $entity_type = sanitize_key( (string) ( $body['entity_type'] ?? '' ) ); $entity_id = (int) ( $body['entity_id'] ?? 0 ); if ( ! in_array( $entity_type, self::LINKABLE, true ) ) { return $this->fail( 'plugixa_crm_unknown_entity', __( 'A document cannot be linked to that.', 'plugixa-crm' ), 422 ); } $record = $this->records->load( $entity_type, $entity_id ); if ( null === $record ) { return $this->fail( 'plugixa_crm_not_found', __( 'That record does not exist.', 'plugixa-crm' ), 404 ); } if ( ! Capabilities::user_can( 'edit', $entity_type, $record ) ) { return $this->forbidden(); } $this->links->link( (int) $request['id'], $entity_type, $entity_id ); return $this->ok( array( 'linked' => true ), array(), 201 ); } /** * Unlink a document from a record. * * Guarded the same way as {@see link()}: removing an association is tampering with that record's * relationships, so it takes edit on the record — WHEN the record still exists. A link left pointing * at a record that is already gone is just debris, and clearing it must not be blocked by the record * it references no longer being loadable. * * @param WP_REST_Request $request The request. */ public function unlink( WP_REST_Request $request ): WP_REST_Response|WP_Error { $entity_type = (string) $request['entity_type']; $entity_id = (int) $request['entity_id']; $record = $this->records->load( $entity_type, $entity_id ); if ( null !== $record && ! Capabilities::user_can( 'edit', $entity_type, $record ) ) { return $this->forbidden(); } $this->links->unlink( (int) $request['id'], $entity_type, $entity_id ); return $this->ok( array( 'unlinked' => true ) ); } /** * The library documents filed against a record — the reciprocal panel's data. * * @param WP_REST_Request $request The request. */ public function record_documents( WP_REST_Request $request ): WP_REST_Response { $out = array(); foreach ( $this->links->for_record( (string) $request['entity_type'], (int) $request['entity_id'] ) as $document_id ) { $document = $this->service->get( $document_id ); // A trashed or purged document is not shown on the record — get() returns only live rows. if ( null !== $document ) { $out[] = $document; } } return $this->ok( $out ); } /** * Send the bytes. * * Reads the RAW row (not the service's cast one) because the disk name — the only thing that can find * the file — is stripped from everything the client ever sees. By the time this runs, can_read has * already asked whether this user may read the document. * * @param WP_REST_Request $request The request. * * @return WP_REST_Response|WP_Error */ public function download( WP_REST_Request $request ): WP_REST_Response|WP_Error { $document = $this->documents->find( (int) $request['id'], true ); if ( null === $document ) { return $this->fail( 'plugixa_crm_not_found', __( 'That document does not exist.', 'plugixa-crm' ), 404 ); } $path = Storage::path( (string) $document['disk_name'] ); if ( null === $path || ! is_readable( $path ) ) { return $this->fail( 'plugixa_crm_file_missing', __( 'That file is no longer on the server.', 'plugixa-crm' ), 410 ); } add_filter( 'rest_pre_serve_request', static function () use ( $path, $document ): bool { // One place decides the headers for every file the plugin serves — the attachment // controller's, reused verbatim so a library download is exactly as safe as an // attachment download: always `attachment`, never inline, nosniff, no shared caching. foreach ( DocumentsController::headers_for( (string) $document['original_name'], (string) $document['mime'], (int) filesize( $path ) ) as $header => $value ) { header( $header . ': ' . $value ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile readfile( $path ); return true; } ); return new WP_REST_Response( null, 200 ); } /** * A record's display name, by entity type. * * @param string $entity_type The record type. * @param array $record The record row. */ private function label_for( string $entity_type, array $record ): string { $column = self::NAME_COLUMN[ $entity_type ] ?? 'name'; $label = trim( (string) ( $record[ $column ] ?? '' ) ); return '' === $label ? '#' . (int) ( $record['id'] ?? 0 ) : $label; } }