View file IPS Community Suite 4.7.8 NULLED/system/Content/Api/GraphQL/ItemType.php

File size: 22.32Kb
<?php
/**
 * @brief		Base class for Content Items
 * @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		28 Aug 2018
 */

namespace IPS\Content\Api\GraphQL;
use GraphQL\Type\Definition\ObjectType;
use IPS\Api\GraphQL\TypeRegistry;

/* 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;
}

/**
 * @brief	Base mutator class for Content Items
 */
class _ItemType extends ObjectType
{
	/*
	 * @brief 	The item classname we use for this type
	 */
	protected static $itemClass	= '\IPS\Content\Item';

	/*
	 * @brief 	GraphQL type name
	 */
	protected static $typeName = 'core_Item';

	/*
	 * @brief 	GraphQL type description
	 */
	protected static $typeDescription = 'A generic content item';


	public function __construct()
	{
		$config = [
			'name' => static::$typeName,
			'description' => static::$typeDescription,
			'fields' => function () {
				return $this->fields();
			}
		];

		parent::__construct($config);
	}

	/**
	 * Get the fields that this type supports
	 *
	 * @return	array
	 */
	public function fields()
	{
		return array(
			'id' => [
				'type' => TypeRegistry::id(),
				'resolve' => function ($item) {
					$idColumn = static::getIdColumn($item);
					return $item->$idColumn;
				}
			],
			'url' => [
				'type' => TypeRegistry::url(),
				'resolve' => function ($item) {
					return $item->url();
				}
			],
			'title' => [
				'type' => TypeRegistry::string(),
				'resolve' => function ($item) {
					return $item->mapped('title');
				}
			],
			'seoTitle' => [
				'type' => TypeRegistry::string(),
				'resolve' => function ($item) {
					return \IPS\Http\Url\Friendly::seoTitle( $item->mapped('title') );
				}
			],
			'views' => [
				'type' => TypeRegistry::int(),
				'resolve' => function ($item) {
					if ( \in_array( 'IPS\Content\Views', class_implements( $item ) ) )
					{
						return $item->mapped('views');
					}

					return NULL;
				}
			],
			'commentCount' => [
				'type' => TypeRegistry::int(),
				'args' => [
					'includeHidden' => [
						'type' => TypeRegistry::boolean(),
						'description' => "Should the count include hidden/unapproved comments that the logged in member can see?",
						'defaultValue' => FALSE
					]
				],
				'resolve' => function ($item, $args) {
					if( $args['includeHidden'] && method_exists( $item, 'commentCount' )  ){
						return $item->commentCount();
					}

					return $item->mapped('num_comments');
				}
			],
			'isLocked' => [
				'type' => TypeRegistry::boolean(),
				'resolve' => function ($item) {
					if ( \in_array( 'IPS\Content\Lockable', class_implements( $item ) ) )
					{
						return (bool) $item->locked();
					}

					return NULL;
				}
			],
			'isPinned' => [
				'type' => TypeRegistry::boolean(),
				'resolve' => function ($item) {
					if ( \in_array( 'IPS\Content\Pinnable', class_implements( $item ) ) )
					{
						return (bool) $item->mapped('pinned');
					}

					return NULL;
				}
			],
			'isFeatured' => [
				'type' => TypeRegistry::boolean(),
				'resolve' => function ($item) {
					if ( \in_array( 'IPS\Content\Featurable', class_implements( $item ) ) )
					{
						return (bool) $item->mapped('featured');
					}

					return NULL;
				}
			],
			'hiddenStatus' => [
				'type' => TypeRegistry::eNum([
					'name' => static::$typeName . '_hiddenStatus',
					'values' => ['HIDDEN', 'PENDING', 'DELETED']
				]),
				'resolve' => function ($item) {
					if ( !\in_array( 'IPS\Content\Hideable', class_implements( $item ) ) )
					{
						switch( $item->hidden() ){
							case -2:
								return 'DELETED';
							case -1:
								return 'HIDDEN';
							case 1:
								return 'PENDING';
							default:
								return NULL;
						}
					}
				}
			],
			'updated' => [
				'type' => TypeRegistry::string(),
				'resolve' => function ($item) {
					return $item->mapped('updated');
				}
			],
			'started' => [
				'type' => TypeRegistry::string(),
				'resolve' => function ($item) {
					return $item->mapped('date');
				}
			],
			'isUnread' => [
				'type' => TypeRegistry::boolean(),
				'resolve' => function ($item) {
					if ( \in_array( 'IPS\Content\ReadMarkers', class_implements( $item ) ) )
					{
						return $item->unread();
					}

					return NULL;
				}
			],
			'timeLastRead' => [
				'type' => TypeRegistry::int(),
				'resolve' => function ($item) {
					return static::timeLastRead($item);
				}
			],
			'unreadCommentPosition' => [
				'type' => TypeRegistry::int(),
				'description' => 'Returns the position of the comment that is the first unread in this topic',
				'resolve' => function ($item) {
					if ( \in_array( 'IPS\Content\ReadMarkers', class_implements( $item ) ) )
					{
						return static::getUnreadPosition($item);
					}

					return NULL;
				}
			],
			'findCommentPosition' => [
				'type' => TypeRegistry::int(),
				'args' => [
					'findComment' => TypeRegistry::int()
				],
				'description' => 'Returns the position of the comment provided in the required findComment arg',
				'resolve' => function ($item, $args) {
					return static::findCommentPosition($item, $args);
				}
			],
			'follow' => [
				'type' => TypeRegistry::follow(),
				'resolve' => function ($item) {
					if( \in_array( 'IPS\Content\Followable', class_implements( $item ) ) && isset( static::$followData ) && \is_array( static::$followData ) ){
						$idColumn = static::getIdColumn($item);
						return array_merge( static::$followData, array(
							'id' => $item->$idColumn,
							'item' => $item,
							'itemClass' => static::$itemClass
						));
					}

					return NULL;
				}
			],
			'tags' => [
				'type' => TypeRegistry::listOf( \IPS\core\api\GraphQL\TypeRegistry::tag() ),
				'resolve' => function ($item) {
					if ( \in_array( 'IPS\Content\Tags', class_implements( $item ) ) )
					{
						return static::tags($item);
					}

					return NULL;
				}
			],
			'author' => [
				'type' => \IPS\core\api\GraphQL\TypeRegistry::member(),
				'resolve' => function ($item, $args) {
					return static::author($item, $args);
				}
			],
			'container' => [
				// @todo return generic node
				'type' => \IPS\Node\Api\GraphQL\TypeRegistry::node(),
				'resolve' => function ($item) {
					return $item->container();
				}
			],
			'content' => [
				'type' => TypeRegistry::richText(),
				'resolve' => function ($item) {
					return $item->content();
				}
			],
			'contentImages' => [
				'type' => TypeRegistry::listOf( TypeRegistry::string() ),
				'resolve' => function ($item) {
					return static::contentImages($item);
				}
			],
			'hasPoll' => [
				'type' => TypeRegistry::boolean(),
				'resolve' => function ($item) {
					return $item instanceof \IPS\Content\Polls && $item->poll_state;
				}
			],
			'poll' => [
				'type' => \IPS\core\api\GraphQL\TypeRegistry::poll(),
				'resolve' => function ($item) {
					if( $item instanceof \IPS\Content\Polls && $item->poll_state )
					{
						return $item->getPoll();
					}

					return NULL;
				}
			],
			'firstCommentRequired' => [
				'type' => TypeRegistry::boolean(),
				'resolve' => function ($item) {
					return static::$itemClass::$firstCommentRequired;
				}
			],
			'articleLang' => [
				'type' => new ObjectType([
					'name' => static::$typeName . '_articleLang',
					'fields' => [
						'indefinite' => TypeRegistry::string(),
						'definite' => [
							'type' => TypeRegistry::string(),
							'args' => [
								'uppercase' => [
									'type' => TypeRegistry::boolean(),
									'defaultValue' => FALSE
								]
							]
						]
					],
					'resolveField' => function ($item, $args, $context, $info) {
						$className = \get_class( $item );

						switch( $info->fieldName )
						{
							case 'indefinite':
								return $className::_indefiniteArticle( NULL );
							break;
							case 'definite':
								return $className::_definiteArticle( NULL, NULL, $args['uppercase'] ? array( 'ucfirst' => TRUE ) : array() );	
							break;
						}
					}
				]),
				'resolve' => function ($item) {
					return $item;
				}
			],
			'lastCommentAuthor' => [
				'type' => \IPS\core\api\GraphQL\TypeRegistry::member(),
				'resolve' => function ($item, $args) {
					return static::lastCommentAuthor($item, $args);
				}
			],
			'lastCommentDate' => [
				'type' => TypeRegistry::string(),
				'resolve' => function ($item, $args) {
					return static::lastCommentDate($item, $args);
				}
			],
			'comments' => [
				'type' => TypeRegistry::listOf( static::getCommentType() ),
				'args' => static::getCommentType()::args(),
				'resolve' => function ($item, $args, $context) {
					return static::comments($item, $args);
				}
			],
			'itemPermissions' => [
				'type' => new ObjectType([
					'name' => static::$typeName . '_itemPermissions',
					'fields' => static::getItemPermissionFields()
				]),
				'resolve' => function ($item) {
					return $item;
				}
			],
			'uploadPermissions' => [
				'type' => \IPS\core\api\GraphQL\TypeRegistry::attachmentPermissions(),
				'description' => 'Details about what the user can attach when commenting on this item.',
				'args' => [
					'postKey' => TypeRegistry::nonNull( TypeRegistry::string() ),
				],
				'resolve' => function( $node, $args, $context ) {
					return array( 'postKey' => $args['postKey'] );
				}
			],
			'reportStatus' => [
				'type' => \IPS\core\api\GraphQL\TypeRegistry::report(),
				'resolve' => function ($item) {
					return $item;
				}
			]
		);
	}

