*/ private static $rows = array(); /** * Hooks cleanup when spaces or users are deleted. * * @return void */ public static function init() { add_action( 'spacebay_space_deleted', array( __CLASS__, 'on_space_deleted' ) ); add_action( 'deleted_user', array( __CLASS__, 'on_user_deleted' ) ); add_action( 'remove_user_from_blog', array( __CLASS__, 'on_user_deleted' ) ); foreach ( array( 'spacebay_membership_changed', 'spacebay_membership_removed', 'spacebay_space_created', 'spacebay_space_updated', 'spacebay_space_deleted' ) as $hook ) { add_action( $hook, array( PostAccess::class, 'flush' ) ); } } /** * Finds a post. * * @param int $id Post ID. * @return array|null */ public static function find( $id ) { global $wpdb; $id = absint( $id ); if ( ! $id ) { return null; } if ( ! array_key_exists( $id, self::$rows ) ) { $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, request cache above. $wpdb->prepare( 'SELECT ' . self::COLUMNS . ' FROM %i WHERE id = %d', Tables::name( 'posts' ), $id ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- COLUMNS is a class constant. ARRAY_A ); self::$rows[ $id ] = $row ? self::normalize( $row ) : null; } return self::$rows[ $id ]; } /** * Lists posts the viewer may read. * * @param array $args { * Feed arguments. * * @type int $space_id Only this space. * @type int $user_id Only posts by this member. * @type int $topic_id Only posts with this topic. * @type string $hashtag Only posts with this hashtag. * @type bool $bookmarked Only posts the viewer saved. * @type string $search Words in the title or text. * @type string $orderby "latest" or "activity". * @type int $page Page number, from 1. * @type int $per_page Posts per page. * @type bool $count False skips the exact total (see the return value). Default true. * } * @param int $viewer_id Viewer ID. * @return array{items: array[], total: int} With "count" false, total is the number of posts up to * and including this page, plus one when a later page exists, and 0 for * a page past the end. That is all a "load more" list needs, and it saves counting * every post the viewer can read. */ public static function query( array $args, $viewer_id ) { global $wpdb; $spaces = PostAccess::readable_space_ids( $viewer_id ); if ( ! empty( $args['space_id'] ) ) { $spaces = in_array( (int) $args['space_id'], $spaces, true ) ? array( (int) $args['space_id'] ) : array(); } if ( ! $spaces ) { return array( 'items' => array(), 'total' => 0, ); } // The query is built from fixed SQL pieces with placeholders; every value // goes into $join_args or $where_args and through one $wpdb->prepare() call. $joins = array(); $join_args = array(); $where = array( 'p.status = %s', 'p.space_id IN (' . implode( ',', array_fill( 0, count( $spaces ), '%d' ) ) . ')' ); $where_args = array_merge( array( 'published' ), array_map( 'absint', $spaces ) ); $per_page = max( 1, min( 50, absint( $args['per_page'] ?? 20 ) ) ); $offset = ( max( 1, absint( $args['page'] ?? 1 ) ) - 1 ) * $per_page; $order_sql = 'p.created_at DESC'; $exact = ! isset( $args['count'] ) || $args['count']; if ( ! empty( $args['user_id'] ) ) { $where[] = 'p.user_id = %d'; $where_args[] = absint( $args['user_id'] ); } if ( ! empty( $args['topic_id'] ) ) { $joins[] = 'INNER JOIN %i tr ON tr.object_id = p.id AND tr.term_id = %d'; $join_args = array_merge( $join_args, array( Tables::name( 'term_relations' ), absint( $args['topic_id'] ) ) ); } if ( ! empty( $args['hashtag'] ) ) { $joins[] = 'INNER JOIN %i th ON th.object_id = p.id AND th.term_id = %d'; $join_args = array_merge( $join_args, array( Tables::name( 'term_relations' ), absint( Terms::hashtag_id( $args['hashtag'] ) ) ) ); } if ( ! empty( $args['bookmarked'] ) ) { $joins[] = 'INNER JOIN %i rb ON rb.object_type = %s AND rb.object_id = p.id AND rb.type = %s AND rb.user_id = %d'; $join_args = array_merge( $join_args, array( Tables::name( 'reactions' ), 'post', 'bookmark', absint( $viewer_id ) ) ); $order_sql = 'rb.created_at DESC'; } if ( ! empty( $args['search'] ) ) { $like = '%' . $wpdb->esc_like( $args['search'] ) . '%'; $where[] = '(p.title LIKE %s OR p.message LIKE %s)'; $where_args[] = $like; $where_args[] = $like; } if ( ! empty( $args['space_id'] ) && empty( $args['bookmarked'] ) ) { $order_sql = 'p.is_pinned DESC, ' . $order_sql; } if ( 'activity' === ( $args['orderby'] ?? '' ) ) { $order_sql = 'p.last_activity_at DESC'; } $from = 'FROM %i p ' . implode( ' ', $joins ) . ' WHERE ' . implode( ' AND ', $where ); $from_args = array_merge( array( Tables::name( 'posts' ) ), $join_args, $where_args ); $columns = 'p.' . implode( ', p.', array_map( 'trim', explode( ',', self::COLUMNS ) ) ); // $from, $columns and $order_sql only hold fixed SQL and placeholders (see above), and // $count_sql / $rows_sql are the output of $wpdb->prepare() with every value as an argument. // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber $count_sql = $wpdb->prepare( "SELECT COUNT(p.id) {$from}", $from_args ); $rows_sql = $wpdb->prepare( "SELECT {$columns} {$from} ORDER BY {$order_sql}, p.id DESC LIMIT %d OFFSET %d", array_merge( $from_args, array( $exact ? $per_page : $per_page + 1, $offset ) ) ); $total = $exact ? (int) $wpdb->get_var( $count_sql ) : 0; $rows = (array) $wpdb->get_results( $rows_sql, ARRAY_A ); // phpcs:enable if ( ! $exact ) { // One extra row was read only to learn whether there is a next page. // A page past the end has no rows and must not look as if more follow. $total = $rows ? $offset + count( $rows ) : 0; $rows = array_slice( $rows, 0, $per_page ); } $items = array(); foreach ( $rows as $row ) { $post = self::normalize( $row ); self::$rows[ $post['id'] ] = $post; $items[] = $post; } return array( 'items' => $items, 'total' => $total, ); } /** * Creates a post. * * @param array $input Raw input: space_id, title, message, media_ids, topic_ids. * @param int $user_id Author. * @return array|WP_Error The new post row. */ public static function create( array $input, $user_id ) { global $wpdb; $space = SpaceRepository::find( absint( $input['space_id'] ?? 0 ) ); if ( ! $space || 'space' !== $space['type'] || ! PostAccess::can_post( $space, $user_id ) ) { return new WP_Error( 'spacebay_cannot_post', __( 'You cannot post in this space.', 'spacebay' ), array( 'status' => 403 ) ); } $limited = RateLimit::hit( 'post', $user_id, 5 ); if ( is_wp_error( $limited ) ) { return $limited; } $screen = Moderation::screen( array( 'title' => (string) ( $input['title'] ?? '' ), 'message' => (string) ( $input['message'] ?? '' ), ), $space, $user_id, 'post' ); if ( is_wp_error( $screen ) ) { return $screen; } $input = array_merge( $input, $screen['texts'] ); $content = self::prepare_content( $input ); if ( is_wp_error( $content ) ) { return $content; } $now = Time::now(); $status = '' === $screen['hold'] ? 'published' : 'pending'; $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. Tables::name( 'posts' ), array( 'space_id' => $space['id'], 'user_id' => absint( $user_id ), 'type' => 'text', 'title' => $content['title'], 'slug' => Slug::make( $content['title'] ), 'message' => $content['text'], 'message_rendered' => $content['html'], 'status' => $status, 'meta' => wp_json_encode( $content['meta'] ), 'last_activity_at' => $now, 'created_at' => $now, 'updated_at' => $now, ) ); $id = (int) $wpdb->insert_id; if ( ! $id ) { return new WP_Error( 'spacebay_post_failed', __( 'Your post could not be saved. Please try again.', 'spacebay' ), array( 'status' => 500 ) ); } Media::attach( $content['media_ids'], $user_id, 'post', $id ); Terms::set_post_terms( $id, $content['topic_ids'], $content['hashtags'] ); delete_transient( 'spacebay_admin_stats' ); $post = self::find( $id ); if ( 'pending' === $status ) { Moderation::held( 'post', $post, $screen['hold'], $screen['note'] ); return $post; } /** * Fires after a post is published, including when a moderator * approves a post that was held for review. * * @since 1.0.0 * * @param array $post Post row. * @param int[] $mentions IDs of mentioned members. */ do_action( 'spacebay_post_created', $post, $content['meta']['mentions'] ); return $post; } /** * Updates a post's title, text, images and topics. * * @param array $post Post row. * @param array $input Raw input. * @param int $user_id Editor. * @return array|WP_Error The updated post row. */ public static function update( array $post, array $input, $user_id ) { global $wpdb; $limited = RateLimit::hit( 'edit', $user_id, 20 ); if ( is_wp_error( $limited ) ) { return $limited; } $space = SpaceRepository::find( $post['space_id'] ); $hold = ''; if ( $space ) { $screen = Moderation::screen( array( 'title' => (string) ( $input['title'] ?? $post['title'] ), 'message' => (string) ( $input['message'] ?? $post['message'] ), ), $space, $user_id, 'post_edit' ); if ( is_wp_error( $screen ) ) { return $screen; } $input = array_merge( $input, $screen['texts'] ); $hold = $screen['hold']; } $input = array_merge( array( 'title' => $post['title'], 'message' => $post['message'], 'topic_ids' => wp_list_pluck( Terms::for_posts( array( $post['id'] ) )[ $post['id'] ]['topics'] ?? array(), 'id' ), 'media_ids' => wp_list_pluck( Media::for_objects( 'post', array( $post['id'] ) )[ $post['id'] ] ?? array(), 'id' ), ), $input ); $content = self::prepare_content( $input, (int) $post['user_id'], $post['id'] ); if ( is_wp_error( $content ) ) { return $content; } $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. Tables::name( 'posts' ), array( 'title' => $content['title'], 'slug' => Slug::make( $content['title'] ), 'message' => $content['text'], 'message_rendered' => $content['html'], 'meta' => wp_json_encode( array_merge( $post['meta'], $content['meta'], array( 'edited_by' => absint( $user_id ) ) ) ), 'updated_at' => Time::now(), ), array( 'id' => $post['id'] ) ); $kept = Media::attach( $content['media_ids'], (int) $post['user_id'], 'post', $post['id'] ); Media::delete_for_object( 'post', $post['id'], $kept ); Terms::set_post_terms( $post['id'], $content['topic_ids'], $content['hashtags'] ); unset( self::$rows[ $post['id'] ] ); $updated = self::find( $post['id'] ); if ( '' !== $hold && 'published' === $updated['status'] ) { $updated = self::set_status( $updated, 'pending' ); Moderation::held( 'post', $updated, $hold ); } /** * Fires after a post is edited. * * @since 1.0.0 * * @param array $updated Post row. * @param int $user_id Editor. */ do_action( 'spacebay_post_updated', $updated, $user_id ); return $updated; } /** * Pins or unpins a post in its space. * * @param array $post Post row. * @param bool $pinned New state. * @return array Updated row. */ public static function set_pinned( array $post, $pinned ) { global $wpdb; $wpdb->update( Tables::name( 'posts' ), array( 'is_pinned' => $pinned ? 1 : 0 ), array( 'id' => $post['id'] ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. unset( self::$rows[ $post['id'] ] ); return self::find( $post['id'] ); } /** * Changes the status of a post. * * @param array $post Post row. * @param string $status "published" or "pending". * @param bool $bump Move the post to the top of the feed, for posts * published for the first time after review. * @return array Updated row. */ public static function set_status( array $post, $status, $bump = false ) { global $wpdb; $data = array( 'status' => 'pending' === $status ? 'pending' : 'published' ); if ( $bump ) { $data['created_at'] = Time::now(); $data['last_activity_at'] = $data['created_at']; } $wpdb->update( Tables::name( 'posts' ), $data, array( 'id' => $post['id'] ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. unset( self::$rows[ $post['id'] ] ); delete_transient( 'spacebay_admin_stats' ); return self::find( $post['id'] ); } /** * Deletes posts with their comments, likes, images and terms. * * @param int[] $ids Post IDs. * @return void */ public static function delete( array $ids ) { global $wpdb; foreach ( array_map( 'absint', $ids ) as $id ) { $post = self::find( $id ); if ( ! $post ) { continue; } Comments::delete_for_post( $id ); Reactions::delete_for( 'post', array( $id ) ); Media::delete_for_object( 'post', $id ); Terms::delete_for_posts( array( $id ) ); $wpdb->delete( Tables::name( 'posts' ), array( 'id' => $id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. unset( self::$rows[ $id ] ); /** * Fires after a post is deleted. * * @since 1.0.0 * * @param array $post The deleted post row. */ do_action( 'spacebay_post_deleted', $post ); } delete_transient( 'spacebay_admin_stats' ); } /** * Updates comment count and activity time of a post. * * @param int $id Post ID. * @return void */ public static function touch( $id ) { global $wpdb; $count = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Counter refresh after a write. $wpdb->prepare( 'SELECT COUNT(id) FROM %i WHERE post_id = %d AND status = %s', Tables::name( 'comments' ), absint( $id ), 'published' ) ); $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. Tables::name( 'posts' ), array( 'comments_count' => $count, 'last_activity_at' => Time::now(), ), array( 'id' => absint( $id ) ) ); unset( self::$rows[ absint( $id ) ] ); } /** * Forgets cached rows. Used after counters change. * * @param int $id Post ID. * @return void */ public static function forget( $id ) { unset( self::$rows[ absint( $id ) ] ); } /** * Deletes every post of a deleted space. * * @param array $space Deleted space row. * @return void */ public static function on_space_deleted( $space ) { if ( 'space' === ( $space['type'] ?? '' ) ) { self::delete( self::ids_where( 'space_id', (int) $space['id'] ) ); } } /** * Deletes everything a deleted user wrote. * * @param int $user_id User ID. * @return void */ public static function on_user_deleted( $user_id ) { global $wpdb; self::delete( self::ids_where( 'user_id', absint( $user_id ) ) ); Comments::delete_for_user( $user_id ); Media::delete_for_user( $user_id ); $wpdb->delete( Tables::name( 'reactions' ), array( 'user_id' => absint( $user_id ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. } /** * Post IDs matching one column. * * @param string $column "space_id" or "user_id". * @param int $value Value. * @return int[] */ private static function ids_where( $column, $value ) { global $wpdb; if ( ! in_array( $column, array( 'space_id', 'user_id' ), true ) ) { return array(); } $ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup query. $wpdb->prepare( 'SELECT id FROM %i WHERE %i = %d', Tables::name( 'posts' ), $column, $value ) ); return array_map( 'intval', (array) $ids ); } /** * Validates and renders the content fields of a post. * * @param array $input Raw input. * @param int $author_id Author, used to attach images. * @param int $post_id Post being edited, 0 when new. * @return array|WP_Error */ private static function prepare_content( array $input, $author_id = 0, $post_id = 0 ) { unset( $author_id, $post_id ); $title = mb_substr( sanitize_text_field( (string) ( $input['title'] ?? '' ) ), 0, 200 ); $rendered = Renderer::render( (string) ( $input['message'] ?? '' ) ); $media_ids = array_slice( array_map( 'absint', (array) ( $input['media_ids'] ?? array() ) ), 0, 10 ); if ( mb_strlen( $rendered['text'] ) > self::MAX_LENGTH ) { return new WP_Error( 'spacebay_post_too_long', sprintf( /* translators: %s: Maximum number of characters. */ __( 'Posts can be up to %s characters long.', 'spacebay' ), number_format_i18n( self::MAX_LENGTH ) ), array( 'status' => 400 ) ); } if ( '' === $rendered['text'] && '' === $title && ! $media_ids ) { return new WP_Error( 'spacebay_post_empty', __( 'Write something or add an image before posting.', 'spacebay' ), array( 'status' => 400 ) ); } $url = Renderer::first_url( $rendered['text'] ); $preview = '' !== $url ? LinkPreview::for_url( $url ) : null; return array( 'title' => $title, 'text' => $rendered['text'], 'html' => $rendered['html'], 'hashtags' => $rendered['hashtags'], 'topic_ids' => Terms::valid_topic_ids( (array) ( $input['topic_ids'] ?? array() ) ), 'media_ids' => $media_ids, 'meta' => array( 'mentions' => $rendered['mentions'], 'preview' => $preview, ), ); } /** * Casts a post row. * * @param array $row Raw row. * @return array */ private static function normalize( array $row ) { $meta = json_decode( (string) $row['meta'], true ); return array( 'id' => (int) $row['id'], 'space_id' => (int) $row['space_id'], 'user_id' => (int) $row['user_id'], 'type' => $row['type'], 'title' => $row['title'], 'message' => (string) $row['message'], 'message_rendered' => (string) $row['message_rendered'], 'status' => $row['status'], 'is_pinned' => (bool) $row['is_pinned'], 'comments_count' => (int) $row['comments_count'], 'reactions_count' => (int) $row['reactions_count'], 'meta' => is_array( $meta ) ? $meta : array(), 'last_activity_at' => $row['last_activity_at'], 'created_at' => $row['created_at'], 'updated_at' => $row['updated_at'], ); } }