402 lines
11 KiB
PHP
402 lines
11 KiB
PHP
<?php
|
|
/**
|
|
* @brief content
|
|
* @author <a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
|
|
* @copyright (c) Invision Power Services, Inc.
|
|
* @license https://www.invisioncommunity.com/legal/standards/
|
|
* @package Invision Community
|
|
|
|
* @since 21 Aug 2023
|
|
*/
|
|
|
|
namespace IPS\core\modules\front\hive;
|
|
|
|
use IPS\core\Hive;
|
|
use IPS\Content\Item;
|
|
use IPS\core\Stream;
|
|
use IPS\Db;
|
|
use IPS\File;
|
|
use IPS\File\Exception;
|
|
use IPS\Http\Url;
|
|
use IPS\IPS;
|
|
use IPS\Member;
|
|
use IPS\Output;
|
|
use IPS\Patterns\ActiveRecordIterator;
|
|
use IPS\Request;
|
|
use IPS\Settings;
|
|
use Jose\Component\Checker\AudienceChecker;
|
|
use Jose\Component\Checker\ClaimCheckerManager;
|
|
use Jose\Component\Checker\ExpirationTimeChecker;
|
|
use Jose\Component\Checker\InvalidClaimException;
|
|
use Jose\Component\Checker\IssuedAtChecker;
|
|
use Jose\Component\Checker\IssuerChecker;
|
|
use Jose\Component\Checker\MissingMandatoryClaimException;
|
|
use Jose\Component\Checker\NotBeforeChecker;
|
|
use Jose\Component\Signature\Serializer\CompactSerializer;
|
|
|
|
/* To prevent PHP errors (extending class does not exist) revealing path */
|
|
if ( !\defined( '\IPS\SUITE_UNIQUE_KEY' ) )
|
|
{
|
|
header( ( isset( $_SERVER['SERVER_PROTOCOL'] ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0' ) . ' 403 Forbidden' );
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* content
|
|
*/
|
|
class _content extends \IPS\Dispatcher\Controller
|
|
{
|
|
public function __construct( $url = NULL )
|
|
{
|
|
parent::__construct( $url );
|
|
|
|
IPS::$PSR0Namespaces['Jose'] = \IPS\ROOT_PATH . '/system/3rd_party/JwtFramework/src';
|
|
IPS::$PSR0Namespaces['Base64Url'] = \IPS\ROOT_PATH .'/system/3rd_party/Base64Url';
|
|
|
|
\IPS\Output::i()->pageCaching = FALSE;
|
|
}
|
|
|
|
/**
|
|
* Execute
|
|
*
|
|
* @return void
|
|
*/
|
|
public function execute()
|
|
{
|
|
if( isset( Request::i()->click ) )
|
|
{
|
|
$this->_doClick();
|
|
}
|
|
elseif( isset( Request::i()->follow ) )
|
|
{
|
|
$this->_doFollowCommunity();
|
|
}
|
|
else
|
|
{
|
|
/* Don't feed any content if disabled */
|
|
if( !Settings::i()->hive_enabled )
|
|
{
|
|
Output::i()->json( [ 'error' => 'Community Hive not enabled' ], 404 );
|
|
}
|
|
|
|
$this->_verifyJwt();
|
|
}
|
|
|
|
parent::execute();
|
|
}
|
|
|
|
/**
|
|
* ...
|
|
*
|
|
* @return void
|
|
*/
|
|
protected function manage()
|
|
{
|
|
try
|
|
{
|
|
match ( $this->payload['request_type'] )
|
|
{
|
|
'content' => $this->_getContent(),
|
|
'sync' => $this->_syncMembers(),
|
|
'unfollow' => $this->_unfollow()
|
|
};
|
|
}
|
|
catch( \UnhandledMatchError $e )
|
|
{
|
|
Output::i()->json( array( 'error' => 'Invalid request type' ), 400 );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process Hive click and redirect
|
|
*
|
|
* @return void
|
|
*/
|
|
protected function _doClick()
|
|
{
|
|
try
|
|
{
|
|
$string = base64_decode( Request::i()->click );
|
|
$result = [];
|
|
parse_str( $string, $result );
|
|
|
|
if( empty( $result['key2' ] ) )
|
|
{
|
|
throw new \UnexpectedValueException( 'key2 missing' );
|
|
}
|
|
|
|
$item = explode( '/', $result['key2'] );
|
|
|
|
if( \count( $item ) !== 2 )
|
|
{
|
|
throw new \UnexpectedValueException( 'key2 format unexpected' );
|
|
}
|
|
|
|
if( !class_exists( $item[0], TRUE ) )
|
|
{
|
|
throw new \UnexpectedValueException( 'key2 data unexpected' );
|
|
}
|
|
|
|
$object = $item[0]::load( (int) $item[1] );
|
|
|
|
if( !( $object instanceof \IPS\Content ) )
|
|
{
|
|
throw new \UnexpectedValueException( 'key2 data unexpected 2' );
|
|
}
|
|
|
|
Output::i()->redirect( $object->url() );
|
|
}
|
|
catch( \UnexpectedValueException | \OutOfRangeException $e )
|
|
{
|
|
Output::i()->redirect( Url::internal( '' ) );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Redirect to Hive follow page
|
|
*
|
|
* @return void
|
|
*/
|
|
protected function _doFollowCommunity()
|
|
{
|
|
Output::i()->redirect( Url::internal( 'app=core&module=hive&controller=account&do=follow', 'front', 'hive_follow' ) );
|
|
}
|
|
|
|
/**
|
|
* Get content for Hive
|
|
*
|
|
* @return void
|
|
*/
|
|
protected function _getContent()
|
|
{
|
|
/* Make sure we have a valid payload */
|
|
if( ! isset ( $this->payload['group_hash'] ) )
|
|
{
|
|
Output::i()->json( [ 'error' => 'Missing Group Hash' ], 400 );
|
|
}
|
|
|
|
/* Use new member object for permissions */
|
|
$member = new Member;
|
|
|
|
/* Set secondary groups */
|
|
$member->member_group_id = Settings::i()->guest_group;
|
|
if( $this->payload['group_hash'] !== 'guest' )
|
|
{
|
|
$member->mgroup_others = $this->payload['group_hash'];
|
|
}
|
|
|
|
$stream = Stream::allActivityStream();
|
|
$stream->date_relative_days = 365;
|
|
$stream->id = 0;
|
|
$stream->include_comments = TRUE;
|
|
$stream->baseUrl = Url::internal( "app=core&module=discover&controller=streams", 'front', 'discover_all' );
|
|
|
|
/* Content Limitations */
|
|
$settings = json_decode( Settings::i()->hive_content, TRUE );
|
|
if( $settings['content_classes'] !== '*' )
|
|
{
|
|
$stream->classes = $settings['content_classes'];
|
|
}
|
|
else
|
|
{
|
|
$stream->classes = '';
|
|
foreach ( \IPS\Content::routedClasses( TRUE, FALSE, TRUE ) as $class )
|
|
{
|
|
if ( is_subclass_of( $class, 'IPS\Content\Searchable' ) and isset( $class::$databaseColumnMap['date'] ) )
|
|
{
|
|
$stream->classes .= ',' . $class;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Container - Stream wants JSON and we have an array... */
|
|
$stream->containers = json_encode( $settings['content_containers'] );
|
|
|
|
$query = $stream->query( $member )->setLimit( 10 );
|
|
$results = $query->search();
|
|
|
|
$return = [];
|
|
foreach( $results as $comment )
|
|
{
|
|
$commentData = $comment->asArray();
|
|
$commentClass = $commentData['indexData']['index_class'];
|
|
$itemClass = $commentClass::$itemClass ?? $commentClass;
|
|
$item = $itemClass::load( $commentData['indexData']['index_item_id'] );
|
|
|
|
/* Attachments */
|
|
$attachment = $fileObj = NULL;
|
|
try
|
|
{
|
|
if( !empty( $commentData['itemData']['attachedImages'] ) )
|
|
{
|
|
$fileObj = File::get( $commentData['itemData']['attachedImages'][0]['extension'], $commentData['itemData']['attachedImages'][0]['thumb_location'] ?: $commentData['itemData']['attachedImages'][0]['location'] );
|
|
}
|
|
elseif( is_subclass_of( $commentData['indexData']['index_class'], 'IPS\Content\Item' ) AND $contentImage = $item->contentImages( 1, TRUE ) )
|
|
{
|
|
$attachType = key( $contentImage[0] );
|
|
$fileObj = File::get( $attachType, $contentImage[0][ $attachType ] );
|
|
}
|
|
|
|
if( $fileObj !== NULL )
|
|
{
|
|
if ( $fileObj->isImage() )
|
|
{
|
|
$fileContents = $fileObj->contents();
|
|
if ( \strlen( $fileContents ) > 1000000 )
|
|
{
|
|
throw new \OutOfRangeException;
|
|
}
|
|
|
|
$attachment = [
|
|
'name' => $fileObj->originalFilename,
|
|
'file' => base64_encode( $fileContents )
|
|
];
|
|
}
|
|
}
|
|
}
|
|
catch( Exception | \UnderflowException | \OutOfRangeException $e ) { }
|
|
|
|
/* Truncate content */
|
|
if( mb_strlen( $commentData['indexData']['index_content'] ) > 500 )
|
|
{
|
|
$commentData['indexData']['index_content'] = trim( mb_substr( $commentData['indexData']['index_content'], 0, 500 ) ) . '...';
|
|
}
|
|
$classObj = $commentClass::load( $commentData['indexData']['index_object_id'] );
|
|
$return[] = array(
|
|
'title' => $item->mapped('title'),
|
|
'content' => $commentData['indexData']['index_content'],
|
|
'date' => $commentData['indexData']['index_date_commented'],
|
|
'author' => $classObj->isAnonymous() ? \IPS\Member::loggedIn()->language()->get( "post_anonymously_placename" ) : $commentData['authorData']['name'],
|
|
'key1' => $itemClass . '/' . $commentData['indexData']['index_item_id'],
|
|
'key2' => $commentClass . '/' . $commentData['indexData']['index_object_id'],
|
|
'replies' => ( $item instanceof Item ) ? $item->commentCount() : NULL,
|
|
'reactions' => IPS::classUsesTrait( $itemClass, 'IPS\Content\Reactable' ) ? $item->reactionCount() : NULL,
|
|
'image' => $attachment
|
|
);
|
|
}
|
|
|
|
Output::i()->json([
|
|
'results' => $return
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Sync Hive Membership data
|
|
*
|
|
* @return void
|
|
*/
|
|
protected function _syncMembers()
|
|
{
|
|
if( !isset( $this->payload['sync_data'] ) OR !\count( $this->payload['sync_data'] ) )
|
|
{
|
|
Output::i()->json( [ 'error' => 'Missing sync data' ], 400 );
|
|
}
|
|
|
|
$memberIds = array_keys( $this->payload['sync_data'] );
|
|
$memberData = new ActiveRecordIterator( Db::i()->select( '*', 'core_members', [ Db::i()->in( 'member_id', $memberIds ) ] ), 'IPS\Member' );
|
|
$respond = [];
|
|
$seen = [];
|
|
|
|
foreach( $memberData as $member )
|
|
{
|
|
$generatedHash = Hive::groupHash( $member );
|
|
|
|
if( $generatedHash !== $this->payload['sync_data'][ $member->member_id ] )
|
|
{
|
|
$respond[ $member->member_id ] = $generatedHash;
|
|
}
|
|
$seen[] = $member->member_id;
|
|
}
|
|
|
|
/* Record subscribe confirm */
|
|
Db::i()->update( 'core_hive_subscribers', [ 'subscribe_confirmed' => 1 ], [ [ 'subscribe_confirmed=?', 0 ], [ Db::i()->in( 'member_id', $memberIds ) ] ] );
|
|
|
|
/* Those that haven't been seen, thus deleted */
|
|
$deleted = array_diff( $memberIds, $seen );
|
|
|
|
foreach( $deleted as $del )
|
|
{
|
|
$respond[ $del ] = 'guest';
|
|
}
|
|
|
|
Output::i()->json( $respond );
|
|
}
|
|
|
|
/**
|
|
* Unfollow
|
|
*
|
|
* @return void
|
|
*/
|
|
protected function _unfollow()
|
|
{
|
|
Db::i()->delete( 'core_hive_subscribers', [ 'member_id=?', (int) $this->payload['member_id'] ] );
|
|
Output::i()->json( 'ok' );
|
|
}
|
|
|
|
/**
|
|
* Verify JWT
|
|
*
|
|
* @return void
|
|
*/
|
|
protected function _verifyJwt()
|
|
{
|
|
/* Make sure the request is PUT */
|
|
if( ! isset( $_SERVER['REQUEST_METHOD'] ) OR mb_strtoupper( $_SERVER['REQUEST_METHOD'] ) !== 'POST' )
|
|
{
|
|
Output::i()->json( array( 'error' => 'Invalid HTTP Method' ), 400 );
|
|
}
|
|
|
|
/* Make sure we have the hive key (activated) */
|
|
if( ! Settings::i()->hive_enabled )
|
|
{
|
|
Output::i()->json( array( 'error' => 'Community Hive not enabled' ), 404 );
|
|
}
|
|
|
|
/* Do we have the JWT */
|
|
$token = file_get_contents('php://input');
|
|
if( empty( $token ) )
|
|
{
|
|
Output::i()->json( array( 'error' => 'Missing JWT' ), 401 );
|
|
}
|
|
|
|
$jwk = new \Jose\Component\Core\JWK([
|
|
'kty' => 'oct',
|
|
'k' => base64_encode( Settings::i()->hive_key )
|
|
]);
|
|
|
|
$jwsVerifier = new \Jose\Component\Signature\JWSVerifier(
|
|
new \Jose\Component\Core\AlgorithmManager([ new \Jose\Component\Signature\Algorithm\HS256() ])
|
|
);
|
|
|
|
$jwsCompactSerializer = new CompactSerializer();
|
|
|
|
$data = $jwsCompactSerializer->unserialize( $token );
|
|
if( !$jwsVerifier->verifyWithKey( $data, $jwk, 0 ) )
|
|
{
|
|
Output::i()->json( array( 'error' => 'Invalid JWT' ), 401 );
|
|
}
|
|
|
|
$this->payload = json_decode( $data->getPayload(), true );
|
|
|
|
$claimCheckerManager = new ClaimCheckerManager(
|
|
[
|
|
new IssuerChecker( ['communityhive'] ),
|
|
new AudienceChecker( rtrim( Settings::i()->base_url, '/' ) ),
|
|
new IssuedAtChecker( 1000 ),
|
|
new NotBeforeChecker( 1000 ),
|
|
new ExpirationTimeChecker( 1000 )
|
|
]
|
|
);
|
|
|
|
try
|
|
{
|
|
$claimCheckerManager->check( $this->payload, [ 'iss', 'sub', 'exp', 'aud', 'nbf', 'iat' ] );
|
|
}
|
|
catch( MissingMandatoryClaimException | InvalidClaimException $e )
|
|
{
|
|
Output::i()->json( array( 'message' => $e->getMessage() ), 400 );
|
|
}
|
|
|
|
parent::execute();
|
|
}
|
|
} |