	/**
	 * Return the ID column for the provided item
	 *
	 * @return	array
	 */
	protected function getIdColumn($item)
	{
		$className = \get_class( $item );
		return $className::$databaseColumnId;
	}

	/**
	 * Return the arguments that can be used to filter topics. Passed into NodeType.
	 *
	 * @return	array
	 */
	public static function args() 
	{
		return array(
			'offset' => [
				'type' => TypeRegistry::int(),
				'defaultValue' => 0
			],
			'limit' => [
				'type' => TypeRegistry::int(),
				'defaultValue' => 25
			],
			'orderBy' => [
				'type' => TypeRegistry::eNum([
					'name' => static::$typeName . '_order_by',
					'description' => 'Fields on which items can be sorted',
					'values' => static::getOrderByOptions()
				]),
				'defaultValue' => NULL // will use default sort option
			],
			'orderDir' => [
				'type' => TypeRegistry::eNum([
					'name' => static::$typeName . '_order_dir',
					'description' => 'Directions in which items can be sorted',
					'values' => [ 'ASC', 'DESC' ]
				]),
				'defaultValue' => 'DESC'
			],
			'honorPinned' => [
				'type' => TypeRegistry::boolean(),
				'defaultValue' => true
			]
		);
	}

	/**
	 * Return the available sorting options
	 *
	 * @return	array
	 */
	public static function getOrderByOptions()
	{
		return array('title', 'author_name', 'last_comment_name');
	}

