Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion admin/class-outpost-admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ public static function handle_form_submissions() {
$tag = sanitize_text_field( $_POST['hashtag'] ?? '' );
$instance = sanitize_text_field( $_POST['instance_url'] ?? '' );
$label = sanitize_text_field( $_POST['label'] ?? '' );
$result = OUTPOST_Hashtag_Manager::add( $tag, $instance, $label );
$result = OUTPOST_Hashtag_Manager::add( $tag, $instance, $label, sanitize_text_field( $_POST['account_filter'] ?? '' ) );
$redirect = add_query_arg(
[
'page' => 'outpost-hashtags',
Expand All @@ -145,6 +145,7 @@ public static function handle_form_submissions() {
'hashtag' => sanitize_text_field( $_POST['hashtag'] ?? '' ),
'instance_url' => sanitize_text_field( $_POST['instance_url'] ?? '' ),
'label' => sanitize_text_field( $_POST['label'] ?? '' ),
'account_filter' => sanitize_text_field( $_POST['account_filter'] ?? '' ),
'active' => ! empty( $_POST['active'] ),
] );
wp_safe_redirect( add_query_arg( [ 'page' => 'outpost-hashtags', 'outpost_notice' => 'updated' ], admin_url( 'admin.php' ) ) );
Expand Down Expand Up @@ -174,6 +175,7 @@ public static function handle_form_submissions() {
'outpost_branding_text' => $_POST['branding_text'] ?? '',
'outpost_branding_url' => $_POST['branding_url'] ?? '',
'outpost_manage_page_id' => $_POST['manage_page_id'] ?? 0,
'outpost_brand_account' => $_POST['brand_account'] ?? '',
] );
// Reschedule only the digest cron to reflect the new send time. Do not
// round-trip through activate(), which would also re-run table creation,
Expand Down
14 changes: 14 additions & 0 deletions admin/views/hashtags.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@
<p class="description"><?php esc_html_e( 'Human-readable name shown in the admin. Example: BitsTips Daily', 'outpost' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><label for="edit-account-filter"><?php esc_html_e( 'Account filter', 'outpost' ); ?></label></th>
<td>
<input type="text" id="edit-account-filter" name="account_filter" value="<?php echo esc_attr( $edit_row->account_filter ); ?>" class="regular-text" placeholder="[email protected]" />
<p class="description"><?php esc_html_e( 'Optional. Only show posts from this account. Leave blank to show all accounts.', 'outpost' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Active', 'outpost' ); ?></th>
<td>
Expand Down Expand Up @@ -72,6 +79,13 @@
<p class="description"><?php esc_html_e( 'Optional. Defaults to the hashtag name.', 'outpost' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><label for="new-account-filter"><?php esc_html_e( 'Account filter', 'outpost' ); ?></label></th>
<td>
<input type="text" id="new-account-filter" name="account_filter" value="" class="regular-text" placeholder="[email protected]" />
<p class="description"><?php esc_html_e( 'Optional. Only show posts from this account. Leave blank to show all accounts.', 'outpost' ); ?></p>
</td>
</tr>
</table>
<?php submit_button( __( 'Add hashtag', 'outpost' ) ); ?>
</form>
Expand Down
11 changes: 11 additions & 0 deletions admin/views/settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,17 @@
</tr>
</table>

<h2><?php esc_html_e( 'Brand account', 'outpost' ); ?></h2>
<table class="form-table" role="presentation">
<tr>
<th scope="row"><label for="s-brand-account"><?php esc_html_e( 'Mastodon account', 'outpost' ); ?></label></th>
<td>
<input type="text" id="s-brand-account" name="brand_account" value="<?php echo esc_attr( OUTPOST_Settings::get_brand_account() ); ?>" class="regular-text" placeholder="[email protected]" />
<p class="description"><?php esc_html_e( 'Optional. Used by the account feed. Format: [email protected].', 'outpost' ); ?></p>
</td>
</tr>
</table>

<h2><?php esc_html_e( 'Branding', 'outpost' ); ?></h2>
<p><?php esc_html_e( 'Add a line at the bottom of every feed display and digest email to credit your organization.', 'outpost' ); ?></p>
<table class="form-table" role="presentation">
Expand Down
18 changes: 17 additions & 1 deletion includes/class-outpost-activator.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ private static function create_tables() {
hashtag VARCHAR(255) NOT NULL,
instance_url VARCHAR(500) NOT NULL DEFAULT 'https://mastodon.social',
label VARCHAR(255) NOT NULL DEFAULT '',
account_filter VARCHAR(255) NOT NULL DEFAULT '',
active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
Expand Down Expand Up @@ -89,7 +90,21 @@ private static function create_tables() {
dbDelta( $sql_subscribers );
dbDelta( $sql_digest_log );

update_option( 'outpost_db_version', '1.0.0' );
update_option( 'outpost_db_version', '1.1.0' );
}

/**
* Run schema upgrades for already-installed sites. Idempotent.
*/
public static function maybe_upgrade() {
$installed = get_option( 'outpost_db_version' );
if ( $installed && version_compare( $installed, '1.1.0', '>=' ) ) {
return;
}
// create_tables() runs dbDelta with the current schema, which adds any
// missing columns (e.g. account_filter) on existing installs, and writes
// the new db version.
self::create_tables();
}

/**
Expand All @@ -108,6 +123,7 @@ private static function set_defaults() {
'outpost_cache_duration' => 3600, // 1 hour
'outpost_double_optin' => true,
'outpost_manage_page_id' => 0,
'outpost_brand_account' => '',
];

foreach ( $defaults as $key => $value ) {
Expand Down
44 changes: 36 additions & 8 deletions includes/class-outpost-feed-fetcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,26 +32,54 @@ public static function get_posts( $hashtag_id, $limit = 20, $force = false ) {
}

$cache_key = 'outpost_feed_' . $hashtag_id;
$posts = false;

if ( ! $force ) {
$cached = get_transient( $cache_key );
if ( $cached !== false ) {
return array_slice( $cached, 0, $limit );
$posts = $cached;
}
}

$posts = self::fetch_from_api( $hashtag_row );
if ( ! is_wp_error( $posts ) ) {
set_transient( $cache_key, $posts, OUTPOST_Settings::get_cache_duration() );
} else {
// On error, return stale cache if available
$stale = get_transient( $cache_key );
return $stale ? array_slice( $stale, 0, $limit ) : [];
if ( $posts === false ) {
$fetched = self::fetch_from_api( $hashtag_row );
if ( ! is_wp_error( $fetched ) ) {
set_transient( $cache_key, $fetched, OUTPOST_Settings::get_cache_duration() );
$posts = $fetched;
} else {
// On error, fall back to stale cache if available.
$stale = get_transient( $cache_key );
$posts = $stale ? $stale : [];
}
}

$posts = self::apply_account_filter( $posts, $hashtag_row );

return array_slice( $posts, 0, $limit );
}

/**
* Restrict posts to the hashtag's account_filter when one is set.
*
* @param array $posts
* @param object $hashtag_row
* @return array
*/
private static function apply_account_filter( $posts, $hashtag_row ) {
$filter = isset( $hashtag_row->account_filter ) ? $hashtag_row->account_filter : '';
if ( '' === $filter ) {
return $posts;
}

$matched = array_filter( $posts, function ( $post ) use ( $filter ) {
$acct = isset( $post->account->acct ) ? $post->account->acct : '';
$url = isset( $post->account->url ) ? $post->account->url : '';
return OUTPOST_Hashtag_Manager::post_matches_filter( $filter, $acct, $url );
} );

return array_values( $matched );
}

/**
* Refresh caches for all active hashtags.
*/
Expand Down
59 changes: 53 additions & 6 deletions includes/class-outpost-hashtag-manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public static function get_by_tag( $hashtag, $instance_url ) {
* @param string $label Human-readable label.
* @return int|WP_Error New row ID on success, WP_Error on failure.
*/
public static function add( $hashtag, $instance_url, $label = '' ) {
public static function add( $hashtag, $instance_url, $label = '', $account_filter = '' ) {
global $wpdb;

$hashtag = self::normalize_tag( $hashtag );
Expand All @@ -91,12 +91,13 @@ public static function add( $hashtag, $instance_url, $label = '' ) {
$result = $wpdb->insert(
$wpdb->prefix . 'outpost_hashtags',
[
'hashtag' => $hashtag,
'instance_url' => $instance_url,
'label' => sanitize_text_field( $label ?: '#' . $hashtag ),
'active' => 1,
'hashtag' => $hashtag,
'instance_url' => $instance_url,
'label' => sanitize_text_field( $label ?: '#' . $hashtag ),
'account_filter' => self::normalize_handle( $account_filter ),
'active' => 1,
],
[ '%s', '%s', '%s', '%d' ]
[ '%s', '%s', '%s', '%s', '%d' ]
);

if ( $result === false ) {
Expand Down Expand Up @@ -127,6 +128,9 @@ public static function update( $id, array $data ) {
if ( isset( $data['label'] ) ) {
$allowed['label'] = sanitize_text_field( $data['label'] );
}
if ( isset( $data['account_filter'] ) ) {
$allowed['account_filter'] = self::normalize_handle( $data['account_filter'] );
}
if ( isset( $data['active'] ) ) {
$allowed['active'] = (int) (bool) $data['active'];
}
Expand Down Expand Up @@ -191,6 +195,49 @@ public static function normalize_instance( $url ) {
return $url;
}

/**
* Normalize a Mastodon account handle: trim, strip one leading @, lowercase.
*
* @param string $handle
* @return string
*/
Comment on lines +198 to +203
public static function normalize_handle( $handle ) {
return strtolower( ltrim( trim( (string) $handle ), '@' ) );
}

/**
* Whether a post matches a stored account filter.
*
* @param string $filter Stored filter (any casing; may be blank).
* @param string $acct Post's account->acct ("user" if local to the
* hashtag instance, "user@host" if remote).
* @param string $account_url Post's account->url (used to derive the host
* when the acct is local).
* @return bool
*/
public static function post_matches_filter( $filter, $acct, $account_url ) {
$filter = self::normalize_handle( $filter );
if ( '' === $filter ) {
return true;
}

$acct = strtolower( (string) $acct );
if ( $filter === $acct ) {
return true;
}

// Filter carries a host but the post is local to the hashtag instance
// (acct has no host): compare against username@<host of account url>.
if ( false !== strpos( $filter, '@' ) && false === strpos( $acct, '@' ) ) {
$host = strtolower( (string) wp_parse_url( $account_url, PHP_URL_HOST ) );
if ( $host && $filter === $acct . '@' . $host ) {
return true;
}
}

return false;
}

/**
* Return the Mastodon API endpoint for a hashtag.
*
Expand Down
8 changes: 8 additions & 0 deletions includes/class-outpost-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ public static function get_branding_url() {
return get_option( 'outpost_branding_url', '' );
}

public static function get_brand_account() {
return get_option( 'outpost_brand_account', '' );
}

/**
* Returns rendered branding HTML, or empty string if not configured.
* Used in both feed display and email templates.
Expand Down Expand Up @@ -107,6 +111,7 @@ public static function save( array $data ) {
'outpost_branding_text',
'outpost_branding_url',
'outpost_manage_page_id',
'outpost_brand_account',
];

foreach ( $allowed_keys as $key ) {
Expand All @@ -121,6 +126,9 @@ public static function save( array $data ) {
case 'outpost_branding_url':
update_option( $key, esc_url_raw( $data[ $key ] ) );
break;
case 'outpost_brand_account':
update_option( $key, OUTPOST_Hashtag_Manager::normalize_handle( $data[ $key ] ) );
break;
case 'outpost_digest_send_hour':
case 'outpost_digest_send_minute':
case 'outpost_posts_per_digest':
Expand Down
3 changes: 3 additions & 0 deletions outpost.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ function outpost_init() {
// Load translations
load_plugin_textdomain( 'outpost', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );

// Run any pending schema upgrades for already-installed sites.
OUTPOST_Activator::maybe_upgrade();

// Core services
OUTPOST_Settings::init();
OUTPOST_Hashtag_Manager::init();
Expand Down
37 changes: 37 additions & 0 deletions tests/Unit/AccountFilterMatchTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

namespace Outpost\Tests\Unit;

use Brain\Monkey\Functions;
use Outpost\Tests\TestCase;
use OUTPOST_Hashtag_Manager;

class AccountFilterMatchTest extends TestCase {

protected function setUp(): void {
parent::setUp();
Functions\when( 'wp_parse_url' )->alias( function ( $url, $component ) {
return parse_url( $url, $component );
} );
}

public function test_blank_filter_matches_everything(): void {
$this->assertTrue( OUTPOST_Hashtag_Manager::post_matches_filter( '', 'anyone', 'https://x.social/@anyone' ) );
}

public function test_exact_acct_match(): void {
$this->assertTrue( OUTPOST_Hashtag_Manager::post_matches_filter( '[email protected]', '[email protected]', 'https://example.social/@news' ) );
}

public function test_non_match(): void {
$this->assertFalse( OUTPOST_Hashtag_Manager::post_matches_filter( '[email protected]', 'someoneelse', 'https://example.social/@someoneelse' ) );
}

public function test_local_acct_with_host_only_on_filter(): void {
$this->assertTrue( OUTPOST_Hashtag_Manager::post_matches_filter( '[email protected]', 'alice', 'https://example.social/@alice' ) );
}

public function test_local_acct_wrong_host_does_not_match(): void {
$this->assertFalse( OUTPOST_Hashtag_Manager::post_matches_filter( '[email protected]', 'alice', 'https://example.social/@alice' ) );
}
}
21 changes: 21 additions & 0 deletions tests/Unit/HandleNormalizationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace Outpost\Tests\Unit;

use Outpost\Tests\TestCase;
use OUTPOST_Hashtag_Manager;

class HandleNormalizationTest extends TestCase {

public function test_strips_leading_at_and_lowercases(): void {
$this->assertSame( '[email protected]', OUTPOST_Hashtag_Manager::normalize_handle( '@[email protected]' ) );
}

public function test_trims_whitespace(): void {
$this->assertSame( 'alice', OUTPOST_Hashtag_Manager::normalize_handle( ' alice ' ) );
}

public function test_empty_string_stays_empty(): void {
$this->assertSame( '', OUTPOST_Hashtag_Manager::normalize_handle( '' ) );
}
}
1 change: 1 addition & 0 deletions uninstall.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
'outpost_cache_duration',
'outpost_double_optin',
'outpost_manage_page_id',
'outpost_brand_account',
];

foreach ( $options as $option ) {
Expand Down