Skip to content

Commit

Permalink
Introduce Query Singleton, to store/query all ActivityPub related inf…
Browse files Browse the repository at this point in the history
…ormations based on the $wp_query (#1148)

* Improve Extensibility: Better custom support for conneg and authorized fetch

This came up in a discussion with @Menrath to better support custom endpoints/uris with content negotiation and authorized fetch.

* added missing changelog

* simplify `is_activitypub_request`

props @Menrath

* simplify header method and use transformer for all queried objects except users

* fix number of params

* introduce query singleton

* fix phpcs

* fix phpcs

* Add WP_User transformer

* add PHP doc

* fix phpcs

* re-add preview support

* fix PHPCS

* check if objects are enabled for ActivityPub

* add comment to fall through to default

* store is_activitypub_request value on init

* support virtual users like the blog and application users

* I don't know why this breaks

* fix header code

* class-query.php aktualisieren

Co-authored-by: Konstantin Obenland <[email protected]>

* functions.php aktualisieren

Co-authored-by: Konstantin Obenland <[email protected]>

* functions.php aktualisieren

Co-authored-by: Konstantin Obenland <[email protected]>

* Update CHANGELOG.md

Co-authored-by: Konstantin Obenland <[email protected]>

* move `is_activitypub_request` logic to query

* simplify check

* added unittests

* fix phpcs

* updated changelog

* add more specific unit tests

* use home_url instead of site_url

* add more doc

* run and store `is_activitypub_request` on first call instead of on `construct`

* transform an object to an ID

* optimize code to only load ids/objects if needed

* test for `activitypub_status` in comment-meta

* add missing user-id

* remove unused function

* Update phpdoc

* fix readme

* Align asterisks in block comments

* rename local var to match latest changes

props @obenland

* avoid fall-through

props @obenland

---------

Co-authored-by: Konstantin Obenland <[email protected]>
  • Loading branch information
pfefferle and obenland authored Jan 20, 2025
1 parent cc99cde commit 6362851
Show file tree
Hide file tree
Showing 14 changed files with 684 additions and 178 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Untitled]

### Changed

* Improved content negotiation and AUTHORIZED_FETCH support for third-party plugins

## [4.7.2] - 2025-01-17

### Fixed
Expand Down
44 changes: 14 additions & 30 deletions includes/class-activitypub.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
namespace Activitypub;

use Exception;
use Activitypub\Transformer\Factory;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Extra_Fields;

Expand Down Expand Up @@ -100,17 +101,16 @@ public static function render_activitypub_template( $template ) {
return $template;
}

self::add_headers();

if ( ! is_activitypub_request() ) {
return $template;
}

$activitypub_template = false;
$activitypub_object = Query::get_instance()->get_activitypub_object();