	/**
	 * Get the field config for the item permissions query
	 *
	 * @return	array
	 */
	public static function getItemPermissionFields()
	{
		return array(
			'canComment' => [
				'type' => TypeRegistry::boolean(),
				'description' => 'Can the logged in user comment on this item?',
				'resolve' => function ($item, $args, $context) {
					return $item->canComment( \IPS\Member::loggedIn(), FALSE );
				}
			],
			'commentInformation' => [
				'type' => TypeRegistry::string(),
				'description' => 'A message providing some information about the comment form availability',
				'resolve' => function ($item, $args, $context) {
					if( $item->canComment( \IPS\Member::loggedIn(), FALSE ) )
					{
						if( $item instanceof \IPS\Content\Lockable && $item->locked() )
						{
							return 'locked_can_comment';
						}
					}
					else
					{
						if( $item instanceof \IPS\Content\Lockable && $item->locked() )
						{
							return 'locked_cannot_comment';
						}
						elseif( \IPS\Member::loggedIn()->restrict_post )
						{
							return 'restricted_cannot_comment';
						} 
						elseif( \IPS\Member::loggedIn()->members_bitoptions['unacknowledged_warnings'] )
						{
							return 'unacknowledged_warning_cannot_post';
						}
						elseif( !\IPS\Member::loggedIn()->checkPostsPerDay() )
						{
							return 'member_exceeded_posts_per_day';
						}
					}

					return NULL;
				}
			],
			'canCommentIfSignedIn' => [
				'type' => TypeRegistry::boolean(),
				'description' => 'Returns boolean indicating whether a guest who signs in would be able to comment on this item. Returns NULL if already signed in.',
				'resolve' => function ($item, $args, $context) {
					if ( !\IPS\Member::loggedIn()->member_id )
					{
						$testUser = new \IPS\Member;
						$testUser->member_group_id = \IPS\Settings::i()->member_group;
						
						return $item->canComment( $testUser, FALSE );
					}

					return NULL;
				}
			],
			'canMarkAsRead' => [
				'type' => TypeRegistry::boolean(),
				'description' => 'Boolean indicating whether this item supports read markers, and if so, if the user can mark as read',
				'resolve' => function ($item) {
					return $item instanceof \IPS\Content\ReadMarkers && \IPS\Member::loggedIn()->member_id;
				}
			],
			'canReport' => [
				'type' => TypeRegistry::boolean(),
				'description' => 'Can the user report this item?',
				'resolve' => function ($item) {
					return $item->canReport( \IPS\Member::loggedIn() ) === TRUE;
				}
			],
			'canReportOrRevoke' => [
				'type' => TypeRegistry::boolean(),
				'description' => 'Can the user report (or revoke a report) on this item?',
				'resolve' => function ($item) {
					return $item->canReportOrRevoke( \IPS\Member::loggedIn() ) === TRUE;
				}
			],
			'canShare' => [
				'type' => TypeRegistry::boolean(),
				'description' => 'Can this item be shared?',
				'resolve' => function ($item) {
					return $item->canShare();
				}
			]
		);
	}

