Invision Power Services, Inc. * @copyright (c) Invision Power Services, Inc. * @license https://www.invisioncommunity.com/legal/standards/ * @package Invision Community * @subpackage Downloads * @since 8 Oct 2013 */ namespace IPS\downloads; /* To prevent PHP errors (extending class does not exist) revealing path */ use BadMethodCallException; use CachingIterator; use DateInterval; use DomainException; use Exception; use InvalidArgumentException; use IPS\Api\Webhook; use IPS\Application; use IPS\Content\Anonymous; use IPS\Content\Comment; use IPS\Content\EditHistory; use IPS\Content\Embeddable; use IPS\Content\Filter; use IPS\Content\Followable; use IPS\Content\Hideable; use IPS\Content\Item; use IPS\Content\ItemTopic; use IPS\Content\Lockable; use IPS\Content\MetaData; use IPS\Content\Featurable; use IPS\Content\Pinnable; use IPS\Content\Reactable; use IPS\Content\ReadMarkers; use IPS\Content\Reportable; use IPS\Content\Shareable; use IPS\Content\Statistics; use IPS\Content\ViewUpdates; use IPS\Content\Taggable; use IPS\DateTime; use IPS\Db; use IPS\Db\Select; use IPS\downloads\extensions\nexus\Item\File as FileNexusItem; use IPS\downloads\File\PendingVersion; use IPS\Events\Event; use IPS\File as SystemFile; use IPS\File\Iterator; use IPS\Helpers\Form; use IPS\Helpers\Form\Custom; use IPS\Helpers\Form\Editor; use IPS\Helpers\Form\Node; use IPS\Helpers\Form\Radio; use IPS\Helpers\Form\Upload; use IPS\Helpers\Menu; use IPS\Http\Url; use IPS\Http\Url\Friendly; use IPS\Math\Number; use IPS\Member; use IPS\Member\Group; use IPS\nexus\Customer; use IPS\nexus\Form\Money as FormMoney; use IPS\nexus\Form\RenewalTerm; use IPS\nexus\Money; use IPS\nexus\Package; use IPS\nexus\Purchase\RenewalTerm as PurchaseRenewalTerm; use IPS\nexus\Tax; use IPS\Node\Model; use IPS\Notification; use IPS\Output; use IPS\Output\Plugin\Filesize; use IPS\Output\UI\UiExtension; use IPS\Patterns\ActiveRecordIterator; use IPS\Request; use IPS\Settings; use IPS\Task; use IPS\Theme; use OutOfBoundsException; use OutOfRangeException; use RuntimeException; use UnderflowException; use function array_slice; use function count; use function defined; use function in_array; use function is_array; use function is_string; use const IPS\NOTIFICATION_BACKGROUND_THRESHOLD; if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) ) { header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' ); exit; } /** * File Model */ class File extends Item implements Embeddable, Filter { use Reactable, Reportable, Pinnable, Anonymous, Followable, Lockable, ItemTopic, MetaData, Shareable, Taggable, EditHistory, ReadMarkers, Hideable, Statistics, ViewUpdates, Featurable { Hideable::unhide as public _unhide; Hideable::sendApprovedNotification as public _sendApprovedNotification; Hideable::logDelete as public _logDelete; ItemTopic::syncTopic as public _syncTopic; } /** * @brief Application */ public static string $application = 'downloads'; /** * @brief Module */ public static string $module = 'downloads'; /** * @brief Database Table */ public static ?string $databaseTable = 'downloads_files'; /** * @brief Database Prefix */ public static string $databasePrefix = 'file_'; /** * @brief Multiton Store */ protected static array $multitons; /** * @brief Node Class */ public static ?string $containerNodeClass = 'IPS\downloads\Category'; /** * @brief Comment Class */ public static ?string $commentClass = 'IPS\downloads\File\Comment'; /** * @brief Review Class */ public static string $reviewClass = 'IPS\downloads\File\Review'; /** * @brief Database Column Map */ public static array $databaseColumnMap = array( 'container' => 'cat', 'author' => 'submitter', 'author_name' => 'author_name', 'views' => 'views', 'title' => 'name', 'content' => 'desc', 'num_comments' => 'comments', 'unapproved_comments' => 'unapproved_comments', 'hidden_comments' => 'hidden_comments', 'num_reviews' => 'reviews', 'unapproved_reviews' => 'unapproved_reviews', 'hidden_reviews' => 'hidden_reviews', 'last_comment' => 'last_comment', 'last_review' => 'last_review', 'date' => 'submitted', 'updated' => 'updated', 'rating' => 'rating', 'approved' => 'open', 'approved_by' => 'approver', 'approved_date' => 'approvedon', 'pinned' => 'pinned', 'featured' => 'featured', 'locked' => 'locked', 'ip_address' => 'ipaddress', 'meta_data' => 'meta_data', 'edit_time' => 'edit_time', 'edit_member_name' => 'edit_name', 'edit_show' => 'append_edit', 'edit_reason' => 'edit_reason', 'is_anon' => 'is_anon', 'last_comment_anon' => 'last_comment_anon', 'item_topicid' => 'topicid' ); /** * @brief Title */ public static string $title = 'downloads_file'; /** * @brief Icon */ public static string $icon = 'download'; /** * @brief Form Lang Prefix */ public static string $formLangPrefix = 'file_'; /** * @brief [Content] Key for hide reasons */ public static ?string $hideLogKey = 'downloads-file'; /** * Columns needed to query for search result / stream view * * @return array */ public static function basicDataColumns(): array { $return = parent::basicDataColumns(); $return[] = 'file_primary_screenshot'; $return[] = 'file_version'; $return[] = 'file_downloads'; $return[] = 'file_cost'; $return[] = 'file_nexus'; return $return; } /** * Query to get additional data for search result / stream view * * @param array $items Item data (will be an array containing values from basicDataColumns()) * @return array */ public static function searchResultExtraData( array $items ): array { $screenshotIds = array(); foreach ( $items as $itemData ) { if ( $itemData['file_primary_screenshot'] ) { $screenshotIds[] = $itemData['file_primary_screenshot']; } } if ( count( $screenshotIds ) ) { return iterator_to_array( Db::i()->select( array( 'record_file_id', 'record_location', 'record_thumb' ), 'downloads_files_records', Db::i()->in( 'record_id', $screenshotIds ) )->setKeyField( 'record_file_id' ) ); } return array(); } /** * Set name * * @param string $name Name * @return void */ public function set_name( string $name ) : void { $this->_data['name'] = $name; $this->_data['name_furl'] = Friendly::seoTitle( $name ); } /** * Get SEO name * * @return string */ public function get_name_furl(): string { if( !$this->_data['name_furl'] ) { $this->name_furl = Friendly::seoTitle( $this->name ); $this->save(); } return $this->_data['name_furl'] ?: Friendly::seoTitle( $this->name ); } /** * Get primary screenshot ID * * @return int|null */ public function get__primary_screenshot(): ?int { return ( isset( $this->_data['primary_screenshot'] ) ) ? $this->_data['primary_screenshot'] : NULL; } /** * @brief Cached URLs */ protected mixed $_url = array(); /** * @brief URL Base */ public static string $urlBase = 'app=downloads&module=downloads&controller=view&id='; /** * @brief URL Base */ public static string $urlTemplate = 'downloads_file'; /** * @brief SEO Title Column */ public static string $seoTitleColumn = 'name_furl'; /** * Get URL for last comment page * * @return Url */ public function lastCommentPageUrl(): Url { return parent::lastCommentPageUrl()->setQueryString( 'tab', 'comments' ); } /** * Get URL for last review page * * @return Url */ public function lastReviewPageUrl(): Url { return parent::lastReviewPageUrl()->setQueryString( 'tab', 'reviews' ); } /** * Get template for content tables * * @return array */ public static function contentTableTemplate(): array { /* Load our CSS */ \IPS\downloads\Application::outputCss(); return array( Theme::i()->getTemplate( 'browse', 'downloads', 'front' ), 'rows' ); } /** * HTML to manage an item's follows * * @return array */ public static function manageFollowRows(): array { return array( Theme::i()->getTemplate( 'global', 'downloads', 'front' ), 'manageFollowRow' ); } /** * Files */ protected array $_files = array(); /** * Get files * * @param int|null $version If provided, will get the file records for a specific previous version (downloads_filebackup.b_id) * @param bool $includeLinks If true, will include linked files * @param bool $pendingVersion Only include files from a pending new version * @return Iterator */ public function files( int $version=NULL, bool $includeLinks=TRUE, bool $pendingVersion=FALSE ): Iterator { if( isset( $this->_files[ (int) $version ] ) ) { return $this->_files[ (int) $version ]; } $where = $includeLinks ? array( array( 'record_file_id=? AND ( record_type=? OR record_type=? )', $this->id, 'upload', 'link' ) ) : array( array( 'record_file_id=? AND record_type=?', $this->id, 'upload' ) ); if ( $version !== NULL ) { try { $backup = Db::i()->select( 'b_records', 'downloads_filebackup', array( 'b_id=?', $version ) )->first(); $where[] = Db::i()->in( 'record_id', explode( ',', $backup ) ); } catch( UnderflowException $e ) { /* Default to current version if the previous version does not exist */ $where[] = array( 'record_backup=0' ); } } else { $where[] = array( 'record_backup=0' ); } /* Exclude future versions */ $where[] = array( 'record_hidden=?', $pendingVersion ); $iterator = Db::i()->select( '*', 'downloads_files_records', $where )->setKeyField( 'record_id' ); $iterator = new Iterator( $iterator, 'downloads_Files', 'record_location', FALSE, 'record_realname', 'record_size' ); $this->_files[ (int) $version ] = $iterator; return $this->_files[ (int) $version ]; } /** * Total filesize */ protected ?int $_filesize = NULL; /** * Get Total filesize * * @return int|null */ public function filesize(): ?int { if ( $this->_filesize === NULL ) { $this->_filesize = Db::i()->select( 'SUM(record_size)', 'downloads_files_records', array( 'record_file_id=? AND record_type=? AND record_backup=0', $this->id, 'upload' ) )->first(); } return $this->_filesize; } /** * Is this a paid file? * * @return bool */ public function isPaid(): bool { if ( Application::appIsEnabled( 'nexus' ) and Settings::i()->idm_nexus_on ) { if ( $this->nexus ) { return TRUE; } if ( $this->cost ) { $costs = json_decode( $this->cost, TRUE ); if ( is_array( $costs ) ) { foreach ( $costs as $currency => $data ) { if ( $data['amount'] ) { return TRUE; } } } else { return TRUE; } } } return FALSE; } /** * Is Purchasable? * * @param bool $checkPaid Check if the file is a paid file * @return bool */ public function isPurchasable( bool $checkPaid = TRUE ): bool { /* If it's not a paid file, then it's not purchasable */ if ( $checkPaid and !$this->isPaid() ) { return FALSE; } return (bool) $this->purchasable; } /** * Can enable purchases? * * @param Member|NULL $member Member to check, or NULL for currently logged in member * @return bool */ public function canEnablePurchases( ?Member $member = NULL ): bool { if ( !$this->isPaid() ) { return FALSE; } $member = $member ?: Member::loggedIn(); if( !$member->modPermission('can_make_purchasable') ) { return FALSE; } /* If there isn't an existing product record, do not allow purchases to be re-enabled */ if ( $this->nexus ) { $productIds = explode( ',', $this->nexus ); $hasProduct = false; foreach ( $productIds as $productId ) { try { Package::load( $productId ); $hasProduct = true; break; } catch ( OutOfRangeException $e ) { } } if( !$hasProduct ) { return FALSE; } } return TRUE; } /** * Can disable purchases? * * @param Member|NULL $member Member to check, or NULL for currently logged in member * @return bool */ public function canDisablePurchases( ?Member $member = NULL ): bool { if ( !$this->isPaid() ) { return FALSE; } $member = $member ?: Member::loggedIn(); return (bool) $member->modPermission('can_make_unpurchasable'); } /** * Get Price * * @param Customer|null $customer * @return Money|NULL */ public function price( ?Customer $customer=null ): ?Money { return static::_price( $this->cost, $this->nexus, $customer ); } /** * Get Price * * @param string|null $cost The cost * @param string|null $nexusPackageIds Comma-delimited list of associated package IDs * @param Customer|null $customer * @return Money|NULL */ public static function _price( ?string $cost, ?string $nexusPackageIds, ?Customer $customer=null ): ?Money { if ( Application::appIsEnabled( 'nexus' ) and Settings::i()->idm_nexus_on ) { if ( $nexusPackageIds ) { $packages = explode( ',', $nexusPackageIds ); try { return Package::lowestPriceToDisplay( new ActiveRecordIterator( Db::i()->select( '*', 'nexus_packages', Db::i()->in( 'p_id', $packages ) ), 'IPS\nexus\Package' ), null, true ); } catch ( OutOfRangeException | OutOfBoundsException $e ) { } return NULL; } if ( $cost ) { $customer = $customer ?: Customer::loggedIn(); $currency = ( isset( Request::i()->cookie['currency'] ) and in_array( Request::i()->cookie['currency'], Money::currencies() ) ) ? Request::i()->cookie['currency'] : $customer->defaultCurrency(); /* If $cost is an empty JSON array, the conditional will evaluate false thus resulting in [] being passed to \IPS\nexus\Money (which will fail). */ $costs = json_decode( $cost, TRUE ); if ( is_array( $costs ) ) { if ( isset( $costs[ $currency ]['amount'] ) and $costs[ $currency ]['amount'] ) { return new Money( $costs[ $currency ]['amount'], $currency ); } } else { return new Money( $cost, $currency ); } } } return NULL; } /** * @brief Number of purchases */ protected static ?array $purchaseCounts = NULL; /** * Get number of purchases * * @return int|null */ public function purchaseCount(): ?int { if ( Application::appIsEnabled( 'nexus' ) and Settings::i()->idm_nexus_on AND !$this->nexus ) { if ( static::$purchaseCounts === NULL ) { static::$purchaseCounts = iterator_to_array( Db::i()->select( 'COUNT(*) AS count, ps_item_id', 'nexus_purchases', array( array( 'ps_app=? AND ps_type=?', 'downloads', 'file' ), Db::i()->in( 'ps_item_id', array_keys( static::$multitons ) ) ), NULL, NULL, 'ps_item_id' )->setKeyField('ps_item_id')->setValueField('count') ); foreach ( array_keys( static::$multitons ) as $k ) { if ( !isset( static::$purchaseCounts[ $k ] ) ) { static::$purchaseCounts[ $k ] = 0; } } } if ( !isset( static::$purchaseCounts[ $this->id ] ) ) { static::$purchaseCounts[ $this->id ] = Db::i()->select( 'COUNT(*)', 'nexus_purchases', array( 'ps_app=? AND ps_type=? AND ps_item_id=?', 'downloads', 'file', $this->id ) )->first(); } return static::$purchaseCounts[ $this->id ]; } return NULL; } /** * Get Renewal Term * * @param Customer|null $customer * @return PurchaseRenewalTerm|NULL */ public function renewalTerm( ?Customer $customer = null ): ?PurchaseRenewalTerm { if ( Application::appIsEnabled( 'nexus' ) and Settings::i()->idm_nexus_on and $this->renewal_term ) { $customer = $customer ?: Customer::loggedIn(); $renewalPrice = json_decode( $this->renewal_price, TRUE ); $renewalPrice = is_array( $renewalPrice ) ? $renewalPrice[ $customer->defaultCurrency() ] : array( 'currency' => $customer->defaultCurrency(), 'amount' => $renewalPrice ); $tax = NULL; try { $tax = Settings::i()->idm_nexus_tax ? Tax::load( Settings::i()->idm_nexus_tax ) : NULL; } catch ( OutOfRangeException $e ) { } return new PurchaseRenewalTerm( new Money( $renewalPrice['amount'], $renewalPrice['currency'] ), new DateInterval( "P{$this->renewal_term}" . mb_strtoupper( $this->renewal_units ) ), $tax ); } return NULL; } /** * @brief Cache of normal screenshots */ protected ?CachingIterator $_screenshotsNormal = NULL; /** * @brief Cache of thumbnails */ protected ?CachingIterator $_screenshotsThumbs = NULL; /** * @brief Cache of original screenshot images */ protected ?CachingIterator $_screenshotsOriginal = NULL; /** * Get screenshots * * @param int $type 0 = Normal, 1 = Thumbnails, 2 = No watermark * @param bool $includeLinks If true, will include linked files * @param int|NULL $version If provided, will get the file records for a specific previous version (downloads_filebackup.b_id) * @param bool $pendingVersion Only show screenshots from new pending version * @return CachingIterator */ public function screenshots( int $type=0, bool $includeLinks=TRUE, ?int $version = NULL, bool $pendingVersion=FALSE ) : CachingIterator { $fileSizeField = null; switch ( $type ) { case 0: if( $this->_screenshotsNormal !== NULL AND $pendingVersion === FALSE ) { return $this->_screenshotsNormal; } $valueField = 'record_location'; $property = "_screenshotsNormal"; $fileSizeField = 'record_size'; break; case 1: if( $this->_screenshotsThumbs !== NULL AND $pendingVersion === FALSE ) { return $this->_screenshotsThumbs; } $valueField = function( $row ) { return ( $row['record_type'] == 'sslink' ) ? 'record_location' : 'record_thumb'; }; $property = "_screenshotsThumbs"; $fileSizeField = FALSE; break; case 2: if( $this->_screenshotsOriginal !== NULL AND $pendingVersion === FALSE ) { return $this->_screenshotsOriginal; } $valueField = function( $row ) { return $row['record_no_watermark'] ? 'record_no_watermark' : 'record_location'; }; $property = "_screenshotsOriginal"; break; default: throw new InvalidArgumentException; } $where = array( array( 'record_file_id=?', $this->id ) ); if ( $includeLinks ) { $where[] = array( '( record_type=? OR record_type=? )', 'ssupload', 'sslink' ); } else { $where[] = array( 'record_type=?', 'ssupload' ); } if ( $version !== NULL ) { $backup = Db::i()->select( 'b_records', 'downloads_filebackup', array( 'b_id=?', $version ) )->first(); $where[] = Db::i()->in( 'record_id', explode( ',', $backup ) ); } else { $where[] = array( 'record_backup=0' ); } /* Ignore future versions */ $where[] = array( 'record_hidden=?', $pendingVersion ); $iterator = Db::i()->select( 'record_id, record_location, record_thumb, record_no_watermark, record_default, record_type, record_realname, record_size', 'downloads_files_records', $where, 'record_time DESC' )->setKeyField( 'record_id' ); $iterator = new Iterator( $iterator, 'downloads_Screenshots', $valueField, FALSE, 'record_realname', $fileSizeField ); $iterator = new CachingIterator( $iterator, CachingIterator::FULL_CACHE ); /* Do not cache if loading pending version data */ if( $pendingVersion === TRUE ) { return $iterator; } $this->$property = $iterator; return $this->$property; } /** * Returns the content images * * @param int|null $limit Number of attachments to fetch, or NULL for all * @param bool $ignorePermissions If set to TRUE, permission to view the images will not be checked * @return array|NULL * @throws BadMethodCallException */ public function contentImages( ?int $limit = NULL, bool $ignorePermissions = FALSE ): array|null { $count = 0; $images = array(); foreach( $this->screenshots( 0, FALSE ) as $image ) { if ( $count >= $limit ) { break; } $images[] = array( 'downloads_Screenshots' => (string) $image ); $count++; } if( $count < $limit ) { $contentImages = parent::contentImages( $limit, $ignorePermissions ) ?: array(); $images = array_merge( $images, $contentImages ); } return count( $images ) ? array_slice( $images, 0, $limit ) : NULL; } /** * @brief Cached primary screenshot */ protected SystemFile|bool|null $_primaryScreenshot = FALSE; /** * Get primary screenshot * * @return SystemFile|bool|null */ public function get_primary_screenshot(): SystemFile|bool|null { if( $this->_primaryScreenshot !== FALSE ) { return $this->_primaryScreenshot; } /* isset() here returns FALSE if the value is null, which then results in the else block here never being triggered to pull the first screenshot it finds */ if ( array_key_exists( 'primary_screenshot', $this->_data ) ) { $screenshots = $this->screenshots(); if ( $this->_data['primary_screenshot'] and isset( $screenshots[ $this->_data['primary_screenshot'] ] ) ) { $this->_primaryScreenshot = $screenshots[ $this->_data['primary_screenshot'] ]; return $this->_primaryScreenshot; } else { foreach ( $screenshots as $id => $screenshot ) { if ( !$this->_data['primary_screenshot'] or $id === $this->_data['primary_screenshot'] ) { $this->_primaryScreenshot = $screenshot; return $this->_primaryScreenshot; } } } } $this->_primaryScreenshot = NULL; return $this->_primaryScreenshot; } /** * @brief Cached primary screenshot thumb */ protected mixed $_primaryScreenshotThumb = FALSE; /** * Get primary screenshot thumbnail * * @return SystemFile|bool|null */ public function get_primary_screenshot_thumb(): SystemFile|bool|null { if( $this->_primaryScreenshotThumb !== FALSE ) { return $this->_primaryScreenshotThumb; } $screenshots = $this->screenshots( 1 ); if ( isset( $this->_data['primary_screenshot'] ) and isset( $screenshots[ $this->_data['primary_screenshot'] ] ) ) { $this->_primaryScreenshotThumb = $screenshots[ $this->_data['primary_screenshot'] ]; return $this->_primaryScreenshotThumb; } else { foreach( $screenshots as $id => $screenshot ) { if ( !$this->_data['primary_screenshot'] or $id === $this->_data['primary_screenshot'] ) { $this->_primaryScreenshotThumb = $screenshot; return $this->_primaryScreenshotThumb; } } } $this->_primaryScreenshotThumb = NULL; return $this->_primaryScreenshotThumb; } /** * @brief Custom Field Cache */ protected ?array $_customFields = NULL; /** * @brief Field Data Cache */ protected ?array $_fieldData = NULL; /** * Get custom field values * * @param bool $topic Are we returning the custom fields for the topic? If so we need to apply the display formatting. * @return array */ public function customFields( bool $topic=FALSE ): array { $return = array(); $fields = $this->container()->cfields; if( $topic === TRUE AND $this->_fieldData === NULL ) { $this->_fieldData = iterator_to_array( Db::i()->select( '*', 'downloads_cfields', array( 'cf_topic=?', 1 ) )->setKeyField( 'cf_id' ) ); } try { if ( $this->_customFields === NULL ) { $this->_customFields = Db::i()->select( '*', 'downloads_ccontent', array( 'file_id=?', $this->id ) )->first(); } foreach ( $this->_customFields as $k => $v ) { $fieldId = str_replace( 'field_', '', $k ); /* If we're getting fields for the topic we need to skip any that are set to not be included */ if( $topic === TRUE and !isset( $this->_fieldData[ $fieldId ] ) ) { continue; } if ( array_key_exists( $fieldId, $fields ) ) { if( $topic === TRUE ) { $thisField = Field::constructFromData( $this->_fieldData[ $fieldId ] ); if( isset( $this->_fieldData[ $fieldId ] ) ) { $v = str_replace( '{content}', htmlspecialchars( $v, ENT_DISALLOWED, 'UTF-8', FALSE ), $thisField->displayValue( $v ) ); $v = str_replace( '{member_id}', Member::loggedIn()->member_id, $v ); $v = str_replace( '{title}', Member::loggedIn()->language()->addToStack( 'downloads_field_' . $fieldId ), $v ); } else { $v = htmlspecialchars( $v, ENT_DISALLOWED, 'UTF-8', FALSE ); } } $return[ $k ] = $v; } } } catch( UnderflowException $e ){} return $return; } /** * Get available comment/review tabs * * @return array */ public function commentReviewTabs(): array { $tabs = array(); if ( $this->container()->bitoptions['reviews'] ) { $tabs['reviews'] = Member::loggedIn()->language()->addToStack( 'file_review_count', TRUE, array( 'pluralize' => array( $this->mapped('num_reviews') ) ) ); } if ( $this->container()->bitoptions['comments'] ) { $tabs['comments'] = Member::loggedIn()->language()->addToStack( 'file_comment_count', TRUE, array( 'pluralize' => array( $this->mapped('num_comments') ) ) ); } return $tabs; } /** * Get comment/review output * * @param string|null $tab Active tab * @return string */ public function commentReviews( ?string $tab=NULL ): string { if ( $tab === 'reviews' ) { return (string) Theme::i()->getTemplate('view')->reviews( $this ); } elseif( $tab === 'comments' ) { return (string) Theme::i()->getTemplate('view')->comments( $this ); } return ''; } /** * Can view? * * @param Member|NULL $member The member to check for or NULL for the currently logged in member * @return bool */ public function canView( ?Member $member=null ): bool { $member = $member ?: Member::loggedIn(); if ( !$this->container()->open and !$member->isAdmin() ) { return FALSE; } return parent::canView( $member ); } /** * Can edit? * Authors can always edit their own files * * @param Member|NULL $member The member to check for (NULL for currently logged in member) * @return bool */ public function canEdit( ?Member $member=NULL ): bool { $member = $member ?: Member::loggedIn(); if ( !$member->member_id ) { return FALSE; } return ( $member->member_id == $this->author()->member_id ) or parent::canEdit( $member ); } /** * Get items with permisison check * * @param array $where Where clause * @param string|null $order MySQL ORDER BY clause (NULL to order by date) * @param int|array|null $limit Limit clause * @param string $permissionKey A key which has a value in the permission map (either of the container or of this class) matching a column ID in core_permission_index or NULL to ignore permissions * @param int|bool|null $includeHiddenItems Include hidden items? NULL to detect if currently logged in member has permission, -1 to return public content only, TRUE to return unapproved content and FALSE to only return unapproved content the viewing member submitted * @param int $queryFlags Select bitwise flags * @param Member|null $member The member (NULL to use currently logged in member) * @param bool $joinContainer If true, will join container data (set to TRUE if your $where clause depends on this data) * @param bool $joinComments If true, will join comment data (set to TRUE if your $where clause depends on this data) * @param bool $joinReviews If true, will join review data (set to TRUE if your $where clause depends on this data) * @param bool $countOnly If true will return the count * @param array|null $joins Additional arbitrary joins for the query * @param bool|Model $skipPermission If you are getting records from a specific container, pass the container to reduce the number of permission checks necessary or pass TRUE to skip conatiner-based permission. You must still specify this in the $where clause * @param bool $joinTags If true, will join the tags table * @param bool $joinAuthor If true, will join the members table for the author * @param bool $joinLastCommenter If true, will join the members table for the last commenter * @param bool $showMovedLinks If true, moved item links are included in the results * @param array|null $location Array of item lat and long * @return ActiveRecordIterator|int */ public static function getItemsWithPermission( array $where=array(), string $order=null, int|array|null $limit=10, string $permissionKey='read', int|bool|null $includeHiddenItems= Filter::FILTER_AUTOMATIC, int $queryFlags=0, Member $member=null, bool $joinContainer=FALSE, bool $joinComments=FALSE, bool $joinReviews=FALSE, bool $countOnly=FALSE, array|null $joins=null, bool|Model $skipPermission=FALSE, bool $joinTags=TRUE, bool $joinAuthor=TRUE, bool $joinLastCommenter=TRUE, bool $showMovedLinks=FALSE, array|null $location=null ): ActiveRecordIterator|int { $member = $member ?: Member::loggedIn(); if ( !$member->isAdmin() ) { $where[] = array( 'downloads_categories.copen=1' ); $joinContainer = TRUE; } return parent::getItemsWithPermission( $where, $order, $limit, $permissionKey, $includeHiddenItems, $queryFlags, $member, $joinContainer, $joinComments, $joinReviews, $countOnly, $joins, $skipPermission, $joinTags, $joinAuthor, $joinLastCommenter, $showMovedLinks ); } /** * Can a given member create this type of content? * * @param Member $member The member * @param Model|NULL $container Container (e.g. forum), if appropriate * @param bool $showError If TRUE, rather than returning a boolean value, will display an error * @return bool */ public static function canCreate( Member $member, ?Model $container=null, bool $showError=FALSE ) : bool { if ( $member->idm_block_submissions ) { if ( $showError ) { Output::i()->error( 'err_submissions_blocked', '1D168/1', 403, '' ); } return FALSE; } return parent::canCreate( $member, $container, $showError ); } /** * Check if a specific action is available for this Content. * Default to TRUE, but used for overrides in individual Item/Comment classes. * * @param string $action * @param Member|null $member * @return bool */ public function actionEnabled( string $action, ?Member $member=null ) : bool { if( $action == 'review' and $this->mustDownloadBeforeReview( $member ) ) { return false; } return parent::actionEnabled( $action, $member ); } /** * Member has to download before they can review? * * @param Member|NULL $member The member (NULL for currently logged in member) * @return bool */ public function mustDownloadBeforeReview( ?Member $member = NULL ): bool { if ( $this->container()->bitoptions['reviews_download'] ) { try { Db::i()->select( '*', 'downloads_downloads', array( 'dfid=? AND dmid=?', $this->id, $member ? $member->member_id : Member::loggedIn()->member_id ) )->first(); } catch ( UnderflowException $e ) { return TRUE; } } return FALSE; } /** * Change Author * * @param Member $newAuthor The new author * @param bool $log If TRUE, action will be logged to moderator log * @return void */ public function changeAuthor( Member $newAuthor, bool $log=TRUE ): void { if ( Application::appIsEnabled( 'nexus' ) ) { Db::i()->update( 'nexus_purchases', array( 'ps_pay_to' => $newAuthor->member_id ), array( 'ps_app=? AND ps_type=? AND ps_item_id=?', 'downloads', 'file', $this->id ) ); } /* Update any pending versions */ Db::i()->update( 'downloads_files_pending', array( 'pending_member_id' => $newAuthor->member_id ), array( 'pending_file_id=?', $this->id ) ); parent::changeAuthor( $newAuthor, $log ); } /** * @brief Can download? */ protected ?bool $canDownload = NULL; /** * Can the member download this file? * * @param Member|NULL $member The member to check or NULL for currently logged in member * @return bool */ public function canDownload( ?Member $member = NULL ): bool { if ( $this->canDownload === NULL ) { try { $this->downloadCheck( NULL, $member ); $this->canDownload = TRUE; } catch ( DomainException $e ) { $this->canDownload = FALSE; } } return $this->canDownload; } /** * @brief Requires download confirmation? Used to determine if CSRF key should be included in download URL. */ protected ?bool $requiresDownloadConfirmation = NULL; /** * Can the member download this file? * * @param Member|NULL $member The member to check or NULL for currently logged in member * @return bool */ public function requiresDownloadConfirmation( ?Member $member = NULL ): bool { $member = $member ?: Member::loggedIn(); if ( $this->requiresDownloadConfirmation === NULL ) { $this->requiresDownloadConfirmation = FALSE; $category = $this->container(); if( $category->message('disclaimer') and in_array( $category->disclaimer_location, [ 'download', 'both' ] ) ) { $this->requiresDownloadConfirmation = TRUE; } $version = Request::i()->changelog ?? Request::i()->version ?? NULL; $files = $this->files($version); if( count( $files ) > 1 AND !isset( Request::i()->r ) ) { $this->requiresDownloadConfirmation = TRUE; } if( $member->group['idm_wait_period'] AND ( !$this->isPaid() OR $member->group['idm_paid_restrictions'] ) ) { $this->requiresDownloadConfirmation = TRUE; } } return $this->requiresDownloadConfirmation; } /** * Can the member buy this file? * * @param Member|NULL $member The member to check or NULL for currently logged in member * @return bool */ public function canBuy( ?Member $member = NULL ): bool { /* Is this a paid file? */ if ( !$this->isPaid() ) { return FALSE; } /* Init */ $member = $member ?: Member::loggedIn(); $restrictions = json_decode( $member->group['idm_restrictions'], TRUE ); /* File author */ if( $member === $this->author() ) { return FALSE; } /* Basic permission check */ if ( !$this->container()->can( 'download', $member ) ) { /* Hold on - if we're a guest and buying means we'll have to register which will put us in a group with permission, we can continue */ if ( $member->member_id or !$this->container()->can( 'download', Group::load( Settings::i()->member_group ) ) ) { return FALSE; } } /* If restrictions aren't applying to Paid files, stop here */ if ( !$member->group['idm_paid_restrictions'] ) { return TRUE; } /* Minimum posts */ if ( $member->member_id and $restrictions['min_posts'] and $restrictions['min_posts'] > $member->member_posts ) { return FALSE; } /* Is this an associated file ? */ if ( $this->nexus ) { $productIds = explode( ',', $this->nexus ); foreach ( $productIds as $productId ) { try { $package = Package::load( $productId ); try { /* The method does not return anything, but throws an exception if we cannot buy */ $package->memberCanPurchase( $member ); } catch ( DomainException $e ) { return FALSE; } } catch ( OutOfRangeException $e ) { return FALSE; } } } return TRUE; } /** * Purchases that can be renewed * * @param Member|NULL $member The member to check or NULL for currently logged in member * @return array */ public function purchasesToRenew( ?Member $member = NULL ): array { /** return an empty array if we don't have commerce */ if ( !Application::appIsEnabled( 'nexus' ) ) { return array(); } $member = $member ?: Member::loggedIn(); $return = array(); foreach ( FileNexusItem::getPurchases( Customer::load( $member->member_id ), $this->id ) as $purchase ) { if ( !$purchase->active and $purchase->canRenewUntil() !== FALSE ) { $return[] = $purchase; } } return $return; } /** * Download check * * @param array|NULL $record Specific record to download * @param Member|NULL $member The member to check or NULL for currently logged in member * @return void * @throws DomainException */ public function downloadCheck( ?array $record = NULL, ?Member $member = NULL ) : void { $member = $member ?: Member::loggedIn(); $restrictions = $member->group['idm_restrictions'] ? json_decode( $member->group['idm_restrictions'], TRUE ) : []; /* Basic permission check */ if ( !$this->container()->can( 'download', $member ) ) { throw new DomainException( $this->container()->message('npd') ?: 'download_no_perm' ); } /* If the file is hidden and this isn't a moderator, they can't access regardless of 'view' permission */ if ( $this->hidden() and !static::canViewHiddenItems( $member, $this->containerWrapper() ) and ( $this->hidden() !== 1 or $this->author()->member_id !== $member->member_id ) ) { throw new DomainException( $this->container()->message('npd') ?: 'download_no_perm' ); } /* Paid? */ if ( $this->isPaid() ) { /* Guests can't download paid files */ if ( !$member->member_id ) { throw new DomainException( $this->container()->message('npd') ?: 'download_no_perm' ); } if ( !$member->group['idm_bypass_paid'] and $member->member_id != $this->author()->member_id ) { if ( $this->cost ) { if ( !count( FileNexusItem::getPurchases( Customer::load( $member->member_id ), $this->id, FALSE ) ) ) { throw new DomainException( 'file_not_purchased' ); } } elseif ( $this->nexus ) { if ( !count( \IPS\nexus\extensions\nexus\Item\Package::getPurchases( Customer::load( $member->member_id ), explode( ',', $this->nexus ), FALSE ) ) ) { throw new DomainException( 'file_not_purchased' ); } } } /* If restrictions aren't applying to Paid files, stop here */ if ( !$member->group['idm_paid_restrictions'] ) { return; } } /* Minimum posts */ if ( $member->member_id and isset( $restrictions['min_posts'] ) and $restrictions['min_posts'] and $restrictions['min_posts'] > $member->member_posts ) { throw new DomainException( $member->language()->addToStack( 'download_min_posts', FALSE, array( 'pluralize' => array( $restrictions['min_posts'] ) ) ) ); } /* Simultaneous downloads */ if ( isset( $restrictions['min_posts'] ) AND $restrictions['limit_sim'] ) { if ( $this->getCurrentDownloadSessions( $member ) >= $restrictions['limit_sim'] ) { throw new DomainException( $member->language()->addToStack( 'max_simultaneous_downloads', FALSE, array( 'pluralize' => array( $restrictions['limit_sim'] ) ) ) ); } } /* For bandwidth checks, we need a record. If we don't have one - use the one with the smallest filesize */ if ( !$record ) { $it = $this->files(); foreach ( $it as $file ) { $data = $it->data(); if ( !$record or $record['record_size'] > $data['record_size'] ) { $record = $data; } } } /* Bandwidth & Download limits */ $logWhere = $member->member_id ? array( 'dmid=?', $member->member_id ) : array( 'dip=?', Request::i()->ipAddress() ); foreach ( array( 'daily' => 'P1D', 'weekly' => 'P1W', 'monthly' => 'P1M' ) as $k => $interval ) { $timePeriodWhere = array( $logWhere, array( 'dtime>?', DateTime::create()->sub( new DateInterval( $interval ) )->getTimestamp() ) ); /* Bandwidth */ if ( isset( $restrictions[ $k . '_bw' ] ) AND $restrictions[ $k . '_bw' ] ) { $usedThisPeriod = Db::i()->select( 'SUM(dsize)', 'downloads_downloads', $timePeriodWhere )->first(); if ( ( $record['record_size'] + $usedThisPeriod ) > ( $restrictions[ $k . '_bw' ] * 1024 ) ) { if ( $record['record_size'] > ( $restrictions[ $k . '_bw' ] * 1024 ) ) { throw new DomainException( $member->language()->addToStack( 'bandwidth_limit_' . $k . '_never', FALSE, array( 'sprintf' => array( Filesize::humanReadableFilesize( $restrictions[ $k . '_bw' ] * 1024 ), Filesize::humanReadableFilesize( $record['record_size'] ) ) ) ) ); } else { $date = new DateTime; foreach ( Db::i()->select( '*', 'downloads_downloads', $timePeriodWhere, 'dtime ASC' ) as $log ) { $usedThisPeriod -= $log['dsize']; if ( ( $record['record_size'] + $usedThisPeriod ) < ( $restrictions[ $k . '_bw' ] * 1024 ) ) { $date = DateTime::ts( $log['dtime'] ); break; } } throw new DomainException( $member->language()->addToStack( 'bandwidth_limit_' . $k, FALSE, array( 'sprintf' => array( Filesize::humanReadableFilesize( $restrictions[ $k . '_bw' ] * 1024 ), (string) $date->add( new DateInterval( $interval ) ) ) ) ) ); } } } /* Download */ if ( isset( $restrictions[ $k . '_dl' ] ) AND $restrictions[ $k . '_dl' ] ) { try { $downloadsThisPeriod = Db::i()->select( 'COUNT(*)', 'downloads_downloads', $timePeriodWhere )->first(); } catch( UnderflowException $e ) { $downloadsThisPeriod = 0; } if( $downloadsThisPeriod >= $restrictions[ $k . '_dl' ] ) { throw new DomainException( $member->language()->addToStack( 'download_limit_' . $k, FALSE, array( 'pluralize' => array( $restrictions[ $k . '_dl' ] ), 'sprintf' => array( (string) DateTime::ts( Db::i()->select( 'dtime', 'downloads_downloads', $timePeriodWhere, 'dtime ASC', array( 0, 1 ) )->first() )->add( new DateInterval( $interval ) ) ) ) ) ); } } } } /** * @brief Cached number of current download sessions */ protected array $_currentDownloadSessions = array(); /** * Get the current number of download sessions * * @param Member $member Member to check * @return int */ public function getCurrentDownloadSessions( Member $member ): int { if( !array_key_exists( $member->member_id, $this->_currentDownloadSessions ) ) { $this->_currentDownloadSessions[ $member->member_id ] = Db::i()->select( 'COUNT(*)', 'downloads_sessions', array( array( 'dsess_start > ?', time() - ( 60 * 15 ) ), $member->member_id ? array( 'dsess_mid=?', $member->member_id ) : array( 'dsess_ip=?', Request::i()->ipAddress() ) ) )->first(); } return $this->_currentDownloadSessions[ $member->member_id ]; } /** * Can view downloaders? * * @param Member|NULL $member The member to check or NULL for currently logged in member * @return bool */ public function canViewDownloaders( ?Member $member = null ): bool { if ( $this->container()->log === 0 ) { return FALSE; } $member = $member ?: Member::loggedIn(); if ( $member->member_id == $this->author()->member_id and $this->container()->bitoptions['submitter_log'] ) { return TRUE; } return (bool) $member->group['idm_view_downloads']; } /** * Get elements for add/edit form * * @param Item|null $item The current item if editing or NULL if creating * @param Model|null $container Container (e.g. forum) ID, if appropriate * @param string $bulkKey If we are submitting multiple files at once, a key that is used to differentiate between which fields are for which files * @return array * @throws Exception */ public static function formElements( ?Item $item=NULL, ?Model $container=NULL, string $bulkKey = '' ): array { /* Init */ $return = []; foreach ( parent::formElements( $item, $container ) as $k => $input ) { $input->name = "{$bulkKey}{$input->name}"; $return[ $k ] = $input; } /* Description */ $return['description'] = new Editor( "{$bulkKey}file_desc", $item?->desc, TRUE, array( 'app' => 'downloads', 'key' => 'Downloads', 'autoSaveKey' => ( $item ? "downloads-file-{$item->id}" : "{$bulkKey}downloads-new-file" ), 'attachIds' => ( $item === NULL ? NULL : array( $item->id, NULL, 'desc' ) ) ), '\IPS\Helpers\Form::floodCheck' ); /* Edit Log Fields need to be under the editor */ $editReason = NULL; if( isset( $return['edit_reason']) ) { $editReason = $return['edit_reason']; unset( $return['edit_reason'] ); $return['edit_reason'] = $editReason; } $logEdit = NULL; if( isset( $return['log_edit']) ) { $logEdit = $return['log_edit']; unset( $return['log_edit'] ); $return['log_edit'] = $logEdit; } /* Primary screenshot */ if ( $item ) { $screenshotOptions = array(); foreach ( Db::i()->select( '*', 'downloads_files_records', array( 'record_file_id=? AND record_type=? AND record_backup=0', $item->id, 'ssupload' ) ) as $ss ) { $screenshotOptions[ $ss['record_id'] ] = SystemFile::get( 'downloads_Screenshots', $ss['record_location'] )->url; } if ( count( $screenshotOptions ) > 1 ) { $return['primary_screenshot'] = new Radio( "{$bulkKey}file_primary_screenshot", $item->_primary_screenshot, FALSE, array( 'options' => $screenshotOptions, 'parse' => 'image' ) ); } } /* Nexus Integration */ if ( Application::appIsEnabled( 'nexus' ) and Settings::i()->idm_nexus_on and Member::loggedIn()->group['idm_add_paid'] ) { $options = array( 'free' => 'file_free', 'paid' => 'file_paid', ); if ( Member::loggedIn()->isAdmin() AND count( Package::roots() ) > 0 ) { $options['nexus'] = 'file_associate_nexus'; } $return['file_cost_type'] = new Radio( "{$bulkKey}file_cost_type", $item ? ( $item->cost ? 'paid' : ( $item->nexus ? 'nexus' : 'free' ) ) : 'free', TRUE, array( 'options' => $options, 'toggles' => array( 'paid' => array( "{$bulkKey}file_cost", "{$bulkKey}file_renewals" ), 'nexus' => array( "{$bulkKey}file_nexus" ) ) ) ); $commissionBlurb = NULL; $fees = NULL; if ( $_fees = json_decode( Settings::i()->idm_nexus_transfee, TRUE ) ) { $fees = array(); foreach ( $_fees as $fee ) { $fees[] = (string) ( new Money( $fee['amount'], $fee['currency'] ) ); } $fees = Member::loggedIn()->language()->formatList( $fees, Member::loggedIn()->language()->get('or_list_format') ); } if ( Settings::i()->idm_nexus_percent and $fees ) { $commissionBlurb = Member::loggedIn()->language()->addToStack( 'file_cost_desc_both', FALSE, array( 'sprintf' => array( Settings::i()->idm_nexus_percent, $fees ) ) ); } elseif ( Settings::i()->idm_nexus_percent ) { $commissionBlurb = Member::loggedIn()->language()->addToStack('file_cost_desc_percent', FALSE, array( 'sprintf' => Settings::i()->idm_nexus_percent ) ); } elseif ( $fees ) { $commissionBlurb = Member::loggedIn()->language()->addToStack('file_cost_desc_fee', FALSE, array( 'sprintf' => $fees ) ); } $minimums = json_decode( Settings::i()->idm_nexus_mincost, true ); $minCosts = []; foreach( $minimums as $currency => $cost ) { if( ( new Number( $cost['amount'] ) )->isGreaterThanZero() ) { $minCosts[] = (string) ( new Money( $cost['amount'], $currency ) ); } } if( count( $minCosts ) ) { $commissionBlurb .= Member::loggedIn()->language()->addToStack('file_cost_desc_minimum', FALSE, array( 'sprintf' => Member::loggedIn()->language()->formatList( $minCosts ) ) ); } Member::loggedIn()->language()->words['file_cost_desc'] = $commissionBlurb; $return['file_cost'] = new FormMoney( "{$bulkKey}file_cost", $item ? json_decode( $item->cost, TRUE ) : array(), NULL, array(), function( $val ) use ( $minimums, $bulkKey ) { foreach( $val as $currency => $money ) { if( isset( $minimums[ $currency ]['amount'] ) AND $money->amount->compare( new Number( $minimums[ $currency ]['amount'] ) ) === -1 ) { throw new DomainException('file_cost_too_low'); } } }, NULL, NULL, "{$bulkKey}file_cost" ); $return['file_renewals'] = new Radio( "{$bulkKey}file_renewals", $item ? ( $item->renewal_term ? 1 : 0 ) : 0, TRUE, array( 'options' => array( 0 => 'file_renewals_off', 1 => 'file_renewals_on' ), 'toggles' => array( 1 => array( "{$bulkKey}file_renewal_term" ) ) ), NULL, NULL, NULL, "{$bulkKey}file_renewals" ); Member::loggedIn()->language()->words['file_renewal_term_desc'] = $commissionBlurb; $renewTermForEdit = NULL; if ( $item and $item->renewal_term ) { $renewPrices = array(); foreach ( json_decode( $item->renewal_price, TRUE ) as $currency => $data ) { $renewPrices[ $currency ] = new Money( $data['amount'], $currency ); } $renewTermForEdit = new PurchaseRenewalTerm( $renewPrices, new DateInterval( 'P' . $item->renewal_term . mb_strtoupper( $item->renewal_units ) ) ); } $return['file_renewal_term'] = new RenewalTerm( "{$bulkKey}file_renewal_term", $renewTermForEdit, NULL, array( 'allCurrencies' => TRUE ), NULL, NULL, NULL, "{$bulkKey}file_renewal_term" ); if ( Member::loggedIn()->isAdmin() AND count( Package::roots() ) > 0 ) { $return['file_nexus'] = new Node( "{$bulkKey}file_nexus", $item ? $item->nexus : array(), FALSE, array( 'class' => '\IPS\nexus\Package', 'multiple' => TRUE ), NULL, NULL, NULL, "{$bulkKey}file_nexus" ); } } /* Custom Fields */ $customFieldValues = $item ? $item->customFields() : array(); if( $container ) { foreach ( $container->cfields as $k => $field ) { $_id = $field->id; $field->id = "{$bulkKey}{$field->id}"; if ( $field->type === 'Editor' ) { if ( $field->allow_attachments AND $item ) { $attachIds = array( $item->id, $_id, 'fields' ); $field::$editorOptions = array_merge( $field::$editorOptions, array( 'attachIds' => $attachIds ) ); } } $helper = $field->buildHelper( $customFieldValues["field_{$k}"] ?? NULL, NULL, $item ); $helper->label = Member::loggedIn()->language()->addToStack( 'downloads_field_' . $_id ); $field->id = $_id; if ( $field->type === 'Editor' ) { if ( $field->allow_attachments AND !$item ) { $field::$editorOptions = array_merge( $field::$editorOptions, array( 'attachIds' => NULL ) ); } } $return[] = $helper; } } if( $item ) { $return['versioning'] = new Custom( "{$bulkKey}file_versioning_info", NULL, FALSE, array( 'getHtml' => function( $element ) use ( $item ) { return Theme::i()->getTemplate( 'submit' )->editDetailsInfo( $item ); } ) ); } return $return; } /** * Process create/edit form * * @param array $values Values from form * @return void */ public function processForm( array $values ): void { $new = $this->_new; parent::processForm( $values ); if ( !$new ) { $oldContent = $this->desc; } $this->desc = $values['file_desc']; $imageUploads = $values['files'] ?? []; $editorLookups = $new ? [ ( $values['_bulkKey'] ?? '' ) . 'downloads-new-file'] : [ [ $this->id, NULL, 'desc' ] ]; if ( isset( $values['screenshots'] ) and $values['screenshots'] ) { $imageUploads = array_merge( $imageUploads, $values['screenshots'] ); } foreach ( $this->container()->cfields as $field ) { $helper = $field->buildHelper( NULL, NULL, $new ? NULL : $this ); if ( $helper instanceof Upload ) { if ( is_array( $values["downloads_field_{$field->id}"] ) ) { $imageUploads = array_merge( $imageUploads, $values["downloads_field_{$field->id}"] ); } elseif ( $values["downloads_field_{$field->id}"] ) { $imageUploads[] = $values["downloads_field_{$field->id}"]; } } elseif ( $helper instanceof Editor ) { $editorLookups[] = $new ? md5( 'IPS\downloads\Field-' . ( $values['_bulkKey'] ?? '' ) . $field->id . '-new' ) : [ $this->id, $field->id, 'fields' ]; } } $sendFilterNotifications = $this->checkProfanityFilters( FALSE, !$new, NULL, NULL, 'downloads_Downloads', $editorLookups, $imageUploads ); if ( !$new AND $sendFilterNotifications === FALSE ) { $this->sendAfterEditNotifications( $oldContent ); } if( isset( $values['file_primary_screenshot'] ) ) { $this->primary_screenshot = (int) $values['file_primary_screenshot']; } if ( Application::appIsEnabled( 'nexus' ) and Settings::i()->idm_nexus_on and Member::loggedIn()->group['idm_add_paid'] ) { switch ( $values['file_cost_type'] ) { case 'free': $this->cost = NULL; $this->renewal_term = 0; $this->renewal_units = NULL; $this->renewal_price = NULL; $this->nexus = NULL; break; case 'paid': $this->cost = json_encode( $values['file_cost'] ); if ( $values['file_renewals'] and $values['file_renewal_term'] ) { $term = $values['file_renewal_term']->getTerm(); $this->renewal_term = $term['term']; $this->renewal_units = $term['unit']; $this->renewal_price = json_encode( $values['file_renewal_term']->cost ); } else { $this->renewal_term = 0; $this->renewal_units = NULL; $this->renewal_price = NULL; } $this->nexus = NULL; break; case 'nexus': $this->cost = NULL; $this->renewal_term = 0; $this->renewal_units = NULL; $this->renewal_price = NULL; $this->nexus = implode( ',', array_keys( $values['file_nexus'] ) ); break; } } $this->save(); $cfields = array(); foreach ( $this->container()->cfields as $field ) { $helper = $field->buildHelper( NULL, NULL, $new ? NULL : $this ); if ( $helper instanceof Upload ) { $cfields[ "field_{$field->id}" ] = (string) $values[ "downloads_field_{$field->id}" ]; } else { $cfields[ "field_{$field->id}" ] = $helper::stringValue( $values[ "downloads_field_{$field->id}" ] ); } if ( $helper instanceof Editor ) { $field->claimAttachments( $this->id, 'fields' ); } } if ( !empty( $cfields ) ) { Db::i()->insert( 'downloads_ccontent', array_merge( array( 'file_id' => $this->id, 'updated' => time() ), $cfields ), TRUE ); } /* Update Category */ $this->container()->setLastFile( ( $new and $this->open ) ? $this : NULL ); $this->container()->save(); } /** * Process created object BEFORE the object has been created * * @param array $values Values from form * @return void */ protected function processBeforeCreate( array $values ): void { /* Set version */ $this->version = ( isset( $values['file_version'] ) ) ? $values['file_version'] : NULL; /* Try to set the primary screenshot */ try { $this->primary_screenshot = Db::i()->select( 'record_id', 'downloads_files_records', array( 'record_post_key=? AND ( record_type=? or record_type=? ) AND record_backup=0', $values['postKey'], 'ssupload', 'sslink' ), 'record_default DESC, record_id ASC' )->first(); } catch ( Exception $e ) { } parent::processBeforeCreate( $values ); } /** * Process created object AFTER the object has been created * * @param Comment|NULL $comment The first comment * @param array $values Values from form * @return void */ protected function processAfterCreate( ?Comment $comment, array $values ): void { SystemFile::claimAttachments( 'downloads-new-file', $this->id, NULL, 'desc' ); if ( $this->_primary_screenshot ) { Db::i()->update( 'downloads_files_records', array( 'record_default' => 1 ), array( 'record_id=?', $this->_primary_screenshot ) ); } Db::i()->update( 'downloads_files_records', array( 'record_file_id' => $this->id, 'record_post_key' => NULL ), array( 'record_post_key=?', $values['postKey'] ) ); $this->size = (int) Db::i()->select( 'SUM(record_size)', 'downloads_files_records', array( 'record_file_id=? AND record_type=? AND record_backup=0', $this->id, 'upload' ) )->first(); $this->save(); parent::processAfterCreate( $comment, $values ); } /** * Process after the object has been edited on the front-end * * @param array $values Values from form * @return void */ public function processAfterEdit( array $values ): void { parent::processAfterEdit( $values ); Request::i()->setClearAutosaveCookie( 'downloads-file-' . $this->id ); foreach ( $this->container()->cfields as $field ) { Request::i()->setClearAutosaveCookie( md5( 'IPS\downloads\Field-' . $field->id . '-' . $this->id ) ); } } /** * Log for deletion later * * @param Member|false|null $member The member, NULL for currently logged in, or FALSE for no member * @return void */ public function logDelete( Member|false|null $member = NULL ) : void { $this->_logDelete( $member ); if ( $topic = $this->topic() and $this->container()->bitoptions['topic_delete'] ) { $topic->logDelete( $member ); } if ( $this->hasPendingVersion() ) { PendingVersion::load( $this->id, 'pending_file_id' )->delete(); } } /** * Delete Record * * @return void */ public function delete(): void { if ( $topic = $this->topic() and $this->container()->bitoptions['topic_delete'] ) { $topic->delete(); } if ( Application::appIsEnabled( 'nexus' ) ) { Db::i()->update( 'nexus_purchases', array( 'ps_cancelled' => TRUE, 'ps_can_reactivate' => FALSE ), array( 'ps_app=? AND ps_type=? AND ps_item_id=?', 'downloads', 'file', $this->id ) ); } parent::delete(); foreach ( new Iterator( Db::i()->select( 'record_location', 'downloads_files_records', array( 'record_file_id=? AND record_type=?', $this->id, 'upload' ) ), 'downloads_Files' ) as $file ) { try { $file->delete(); } catch ( Exception $e ) { } } foreach ( new Iterator( Db::i()->select( 'record_location', 'downloads_files_records', array( 'record_file_id=? AND record_type=?', $this->id, 'ssupload' ) ), 'downloads_Screenshots' ) as $file ) { try { $file->delete(); } catch ( Exception $e ) { } } foreach ( new Iterator( Db::i()->select( 'record_thumb', 'downloads_files_records', array( 'record_file_id=? AND record_type=? AND record_thumb IS NOT NULL', $this->id, 'ssupload' ) ), 'downloads_Screenshots' ) as $file ) { try { $file->delete(); } catch ( Exception $e ) { } } foreach ( new Iterator( Db::i()->select( 'record_no_watermark', 'downloads_files_records', array( 'record_file_id=? AND record_type=? AND record_no_watermark IS NOT NULL', $this->id, 'ssupload' ) ), 'downloads_Screenshots' ) as $file ) { try { $file->delete(); } catch ( Exception $e ) { } } Db::i()->delete( 'downloads_ccontent', array( 'file_id=?', $this->id ) ); Db::i()->delete( 'downloads_downloads', array( 'dfid=?', $this->id ) ); Db::i()->delete( 'downloads_filebackup', array( 'b_fileid=?', $this->id ) ); Db::i()->delete( 'downloads_files_records', array( 'record_file_id=?', $this->id ) ); Db::i()->delete( 'downloads_files_notify', array( 'notify_file_id=?', $this->id ) ); /* Delete pending version */ if( $this->hasPendingVersion() ) { PendingVersion::load($this->id, 'pending_file_id' )->delete(); } /* Update Category */ $this->container()->setLastFile(); $this->container()->save(); } /** * Delete Records * * @param array|int|null $ids Must be INT if url and handler are provided * @param string|null $url Record location * @param string|null $handler File storage handler key * @return void */ public function deleteRecords( array|int|null $ids, ?string $url=NULL, ?string $handler=NULL ) : void { if( $ids AND $handler AND $url ) { try { if( !Db::i()->select( 'COUNT(record_id)', 'downloads_files_records', array( 'record_id<>? AND record_location=?', $ids, $url ) )->first() ) { SystemFile::get( $handler, $url )->delete(); } } catch ( Exception $e ) { } Db::i()->delete( 'downloads_files_records', array( 'record_id=? AND record_location=?', $ids, $url ) ); } else { if( $ids !== NULL AND !is_array( $ids ) ) { $ids = array( $ids ); } Db::i()->delete( 'downloads_files_records', array( Db::i()->in( 'record_id', $ids ) ) ); } } /** * URL Blacklist Check * * @param array|null|string $val URLs to check * @return void * @throws DomainException */ public static function blacklistCheck( array|null|string $val ) : void { if ( is_array( $val ) ) { foreach ( explode( ',', Settings::i()->idm_link_blacklist ) as $blackListedDomain ) { foreach ( array_filter( $val ) as $url ) { if ( is_string( $url ) ) { $url = Url::external( $url ); } if ( mb_substr( $url->data['host'], -mb_strlen( $blackListedDomain ) ) == $blackListedDomain ) { throw new DomainException( Member::loggedIn()->language()->addToStack( 'err_url_file_blacklist', FALSE, array( 'sprintf' => $blackListedDomain ) ) ); } } } } } /** * Are comments supported by this class? * * @param Member|NULL $member The member to check for or NULL to not check permission * @param Model|NULL $container The container to check in, or NULL for any container * @return bool */ public static function supportsComments( ?Member $member = NULL, ?Model $container = NULL ): bool { if( $container !== NULL ) { return parent::supportsComments() and $container->bitoptions['comments'] AND ( !$member or $container->can( 'read', $member ) ); } else { return parent::supportsComments() and ( !$member or Category::countWhere( 'read', $member, array( 'cbitoptions & 4' ) ) ); } } /** * Are reviews supported by this class? * * @param Member|NULL $member The member to check for or NULL to not check permission * @param Model|NULL $container The container to check in, or NULL for any container * @return bool */ public static function supportsReviews( ?Member $member = NULL, ?Model $container = NULL ): bool { if( $container !== NULL ) { return parent::supportsReviews() and $container->bitoptions['reviews'] AND ( !$member or $container->can( 'read', $member ) ); } else { return parent::supportsReviews() and ( !$member or Category::countWhere( 'read', $member, array( 'cbitoptions & 256' ) ) ); } } /** * Save the current files/screenshots into the backup in preparation for storing a new version * * @return void */ public function saveVersion() : void { /* Move the old details into a backup record */ $b_id = Db::i()->insert( 'downloads_filebackup', array( 'b_fileid' => $this->id, 'b_filetitle' => $this->name, 'b_filedesc' => $this->desc, 'b_hidden' => FALSE, 'b_backup' => $this->published, 'b_updated' => time(), 'b_records' => implode( ',', iterator_to_array( Db::i()->select( 'record_id', 'downloads_files_records', array( 'record_file_id=? AND record_backup=0 AND record_hidden=0', $this->id ) ) ) ), 'b_version' => $this->version, 'b_changelog' => $this->changelog, ) ); /* Fetch the existing locations to prevent backups with the same file name as the current file from removing the disk files */ $locations = array(); $thumbs = array(); $watermarks = array(); foreach( Db::i()->select( '*', 'downloads_files_records', array( 'record_file_id=? AND record_backup=0 AND record_hidden=0', $this->id ) ) as $file ) { $locations[] = $file['record_location']; if ( $file['record_thumb'] ) { $thumbs[] = $file['record_thumb']; } if ( $file['record_no_watermark'] ) { $watermarks[] = $file['record_no_watermark']; } } /* Update the attachment map for this version (NULL means the current version, anything else means previous version). */ SystemFile::claimAttachments( "downloads-{$this->id}-changelog", $this->id, $b_id, 'changelog' ); /* Set the old records to be backups */ Db::i()->update( 'downloads_files_records', array( 'record_backup' => TRUE ), array( 'record_file_id=? AND record_backup=0 AND record_hidden=0', $this->id ) ); /* Delete any old versions we no longer keep */ $category = $this->container(); if ( $category->versioning !== NULL ) { $count = Db::i()->select( 'COUNT(*)', 'downloads_filebackup', array( 'b_fileid=?', $this->id ) )->first(); if ( ( $count - $category->versioning + 1 ) > 0 ) { foreach ( Db::i()->select( '*', 'downloads_filebackup', array( 'b_fileid=?', $this->id ), 'b_backup ASC', $count - $category->versioning + 1 ) as $backUp ) { foreach ( Db::i()->select( '*', 'downloads_files_records', Db::i()->in( 'record_id', explode( ',', $backUp['b_records'] ) ) ) as $k => $file ) { try { if ( !in_array( $file['record_location'], $locations ) ) { $file = SystemFile::get( $file['record_type'] == 'upload' ? 'downloads_Files' : 'downloads_Screenshots', $file['record_location'] )->delete(); } } catch ( Exception $e ) { } if( $file['record_type'] == 'ssupload' ) { if( $file['record_thumb'] ) { try { if ( !in_array( $file['record_thumb'], $thumbs ) ) { $file = SystemFile::get( 'downloads_Screenshots', $file['record_thumb'] )->delete(); } } catch ( Exception $e ) { } } if( $file['record_no_watermark'] ) { try { if ( !in_array( $file['record_no_watermark'], $watermarks ) ) { $file = SystemFile::get( 'downloads_Screenshots', $file['record_no_watermark'] )->delete(); } } catch ( Exception $e ) { } } } } Db::i()->delete( 'downloads_files_records', Db::i()->in( 'record_id', explode( ',', $backUp['b_records'] ) ) ); Db::i()->delete( 'downloads_filebackup', array( 'b_id=?', $backUp['b_id'] ) ); } } } } /* !Followers */ /** * Users to receive immediate notifications (bulk) * * @param Category $category The category the files were posted in. * @param Member|null $member The member posting the files or NULL for currently logged in member. * @param array|int|null $limit LIMIT clause * @param bool $countOnly Only return the count * @return Select|int */ public static function _notificationRecipients( Category $category, Member $member=NULL, array|int|null $limit=array( 0, 25 ), bool $countOnly=FALSE ): Select|int { $member = $member ?: Member::loggedIn(); /* Do we only want the count? */ if( $countOnly ) { $count = 0; $count += $member->followersCount( 3, array( 'immediate' ) ); $count += static::containerFollowerCount( $category, 3, array( 'immediate' ) ); return $count; } $memberFollowers = $member->followers( 3, array( 'immediate' ), NULL, NULL ); if( $memberFollowers !== NULL ) { $unions = array( static::containerFollowers( $category, 3, array( 'immediate' ), NULL, NULL ), $memberFollowers ); return Db::i()->union( $unions, 'follow_added', $limit ); } else { return static::containerFollowers( $category, static::FOLLOW_PUBLIC + static::FOLLOW_ANONYMOUS, array( 'immediate' ), NULL, $limit, 'follow_added' ); } } /** * Send Notifications (bulk) * * @param Category $category The category the files were posted in. * @param Member|NULL $member The member posting the images, or NULL for currently logged in member. * @return void */ public static function _sendNotifications( Category $category, ?Member $member=NULL ) : void { $member = $member ?: Member::loggedIn(); try { $count = static::_notificationRecipients($category, $member, NULL, TRUE); } catch( BadMethodCallException $e ) { return; } $categoryIdColumn = $category::$databaseColumnId; if ( $count > NOTIFICATION_BACKGROUND_THRESHOLD ) { $queueData = array( 'followerCount' => $count, 'category_id' => $category->$categoryIdColumn, 'member_id' => $member->member_id ); Task::queue( 'downloads', 'Follow', $queueData, 2 ); } else { static::_sendNotificationsBatch( $category, $member ); } } /** * Send Unapproved Notification (bulk)( * * @param Category $category The category the files were posted too. * @param Member|null $member The member posting the images, or NULL for currently logged in member. * @return void */ public static function _sendUnapprovedNotifications( Category $category, ?Member $member=NULL ) : void { $member = $member ?: Member::loggedIn(); $moderators = array( 'g' => array(), 'm' => array() ); foreach( Db::i()->select( '*', 'core_moderators' ) AS $mod ) { $canView = FALSE; if ( $mod['perms'] == '*' ) { $canView = TRUE; } if ( $canView === FALSE ) { $perms = json_decode( $mod['perms'], TRUE ); if ( isset( $perms['can_view_hidden_content'] ) AND $perms['can_view_hidden_content'] ) { $canView = TRUE; } else if ( isset( $perms['can_view_hidden_' . static::$title ] ) AND $perms['can_view_hidden_' . static::$title ] ) { $canView = TRUE; } } if ( $canView === TRUE ) { $moderators[ $mod['type'] ][] = $mod['id']; } } $notification = new Notification( Application::load('core'), 'unapproved_content_bulk', $category, array( $category, $member, $category::$contentItemClass ), array( $member->member_id ) ); foreach ( Db::i()->select( '*', 'core_members', ( count( $moderators['m'] ) ? Db::i()->in( 'member_id', $moderators['m'] ) . ' OR ' : '' ) . Db::i()->in( 'member_group_id', $moderators['g'] ) . ' OR ' . Db::i()->findInSet( 'mgroup_others', $moderators['g'] ) ) as $moderator ) { $notification->recipients->attach( Member::constructFromData( $moderator ) ); } $notification->send(); } /** * Send Notification Batch (bulk) * * @param Category $category The category the files were posted too. * @param Member|NULL $member The member posting the images, or NULL for currently logged in member. * @param int $offset Offset * @return int|NULL New Offset or NULL if complete */ public static function _sendNotificationsBatch( Category $category, Member $member=NULL, int $offset=0 ): ?int { /* Check notification initiator spam status */ if( ( $member instanceof Member ) AND $member->members_bitoptions['bw_is_spammer'] ) { /* Initiator is flagged as spammer, don't send notifications */ return NULL; } $member = $member ?: Member::loggedIn(); $followIds = array(); $followers = iterator_to_array( static::_notificationRecipients($category, $member, array($offset, static::NOTIFICATIONS_PER_BATCH)) ); if( !count( $followers ) ) { return NULL; } $notification = new Notification( Application::load( 'core' ), 'new_content_bulk', $category, array( $category, $member, $category::$contentItemClass ), array( $member->member_id ) ); foreach( $followers AS $follower ) { $followMember = Member::load( $follower['follow_member_id'] ); if ( $followMember !== $member and $category->can( 'view', $followMember ) ) { $followIds[] = $follower['follow_id']; $notification->recipients->attach( $followMember ); } } Db::i()->update( 'core_follow', array( 'follow_notify_sent' => time() ), Db::i()->in( 'follow_id', $followIds ) ); $notification->send(); return $offset + static::NOTIFICATIONS_PER_BATCH; } /** * @brief Is first time approval */ protected bool $firstTimeApproval = FALSE; /** * Unhide * * @param Member|null $member The member doing the action (NULL for currently logged in member) * @return void */ public function unhide( ?Member $member=NULL ) : void { if ( $this->hidden() === 1 ) { $this->firstTimeApproval = TRUE; } $this->_unhide( $member ); } /** * Send Approved Notification * * @return void */ public function sendApprovedNotification() : void { if ( $this->firstTimeApproval ) { $this->sendNotifications(); } else { $this->sendUpdateNotifications(); } $this->sendAuthorApprovalNotification(); } /** * Send notifications that the file has been updated * * @return void */ public function sendUpdateNotifications() : void { $count = Db::i()->select( 'count(*)', 'downloads_files_notify', array( 'notify_file_id=?', $this->id ) )->first(); if ( $count ) { $idColumn = static::$databaseColumnId; Task::queue( 'downloads', 'Notify', array( 'file' => $this->$idColumn, 'notifyCount' => $count ), 2 ); } } /** * Syncing when uploading a new version. * * @param array $values Values from the form * @return void */ public function processAfterNewVersion( array $values ) : void { /* This method is mainly used to ensure a topic linked to a file download stays updated when uploading a new version (like when editing it normally), however it also accepts the values from the form so authors can overload and manipulate the data from it. */ if ( Application::appIsEnabled('forums') AND $this->topic() ) { /* And we need to make sure the "cached" values for primary screenshot and thumbnail are cleared so the updated topic always has the latest */ $this->_primaryScreenshot = FALSE; $this->_primaryScreenshotThumb = FALSE; $this->syncTopic(); } /* Call UI extensions */ $this->ui( 'formPostSave', array( $values ) ); /* Fire event for listeners */ Event::fire( 'onCreateOrEdit', $this, array( $values ) ); Webhook::fire( 'downloads_new_version', array( $this ) ); } /* !Embeddable */ /** * Get image for embed * * @return SystemFile|NULL */ public function embedImage(): ?SystemFile { return $this->primary_screenshot_thumb ?: NULL; } /** * Get content for embed * * @param array $params Additional parameters to add to URL * @return string */ public function embedContent( array $params ): string { Output::i()->cssFiles = array_merge( Output::i()->cssFiles, Theme::i()->css( 'embed.css', 'downloads', 'front' ) ); return Theme::i()->getTemplate( 'global', 'downloads' )->embedFile( $this, $this->url()->setQueryString( $params ), $this->embedImage() ); } /** * Get output for API * * @param Member|NULL $authorizedMember The member making the API request or NULL for API Key / client_credentials * @param array|null $backup If provided, will output for a particular version - provide row from downloads_filebackup * @return array * @apiresponse int id ID number * @apiresponse string title Title * @apiresponse \IPS\downloads\Category category Category * @apiresponse \IPS\Member author Author * @apiresponse datetime date When the file was created * @apiresponse datetime updated When the file was last updated * @apiresponse string description Description * @apiresponse string version Current version number * @apiresponse string changelog Description of what changed between this version and the previous one * @apiresponse \IPS\File primaryScreenshot The primary screenshot * @apiresponse [\IPS\File] screenshots Screenshots * @apiresponse [\IPS\File] screenshotsThumbnails Screenshots in Thumbnail size * @apiresponse \IPS\File primaryScreenshot The primary screenshot * @apiresponse \IPS\File primaryScreenshotThumb The primary screenshot * @apiresponse int downloads Number of downloads * @apiresponse int comments Number of comments * @apiresponse int reviews Number of reviews * @apiresponse int views Number of views * @apiresponse string prefix The prefix tag, if there is one * @apiresponse [string] tags The tags * @apiresponse bool locked File is locked * @apiresponse bool hidden File is hidden * @apiresponse bool pinned File is pinned * @apiresponse bool featured File is featured * @apiresponse string url URL * @apiresponse \IPS\forums\Topic topic The topic * @apiresponse bool isPaid Is the file paid? * @apiresponse [float] prices Prices (key is currency, value is price). Does not consider associated packages. * @apiresponse bool canDownload If the authenticated member can download. Will be NULL for requests made using an API Key or the Client Credentials Grant Type * @apiresponse bool canBuy Can purchase the file * @apiresponse bool canReview Can review the file * @apiresponse float rating File rating * @apiresponse int purchases Number of purchases * @apiresponse array renewalTerm File renewal term * @apiresponse bool hasPendingVersion Whether file has a new version pending. Will be NULL for client requests where the authorized member cannot upload new versions. */ public function apiOutput( ?Member $authorizedMember = NULL, ?array $backup=NULL ) : array { return array( 'id' => $this->id, 'title' => $backup ? $backup['b_filetitle'] : $this->name, 'category' => $this->container()->apiOutput( $authorizedMember ), 'author' => $this->author()->apiOutput( $authorizedMember ), 'date' => DateTime::ts( $backup ? $backup['b_backup'] : $this->submitted )->rfc3339(), 'updated' => DateTime::ts( $this->updated )->rfc3339(), 'description' => $backup ? $backup['b_filedesc'] : $this->content(), 'version' => $backup ? $backup['b_version'] : $this->version, 'changelog' => $backup ? $backup['b_changelog'] : $this->changelog, 'screenshots' => array_values( array_map( function( $file ) use ( $authorizedMember ) { return $file->apiOutput( $authorizedMember ); }, iterator_to_array( $this->screenshots( 0, TRUE, $backup ? $backup['b_id'] : NULL ) ) ) ), 'screenshotsThumbnails' => array_values( array_map( function( $file ) use ( $authorizedMember ) { return $file->apiOutput( $authorizedMember ); }, iterator_to_array( $this->screenshots( 1, TRUE, $backup ? $backup['b_id'] : NULL ) ) ) ), 'primaryScreenshot' => $this->primary_screenshot ? ( $this->primary_screenshot->apiOutput( $authorizedMember ) ) : null, 'primaryScreenshotThumb' => $this->primary_screenshot_thumb ? ( $this->primary_screenshot_thumb->apiOutput( $authorizedMember ) ) : null, 'downloads' => $this->downloads, 'comments' => $this->comments, 'reviews' => $this->reviews, 'views' => $this->views, 'prefix' => $this->prefix(), 'tags' => $this->tags(), 'locked' => $this->locked(), 'hidden' => (bool) $this->hidden(), 'pinned' => (bool) $this->mapped('pinned'), 'featured' => (bool) $this->mapped('featured'), 'url' => (string) $this->url(), 'topic' => $this->topic() ? $this->topic()->apiOutput( $authorizedMember ) : NULL, 'isPaid' => $this->isPaid(), 'isPurchasable' => $this->isPurchasable(), 'prices' => ( $this->isPaid() and $this->cost ) ? array_map( function( $price ) { return $price['amount']; }, json_decode( $this->cost, TRUE ) ) : NULL, 'canDownload' => $authorizedMember ? $this->canDownload( $authorizedMember ) : NULL, 'canBuy' => $authorizedMember ? $this->canBuy( $authorizedMember ) : NULL, 'canReview' => $authorizedMember ? ( $this->canReview( $authorizedMember ) AND !$this->hasReviewed( $authorizedMember ) ) : NULL, 'rating' => $this->averageReviewRating(), 'purchases' => $this->purchaseCount(), 'renewalTerm' => $this->isPaid() ? $this->renewalTerm() : NULL, 'hasPendingVersion' => ( !$authorizedMember OR $this->canEdit( $authorizedMember ) ) ? $this->hasPendingVersion() : NULL ); } /** * Message explaining to guests that if they log in they can download * * @return string|NULL */ public function downloadTeaser(): ?string { /* If we're a guest and log in, can we download? */ if ( !Member::loggedIn()->member_id ) { $testUser = new Member; $testUser->member_group_id = Settings::i()->member_group; $this->canDownload = NULL; if ( $this->canDownload( $testUser ) ) { return Theme::i()->getTemplate( 'view', 'downloads', 'front' )->downloadTeaser(); } } return NULL; } /* !Reactions */ /** * Reaction Type * * @return string */ public static function reactionType(): string { return 'file_id'; } /** * Supported Meta Data Types * * @return array */ public static function supportedMetaDataTypes(): array { return array( 'core_FeaturedComments', 'core_ContentMessages' ); } /** * Give a content item the opportunity to filter similar content * * @note Intentionally blank but can be overridden by child classes * @return array|NULL */ public function similarContentFilter(): ?array { if( $this->topicid ) { return array( array( '!(tag_meta_app=? and tag_meta_area=? and tag_meta_id=?)', 'forums', 'forums', $this->topicid ) ); } return NULL; } /** * Return size and downloads count when this content type is inserted as an attachment via the "Insert other media" button on an editor. * * @note Most content types do not support this, and those that do will need to override this method to return the appropriate info * @return array */ public function getAttachmentInfo(): array { return array( 'size' => Filesize::humanReadableFilesize( $this->size, FALSE, TRUE ), 'downloads' => Member::loggedIn()->language()->formatNumber( $this->downloads ) ); } /** * Get the topic title * * @return string */ function getTopicTitle(): string { $title = ''; if( $prefix = $this->container()->_topic_prefix ) { $title .= $prefix . ' '; } $title .= $this->name; if( $suffix = $this->container()->_topic_suffix ) { $title .= ' ' . $suffix; } return $title; } /** * Get the topic content * * @return mixed */ function getTopicContent(): mixed { return Theme::i()->getTemplate( 'submit', 'downloads', 'front' )->topic( $this ); } /** * Create/Update Topic * * @return void */ public function syncTopic(): void { /* Run the parent so that we are sure everything is properly synched */ $this->_syncTopic(); if( $screenshot = $this->primary_screenshot_thumb ) { /* Now we need to copy the primary screenshot as an attachment to the first post */ $topic = $this->topic( false ); if( $topic === null ) { return; } /* Delete any existing attachments */ $delete = []; foreach( Db::i()->select( '*', 'core_attachments_map', [ 'location_key=? and id1=? and id2=?', 'forums_Forums', $topic->tid, $topic->topic_firstpost ] ) ->join( 'core_attachments', 'attachment_id=attach_id' ) as $row ) { try { SystemFile::get( 'forums_Forums', $row['attach_location'] )->delete(); } catch( Exception ){} $delete[] = $row['attach_id']; } if( count( $delete ) ) { Db::i()->delete( 'core_attachments_map', Db::i()->in( 'attachment_id', $delete ) ); Db::i()->delete( 'core_attachments', Db::i()->in( 'attach_id', $delete ) ); } $content = $topic->firstComment()->content(); try { /* Copy the screenshot and generate an attachment */ /* @var SystemFile $screenshot */ $settings = json_decode( Settings::i()->upload_settings, true ); $storageConfiguration = $settings['filestorage__core_Attachment']; $copy = $screenshot->copy( $storageConfiguration ); $attachment = $copy->makeAttachment( md5( uniqid() ), $topic->author() ); Db::i()->insert( 'core_attachments_map', [ 'attachment_id' => $attachment['attach_id'], 'location_key' => 'forums_Forums', 'id1' => $topic->tid, 'id2' => $topic->topic_firstpost ] ); /* Now append to the post content */ $dimensions = $copy->getImageDimensions(); $attachment = Theme::i()->getTemplate( 'editor', 'core', 'global' )->attachedImage( (string) $copy, (string) $copy, $this->name, $attachment['attach_id'], $dimensions[0], $dimensions[1], $this->name ); $content .= $attachment; } catch( RuntimeException ){} $topic->firstComment()->post = $content; $topic->firstComment()->save(); } } /** * Return the Forum ID * * @return int */ public function getForumId() : int { return (int) $this->container()->forum_id; } /** * Subscribed? * * @param Member|null $member * @return bool */ function subscribed( ?Member $member = NULL ): bool { $member = $member ?: Member::loggedIn(); try { Db::i()->select( '*', 'downloads_files_notify', array( 'notify_member_id=? and notify_file_id=?', $member->member_id, $this->id ) )->first(); return TRUE; } catch ( UnderflowException $e ) { return FALSE; } } /** * @brief Cache for pending version check */ protected ?bool $_pendingVersion = NULL; /** * Check whether file has a pending new version * * @return bool */ public function hasPendingVersion(): bool { if( $this->_pendingVersion !== NULL ) { return $this->_pendingVersion; } try { Db::i()->select( 'pending_id', 'downloads_files_pending', array( 'pending_file_id=?', $this->id ) )->first(); $this->_pendingVersion = TRUE; } catch ( UnderflowException $e ) { $this->_pendingVersion = FALSE; } return $this->_pendingVersion; } /** * Add form elements to 'new version' form * * @param Form $form * @return void */ public function newVersionFormElements( Form &$form ) : void { /* UI Extension for new version. You can check Request::i()->do to see which form you're working with. */ foreach( UiExtension::i()->run( $this, 'formElements', array( $this->container() ) ) as $element ) { $form->add( $element ); } } /** * Do Moderator Action * * @param string $action The action * @param Member|NULL $member The member doing the action (NULL for currently logged in member) * @param string|null $reason Reason (for hides) * @param bool $immediately Delete immediately * @return void * @throws OutOfRangeException|InvalidArgumentException|RuntimeException */ public function modAction( string $action, Member $member = NULL, mixed $reason = NULL, bool $immediately=FALSE ): void { if ( $action == 'delete' ) { /* Delete pending version */ if( $this->hasPendingVersion() ) { PendingVersion::load($this->id, 'pending_file_id' )->delete(); $this->_pendingVersion = FALSE; } } parent::modAction( $action, $member, $reason, $immediately ); } /** * Can the member delete the pending version * * @param Member|null $member * @return bool */ public function canDeletePendingVersion( Member $member = NULL): bool { if( !$this->hasPendingVersion() ) { return FALSE; } if( $this->canEdit($member) ) { return TRUE; } return FALSE; } public static string $itemMenuKey ='file_actions'; public static string $itemMenuCss = ''; /** * @param Member|NULL $member * @return Menu */ public function menu( Member $member = NULL ): Menu { $menu = parent::menu( $member ); if( !$this->hasPendingVersion() AND $this->canEdit( $member ) ) { $menu->add( new Menu\Link( url: $this->url()->setQueryString( array( 'do' => 'newVersion' ) ), languageString: Member::loggedIn()->language()->addToStack( 'upload_new_version') ) ); } if( $this->isPurchasable() AND $this->canDisablePurchases( $member ) ) { $menu->add( new Menu\Link( url: $this->url()->setQueryString( array( 'do' => 'purchaseStatus' , 'value' => 0 ) )->csrf(), languageString: Member::loggedIn()->language()->addToStack( 'disable_purchases') ) ); } if( !$this->isPurchasable() AND $this->canEnablePurchases() ) { $menu->add( new Menu\Link( url: $this->url()->setQueryString( array( 'do' => 'purchaseStatus' , 'value' => 1 ) )->csrf(), languageString: Member::loggedIn()->language()->addToStack( 'enable_purchases') ) ); } return $menu; } /** * Allow for individual classes to override and * specify a primary image. Used for grid views, etc. * * @return SystemFile|null */ public function primaryImage() : ?SystemFile { return $this->primary_screenshot_thumb ?? parent::primaryImage(); } }