if ( \is_author() && ! is_user_disabled( \get_the_author_meta( 'ID' ) ) ) {
$activitypub_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/user-json.php';
} elseif ( is_comment() ) {
$activitypub_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/comment-json.php';
} elseif ( \is_singular() && ! is_post_disabled( \get_the_ID() ) ) {
if ( $activitypub_object ) {
if ( \get_query_var( 'preview' ) ) {
\define( 'ACTIVITYPUB_PREVIEW', true );

Expand All @@ -121,10 +121,8 @@ public static function render_activitypub_template( $template ) {
*/
$activitypub_template = apply_filters( 'activitypub_preview_template', ACTIVITYPUB_PLUGIN_DIR . '/templates/post-preview.php' );
} else {
$activitypub_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/post-json.php';
$activitypub_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/activitypub-json.php';
}
} elseif ( \is_home() && ! is_user_type_disabled( 'blog' ) ) {
$activitypub_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/blog-json.php';
}

/*
Expand All @@ -144,6 +142,12 @@ public static function render_activitypub_template( $template ) {
}

if ( $activitypub_template ) {
// Check if header already sent.
if ( ! \headers_sent() && ACTIVITYPUB_SEND_VARY_HEADER ) {
// Send Vary header for Accept header.
\header( 'Vary: Accept' );
}

return $activitypub_template;
}

Expand All @@ -154,32 +158,14 @@ public static function render_activitypub_template( $template ) {
* Add the 'self' link to the header.
*/
public static function add_headers() {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
$request_uri = $_SERVER['REQUEST_URI'];

if ( ! $request_uri ) {
return;
}

$id = false;

// Only add self link to author pages...
if ( is_author() ) {
if ( ! is_user_disabled( get_queried_object_id() ) ) {
$id = get_user_id( get_queried_object_id() );
}
} elseif ( is_singular() ) { // or posts/pages/custom-post-types...
if ( \post_type_supports( \get_post_type(), 'activitypub' ) ) {
$id = get_post_id( get_queried_object_id() );
}
}
$id = Query::get_instance()->get_activitypub_object_id();

if ( ! $id ) {
return;
}

if ( ! headers_sent() ) {
header( 'Link: <' . esc_url( $id ) . '>; title="ActivityPub (JSON)"; rel="alternate"; type="application/activity+json"' );
header( 'Link: <' . esc_url( $id ) . '>; title="ActivityPub (JSON)"; rel="alternate"; type="application/activity+json"', false );
}

add_action(
Expand Down Expand Up @@ -233,8 +219,6 @@ public static function redirect_canonical( $redirect_url, $requested_url ) {
* @return void
*/
public static function template_redirect() {
self::add_headers();

$comment_id = get_query_var( 'c', null );

// Check if it seems to be a comment.
Expand Down
263 changes: 263 additions & 0 deletions includes/class-query.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
<?php
/**
* Query class.
*
* @package Activitypub
*/

namespace Activitypub;

use Activitypub\Collection\Actors;
use Activitypub\Transformer\Factory;

/**
* Singleton class to handle and store the ActivityPub query.
*/
class Query {

/**
* The singleton instance.
*
* @var Query
*/
private static $instance;

/**
* The ActivityPub object.
*
* @link https://www.w3.org/TR/activitystreams-vocabulary/#dfn-object
*
* @var object
*/
private $activitypub_object;

/**
* The ActivityPub object ID.
*
* @link https://www.w3.org/TR/activitystreams-vocabulary/#dfn-id
*
* @var string
*/
private $activitypub_object_id;

/**
* Whether the current request is an ActivityPub request.
*
* @var bool
*/
private $is_activitypub_request;

/**
* The constructor.
*/
private function __construct() {
// Do nothing.
}

/**
* The destructor.
*/
public function __destruct() {
self::$instance = null;
}

/**
* Get the singleton instance.
*
* @return Query The singleton instance.
*/
public static function get_instance() {
if ( ! isset( self::$instance ) ) {
self::$instance = new self();
}

return self::$instance;
}

/**
* Get the ActivityPub object.
*
* @return object The ActivityPub object.
*/
public function get_activitypub_object() {
if ( $this->activitypub_object ) {
return $this->activitypub_object;
}

$queried_object = $this->get_queried_object();

if ( ! $queried_object ) {
// If the object is not a valid ActivityPub object, try to get a virtual object.
$this->activitypub_object = $this->maybe_get_virtual_object();
return $this->activitypub_object;
}

$transformer = Factory::get_transformer( $queried_object );

if ( $transformer && ! is_wp_error( $transformer ) ) {
$this->activitypub_object = $transformer->to_object();
}

return $this->activitypub_object;
}

/**
* Get the ActivityPub object ID.
*
* @return int The ActivityPub object ID.
*/
public function get_activitypub_object_id() {
if ( $this->activitypub_object_id ) {
return $this->activitypub_object_id;
}

$queried_object = $this->get_queried_object();
$this->activitypub_object_id = null;

if ( ! $queried_object ) {
// If the object is not a valid ActivityPub object, try to get a virtual object.
$virtual_object = $this->maybe_get_virtual_object();

if ( $virtual_object ) {
$this->activitypub_object_id = $virtual_object->get_id();

return $this->activitypub_object_id;
}
}

$transformer = Factory::get_transformer( $queried_object );

if ( $transformer && ! is_wp_error( $transformer ) ) {
$this->activitypub_object_id = $transformer->to_id();
}

return $this->activitypub_object_id;
}

/**
* Get the queried object.
*
* This adds support for Comments by `?c=123` IDs and Users by `?author=123` and `@username` IDs.
*
* @return \WP_Term|\WP_Post_Type|\WP_Post|\WP_User|\WP_Comment|null The queried object.
*/
public function get_queried_object() {
$queried_object = \get_queried_object();

if ( $queried_object ) {
return $queried_object;
}

// Check Comment by ID.
$comment_id = \get_query_var( 'c' );
if ( $comment_id ) {
return \get_comment( $comment_id );
}

// Try to get Author by ID.
$url = $this->get_request_url();
$author_id = url_to_authorid( $url );
if ( $author_id ) {
return \get_user_by( 'id', $author_id );
}

return null;
}

/**
* Get the virtual object.
*
* Virtual objects are objects that are not stored in the database, but are created on the fly.
* The plugins currently supports two virtual objects: The Blog-Actor and the Application-Actor.
*
* @see \Activitypub\Blog
* @see \Activitypub\Application
*
* @return object|null The virtual object.
*/
protected function maybe_get_virtual_object() {
$url = $this->get_request_url();

if ( ! $url ) {
return null;
}

$author_id = url_to_authorid( $url );

if ( ! is_numeric( $author_id ) ) {
return null;
}

$user = Actors::get_by_id( $author_id );

if ( \is_wp_error( $user ) || ! $user ) {
return null;
}

return $user;
}

/**
* Get the request URL.
*
* @return string|null The request URL.
*/
protected function get_request_url() {
if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
return null;
}

// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$url = \wp_unslash( $_SERVER['REQUEST_URI'] );
$url = \WP_Http::make_absolute_url( $url, \home_url() );
$url = \sanitize_url( $url );

return $url;
}

/**
* Check if the current request is an ActivityPub request.
*
* @return bool True if the request is an ActivityPub request, false otherwise.
*/
public function is_activitypub_request() {
if ( isset( $this->is_activitypub_request ) ) {
return $this->is_activitypub_request;
}

global $wp_query;

// One can trigger an ActivityPub request by adding ?activitypub to the URL.
if ( isset( $wp_query->query_vars['activitypub'] ) ) {
$this->is_activitypub_request = true;

return true;
}

/*
* The other (more common) option to make an ActivityPub request
* is to send an Accept header.
*/
if ( isset( $_SERVER['HTTP_ACCEPT'] ) ) {
$accept = \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_ACCEPT'] ) );

/*
* $accept can be a single value, or a comma separated list of values.
* We want to support both scenarios,
* and return true when the header includes at least one of the following:
* - application/activity+json
* - application/ld+json
* - application/json
*/
if ( \preg_match( '/(application\/(ld\+json|activity\+json|json))/i', $accept ) ) {
$this->is_activitypub_request = true;

return true;
}
}

$this->is_activitypub_request = false;

return false;
}
}
Loading

0 comments on commit 6362851

Please sign in to comment.