	/**
	 * Get the comment type that goes with this item type
	 *
	 * @return	ObjectType
	 */
	protected static function getCommentType()
	{
		return \IPS\Content\Api\GraphQL\TypeRegistry::comment();
	}

	/**
	 * Return content images for the provided item
	 *
	 * @return	array|null
	 */
	protected static function contentImages($item)
	{
		try
		{
			if ( $images = $item->contentImages( 20 ) )
			{
				foreach( $images as $image )
				{
					foreach( $image as $extension => $file )
					{
						$toReturn[] = (string) \IPS\File::get( $extension, $file )->url;
					}
				}
				return $toReturn;
			}
		}
		catch( \BadMethodCallException $e ) { }

		return NULL;
	}

	/**
	 * Resolve the findCommentPosition field
	 *
	 * @param 	\IPS\forums\Topic
	 * @param 	array 	Arguments passed to this resolver
	 * @return	array
	 */
	protected static function findCommentPosition($item, $args)
	{
		if( $args['findComment'] === NULL )
		{
			return NULL;
		}

		try 
		{
			$comment = static::$itemClass::$commentClass::load( $args['findComment'] );

			// Check this comment belongs to this topic
			if( $comment->item() !== $item )
			{
				return NULL;
			}
		}
		catch (\Exception $e)
		{
			return NULL;
		}

		return static::findComment($comment, $item);
	}

	/**
	 * Resolve the comments field
	 *
	 * @param 	\IPS\forums\Topic
	 * @param 	array 	Arguments passed to this resolver
	 * @return	array
	 */
	protected static function comments($item, $args)
	{
		$offset = 0;
		$limit = 25;

		/* Figure out where we're starting our offset */
		switch( $args['offsetPosition'] )
		{
			case 'UNREAD':
				$offset = static::getUnreadPosition($item) + $args['offsetAdjust'];
			break;
			case 'LAST':
				// Since we're zero-indexed, when we're working from the end we need to go one more to get the last item
				$offset = static::getEndPosition($item) + $args['offsetAdjust'] + 1;
			break;
			case 'ID':
				if( !isset( $args['findComment'] ) )
				{
					throw new \OutOfRangeException;
				}

				$offset = static::getCommentPosition($item, $args['findComment']) + $args['offsetAdjust'];
			break;
			case 'FIRST':
			default:
				$offset = 0 + $args['offsetAdjust'];
		}

		/* Ensure offset is never lower than 0 */
		$offset = max( $offset, 0 );
		$limit = min( $args['limit'], 50 );

		if( $args['orderBy'] == 'DATE' )
		{
			$args['orderBy'] = static::$itemClass::$commentClass::$databaseColumnMap['date'];
		}

		
		// We can't allow straight boolean TRUE here otherwise members without permission
		// will see them. Instead, if TRUE is passed as a value in the query, set the value
		// to NULL which honors permissions.
		$includeDeleted = $args['includeDeleted'] ? NULL : FALSE;
		$includeHidden = $args['includeHidden'] ? NULL : FALSE;

		return $item->comments( $limit, $offset, $args['orderBy'], $args['orderDir'], NULL, $includeHidden, NULL, NULL, NULL, $includeDeleted );
	}

	/**
	 * Get the position of a specific comment
	 *
	 * @param 	\IPS\Content\Item
	 * @return	int
	 */
	protected static function getCommentPosition($item, $commentID)
	{
		try 
		{
			$comment = static::$itemClass::$commentClass::load($commentID);
			return static::findComment($comment, $item);
		}
		catch(\Exception $error)
		{}
	}

	/**
	 * Get the position of the last comment
	 *
	 * @param 	\IPS\Content\Item
	 * @return	int
	 */
	protected static function getEndPosition($item)
	{
		$comment = $item->comments( 1, NULL, 'date', 'desc' );
		return static::findComment($comment, $item);
	}

	/**
	 * Get the position of the first unread comment
	 *
	 * @param 	\IPS\Content\Item
	 * @return	int
	 */
	protected static function getUnreadPosition($item)
	{
		try
		{	
			$class = static::$itemClass;
			$timeLastRead = $item->timeLastRead();

			if ( $timeLastRead instanceof \IPS\DateTime )
			{
				$comment = NULL;
				if( \IPS\DateTime::ts( $item->mapped('date') ) < $timeLastRead )
				{
					$comment = $item->comments( 1, NULL, 'date', 'asc', NULL, NULL, $timeLastRead );
				}

				/* If we don't have any unread comments... */
				if ( !$comment and $class::$firstCommentRequired )
				{
					/* If we haven't read the item at all, go there */
					if ( $item->unread() )
					{
						return 0;
					}
					/* Otherwise, go to the last comment */
					else
					{
						$comment = $item->comments( 1, NULL, 'date', 'desc' );
					}
				}

				if( !$comment ){
					return 0;
				}
			}
			else
			{
				if ( $item->unread() )
				{
					/* If we do not have a time last read set for this content, fallback to the reset time */
					$resetTimes = \IPS\Member::loggedIn()->markersResetTimes( $class::$application );

					if ( array_key_exists( $item->container()->_id, $resetTimes ) and $item->mapped('date') < $resetTimes[ $item->container()->_id ] )
					{
						$comment = $item->comments( 1, NULL, 'date', 'asc', NULL, NULL, \IPS\DateTime::ts( $resetTimes[ $item->container()->_id ] ) );
						
						if ( !$comment || $class::$firstCommentRequired and $comment->isFirst() )
						{
							return 0;
						}
					}
					else
					{
						return 0;
					}
				}
				else
				{
					return 0;
				}
			}

			return static::findComment($comment, $item);
		}
		catch( \Exception $e )
		{
			return 0;
		}
	}

	/**
	 * Find the position of a comment
	 *
	 * @param 	\IPS\Content\Comment
	 * @param 	\IPS\Content\Item
	 * @return	int
	 */
	protected static function findComment($comment, $item)
	{
		try 
		{
			$commentClass = \get_class( $comment );
			$idColumn = $commentClass::$databaseColumnId;
			$itemColumn = $commentClass::$databaseColumnMap['item'];

			/* Work out where the comment is in the item */	
			$directional = ( \in_array( 'IPS\Content\Review', class_parents( $commentClass ) ) ) ? '>=?' : '<=?';
			$where = array(
				array( $commentClass::$databasePrefix . $itemColumn . '=?', $comment->$itemColumn ),
				array( $commentClass::$databasePrefix . $idColumn . $directional, $comment->$idColumn )
			);

			/* Exclude content pending deletion, as it will not be shown inline  */
			if ( isset( $commentClass::$databaseColumnMap['approved'] ) )
			{
				$where[] = array( $commentClass::$databasePrefix . $commentClass::$databaseColumnMap['approved'] . '<>?', -2 );
			}
			elseif( isset( $commentClass::$databaseColumnMap['hidden'] ) )
			{
				$where[] = array( $commentClass::$databasePrefix . $commentClass::$databaseColumnMap['hidden'] . '<>?', -2 );
			}

			if ( $commentClass::commentWhere() !== NULL )
			{
				$where[] = $commentClass::commentWhere();
			}
			if ( $container = $item->containerWrapper() )
			{
				if ( $commentClass::modPermission( 'view_hidden', NULL, $container ) === FALSE )
				{
					if ( isset( $commentClass::$databaseColumnMap['approved'] ) )
					{
						$where[] = array( $commentClass::$databasePrefix . $commentClass::$databaseColumnMap['approved'] . '=?', 1 );
					}
					elseif( isset( $commentClass::$databaseColumnMap['hidden'] ) )
					{
						$where[] = array( $commentClass::$databasePrefix . $commentClass::$databaseColumnMap['hidden'] . '=?', 0 );
					}
				}
			}
			$commentPosition = \IPS\Db::i()->select( 'COUNT(*) AS position', $commentClass::$databaseTable, $where )->first();

			if( static::$itemClass::$firstCommentRequired ){
				$commentPosition = $commentPosition - 1;
			}

			return $commentPosition;
		} 
		catch( \Exception $e )
		{
			return 0;
		}
	}

	/**
	 * Resolve the tags field
	 *
	 * @param 	\IPS\Content\Item
	 * @return	int
	 */
	protected static function timeLastRead($item)
	{
		$time = $item->timeLastRead();
		if( $time instanceof \IPS\DateTime )
		{
			return (int) $time->setTimezone( new \DateTimeZone( "UTC" ) )->format('U');
		}
		return NULL;
	}

	/**
	 * Resolve the tags field
	 *
	 * @param 	\IPS\Content\Item
	 * @return	array
	 */
	protected static function tags($item)
	{
		$tags = $item->tags();
		return \is_array( $tags ) ? $tags : array();
	}

	/**
	 * Resolve the author field
	 *
	 * @param 	null
	 * @param 	array 	Arguments passed to this resolver
	 * @return	\IPS\forums\Forum
	 */
	protected static function author($item, $args)
	{
		return $item->author();
	}

	/**
	 * Resolve the last comment author field
	 *
	 * @param 	\IPS\forums\Topic
	 * @param 	array 	Arguments passed to this resolver
	 * @return	\IPS\Member
	 */
	protected static function lastCommentAuthor($item, $args)
	{
		if( $item->mapped('num_comments') )
		{
			return $item->lastCommenter();
		}
		
		return $item->author();
	}

	 /**
	 * Resolve the last comment date field
	 *
	 * @param 	\IPS\forums\Topic
	 * @param 	array 	Arguments passed to this resolver
	 * @return	string
	 */
	protected static function lastCommentDate($item, $args)
	{
		if( $item->mapped('last_comment') )
		{
			return $item->mapped('last_comment');
		}
		
		return $item->mapped('date');
	}
}