Permalink
Cannot retrieve contributors at this time
Fetching contributors…
| <?php namespace App; | |
| use Acetone; | |
| use App\BoardAdventure; | |
| use App\FileStorage; | |
| use App\FileAttachment; | |
| use App\PostCite; | |
| use App\Contracts\PermissionUser; | |
| use App\Services\ContentFormatter; | |
| use App\Support\Geolocation; | |
| use App\Support\IP; | |
| use App\Traits\TakePerGroup; | |
| use App\Traits\EloquentBinary; | |
| use Illuminate\Database\Eloquent\Collection; | |
| use Illuminate\Database\Eloquent\Model; | |
| use Illuminate\Database\Eloquent\SoftDeletes; | |
| use Cache; | |
| use DB; | |
| use Input; | |
| use File; | |
| use Request; | |
| use Event; | |
| use App\Events\ThreadNewReply; | |
| class Post extends Model { | |
| use EloquentBinary; | |
| use TakePerGroup; | |
| use SoftDeletes; | |
| /** | |
| * The database table used by the model. | |
| * | |
| * @var string | |
| */ | |
| protected $table = 'posts'; | |
| /** | |
| * The primary key that is used by ::get() | |
| * | |
| * @var string | |
| */ | |
| protected $primaryKey = 'post_id'; | |
| /** | |
| * The attributes that should be casted to native types. | |
| * | |
| * @var array | |
| */ | |
| protected $casts = [ | |
| 'board_id' => 'int', | |
| 'reply_to' => 'int', | |
| 'author_ip' => 'ip', | |
| ]; | |
| /** | |
| * The attributes that are mass assignable. | |
| * | |
| * @var array | |
| */ | |
| protected $fillable = [ | |
| 'board_uri', | |
| 'board_id', | |
| 'reply_to', | |
| 'reply_to_board_id', | |
| 'reply_last', | |
| 'bumped_last', | |
| 'created_at', | |
| 'updated_at', | |
| 'stickied', | |
| 'stickied_at', | |
| 'bumplocked_at', | |
| 'locked_at', | |
| 'featured_at', | |
| 'author_ip', | |
| 'author_ip_nulled_at', | |
| 'author_id', | |
| 'author_country', | |
| 'capcode_id', | |
| 'subject', | |
| 'author', | |
| 'insecure_tripcode', | |
| 'email', | |
| 'password', | |
| 'flag_id', | |
| 'body', | |
| 'body_has_content', | |
| 'body_too_long', | |
| 'body_parsed', | |
| 'body_parsed_preview', | |
| 'body_parsed_at', | |
| 'body_html', | |
| ]; | |
| /** | |
| * The attributes excluded from the model's JSON form. | |
| * | |
| * @var array | |
| */ | |
| protected $hidden = [ | |
| // Post Items | |
| 'author_ip', | |
| 'password', | |
| 'body', | |
| 'body_parsed', | |
| 'body_parsed_at', | |
| 'body_html', | |
| // Relationships | |
| // 'bans', | |
| 'board', | |
| // 'citedBy', | |
| // 'citedPosts', | |
| // 'editor', | |
| 'op', | |
| // 'replies', | |
| // 'reports', | |
| ]; | |
| /** | |
| * Attributes which do not exist but should be appended to the JSON output. | |
| * | |
| * @var array | |
| */ | |
| protected $appends = [ | |
| 'content_raw', | |
| 'content_html', | |
| 'recently_created', | |
| ]; | |
| /** | |
| * Attributes which are automatically sent through a Carbon instance on load. | |
| * | |
| * @var array | |
| */ | |
| protected $dates = [ | |
| 'reply_last', | |
| 'bumped_last', | |
| 'created_at', | |
| 'updated_at', | |
| 'deleted_at', | |
| 'stickied_at', | |
| 'bumplocked_at', | |
| 'locked_at', | |
| 'body_parsed_at', | |
| 'author_ip_nulled_at', | |
| ]; | |
| public function attachments() | |
| { | |
| return $this->belongsToMany("\App\FileStorage", 'file_attachments', 'post_id', 'file_id')->withPivot('attachment_id', 'filename', 'is_spoiler', 'is_deleted', 'position'); | |
| } | |
| public function attachmentLinks() | |
| { | |
| return $this->hasMany("\App\FileAttachment"); | |
| } | |
| public function backlinks() | |
| { | |
| return $this->hasMany('\App\PostCite', 'cite_id', 'post_id'); | |
| } | |
| public function bans() | |
| { | |
| return $this->hasMany('\App\Ban', 'post_id'); | |
| } | |
| public function board() | |
| { | |
| return $this->belongsTo('\App\Board', 'board_uri'); | |
| } | |
| public function capcode() | |
| { | |
| return $this->hasOne('\App\Role', 'role_id', 'capcode_id'); | |
| } | |
| public function cites() | |
| { | |
| return $this->hasMany('\App\PostCite', 'post_id'); | |
| } | |
| public function citedPosts() | |
| { | |
| return $this->belongsToMany("\App\Post", 'post_cites', 'post_id'); | |
| } | |
| public function citedByPosts() | |
| { | |
| return $this->belongsToMany("\App\Post", 'post_cites', 'cite_id', 'post_id'); | |
| } | |
| public function editor() | |
| { | |
| return $this->hasOne('\App\User', 'user_id', 'updated_by'); | |
| } | |
| public function flag() | |
| { | |
| return $this->hasOne('\App\BoardAsset', 'board_asset_id', 'flag_id'); | |
| } | |
| public function op() | |
| { | |
| return $this->belongsTo('\App\Post', 'reply_to', 'post_id'); | |
| } | |
| public function replies() | |
| { | |
| return $this->hasMany('\App\Post', 'reply_to', 'post_id'); | |
| } | |
| public function replyFiles() | |
| { | |
| return $this->hasManyThrough('App\FileAttachment', 'App\Post', 'reply_to', 'post_id'); | |
| } | |
| public function reports() | |
| { | |
| return $this->hasMany('\App\Report', 'post_id'); | |
| } | |
| /** | |
| * Determines if the user can bumplock this post | |
| * | |
| * @param App\Contracts\PermissionUser $user | |
| * @return boolean | |
| */ | |
| public function canBumplock($user) | |
| { | |
| return $user->canBumplock($this); | |
| } | |
| /** | |
| * Determines if the user can delete this post. | |
| * | |
| * @param App\Contracts\PermissionUser $user | |
| * @return boolean | |
| */ | |
| public function canDelete($user) | |
| { | |
| return $user->canDelete($this); | |
| } | |
| /** | |
| * Determines if the user can edit this post. | |
| * | |
| * @param App\Contracts\PermissionUser $user | |
| * @return boolean | |
| */ | |
| public function canEdit($user) | |
| { | |
| return $user->canEdit($this); | |
| } | |
| /** | |
| * Determines if the user can lock this post | |
| * | |
| * @param App\Contracts\PermissionUser $user | |
| * @return boolean | |
| */ | |
| public function canLock($user) | |
| { | |
| return $user->canLock($this); | |
| } | |
| /** | |
| * Determines if the user can reply to post, or if this thread is open to replies in general. | |
| * | |
| * @param App\Contracts\PermissionUser|null $user | |
| * @return boolean | |
| */ | |
| public function canReply($user = null) | |
| { | |
| if (!is_null($user)) | |
| { | |
| return $user->canReply($this); | |
| } | |
| return true; | |
| } | |
| /** | |
| * Determines if the user can report this post to board owners. | |
| * | |
| * @param App\Contracts\PermissionUser $user | |
| * @return boolean | |
| */ | |
| public function canReport($user) | |
| { | |
| return $user->canReport($this); | |
| } | |
| /** | |
| * Determines if the user can report this post to site owners. | |
| * | |
| * @param App\Contracts\PermissionUser $user | |
| * @return boolean | |
| */ | |
| public function canReportGlobally($user) | |
| { | |
| return $user->canReportGlobally($this); | |
| } | |
| /** | |
| * Determines if the user can sticky or unsticky this post. | |
| * | |
| * @param App\Contracts\PermissionUser $user | |
| * @return boolean | |
| */ | |
| public function canSticky($user) | |
| { | |
| return $user->canSticky($this); | |
| } | |
| /** | |
| * Counts the number of currently related reports that can be promoted. | |
| * | |
| * @param PermissionUser $user | |
| * @return int | |
| */ | |
| public function countReportsCanPromote(PermissionUser $user) | |
| { | |
| $count = 0; | |
| foreach ($this->reports as $report) | |
| { | |
| if ($report->canPromote($user)) | |
| { | |
| ++$count; | |
| } | |
| } | |
| return $count; | |
| } | |
| /** | |
| * Counts the number of currently related reports that can be demoted. | |
| * | |
| * @param PermissionUser $user | |
| * @return int | |
| */ | |
| public function countReportsCanDemote(PermissionUser $user) | |
| { | |
| $count = 0; | |
| foreach ($this->reports as $report) | |
| { | |
| if ($report->canDemote($user)) | |
| { | |
| ++$count; | |
| } | |
| } | |
| return $count; | |
| } | |
| /** | |
| * Checks a supplied password against the set one. | |
| * | |
| * @param string $password | |
| * @return bool | |
| */ | |
| public function checkPassword($password) | |
| { | |
| $hash = $this->makePassword($password, false); | |
| return !is_null($hash) && !is_null($this->password) && password_verify($hash, $this->password); | |
| } | |
| /** | |
| * Removes post HTML caches.. | |
| * | |
| * @return void | |
| */ | |
| public function clearPostHTMLCache() | |
| { | |
| switch (env('CACHE_DRIVER')) | |
| { | |
| case "file" : | |
| case "database" : | |
| break; | |
| default : | |
| Cache::tags(["post_{$this->post_id}"])->flush(); | |
| break; | |
| } | |
| } | |
| /** | |
| * Removes thread caches containing this post. | |
| * | |
| * @return void | |
| */ | |
| public function clearThreadCache() | |
| { | |
| // If this post is a reply to a thread | |
| if ($this->reply_to_board_id) | |
| { | |
| switch (env('CACHE_DRIVER')) | |
| { | |
| case "file" : | |
| case "database" : | |
| Cache::forget("board.{$this->board_uri}.thread.{$this->reply_to_board_id}"); | |
| break; | |
| default : | |
| Cache::tags(["board.{$this->board_uri}", "threads"])->forget("board.{$this->board_uri}.thread.{$this->reply_to_board_id}"); | |
| break; | |
| } | |
| } | |
| switch (env('CACHE_DRIVER')) | |
| { | |
| case "file" : | |
| case "database" : | |
| Cache::forget("board.{$this->board_uri}.thread.{$this->board_id}"); | |
| break; | |
| default : | |
| Cache::tags(["board.{$this->board_uri}", "threads"])->forget("board.{$this->board_uri}.thread.{$this->board_id}"); | |
| break; | |
| } | |
| if (env('APP_VARNISH')) | |
| { | |
| Acetone::purge("/{$this->board_uri}/thread/{$this->reply_to_board_id}"); | |
| } | |
| } | |
| /** | |
| * Returns backlinks for this post which are permitted by board config. | |
| * | |
| * @param \App\Board|null $board Optional. Board to check against. If null, assumes this post's board. | |
| * @return Collection of \App\PostCite | |
| */ | |
| public function getAllowedBacklinks(Board $board = null) | |
| { | |
| if (is_null($board)) | |
| { | |
| $board = $this->board; | |
| } | |
| $backlinks = collect(); | |
| foreach ($this->backlinks as $backlink) | |
| { | |
| if ($board->isBacklinkAllowed($backlink)) | |
| { | |
| $backlinks->push($backlink); | |
| } | |
| } | |
| return $backlinks; | |
| } | |
| /** | |
| * Returns a small, unique code to identify an author in one thread. | |
| * | |
| * @return string | |
| */ | |
| public function makeAuthorId() | |
| { | |
| $hashParts = []; | |
| $hashParts[] = env('APP_KEY'); | |
| $hashParts[] = $this->board_uri; | |
| $hashParts[] = $this->reply_to_board_id ?: $this->board_id; | |
| $hashParts[] = $this->author_ip; | |
| $hash = implode($hashParts, "-"); | |
| $hash = hash('sha256', $hash); | |
| $hash = substr($hash, 12, 6); | |
| return $hash; | |
| } | |
| /** | |
| * Returns a SHA1 hash (in text or binary) representing an originality/r9k checksum. | |
| * | |
| * @static | |
| * @param string $body The body to be checksum'd. | |
| * @param bool $binary Optional. If the return should be binary. Defaults false. | |
| * @return string|binary | |
| */ | |
| public static function makeChecksum($text, $binary = false) | |
| { | |
| $postRobot = preg_replace('/\s+/', "", $text); | |
| $checksum = sha1($postRobot, $binary); | |
| if ($binary) | |
| { | |
| return binary_sql($checksum); | |
| } | |
| return $checksum; | |
| } | |
| /** | |
| * Bcrypts a password using relative information. | |
| * | |
| * @param string $password The password to be set. If empty password is given, no password will be set. | |
| * @param boolean $encrypt Optional. Indicates if the hash should be bcrypted. Defaults true. | |
| * @return string | |
| */ | |
| public function makePassword($password = null, $encrypt = true) | |
| { | |
| $hashParts = []; | |
| if (!!$password) | |
| { | |
| $hashParts[] = env('APP_KEY'); | |
| $hashParts[] = $this->board_uri; | |
| $hashParts[] = $password; | |
| $hashParts[] = $this->board_id; | |
| } | |
| $parts = implode($hashParts, "|"); | |
| if ($encrypt) | |
| { | |
| return bcrypt($parts); | |
| } | |
| return $parts; | |
| } | |
| /** | |
| * Turns the author id into a consistent color. | |
| * | |
| * @param boolean $asArray | |
| * @return string In the format of rgb(xxx,xxx,xxx) or as an array. | |
| */ | |
| public function getAuthorIdBackgroundColor($asArray = false) | |
| { | |
| $authorId = $this->author_id; | |
| $colors = []; | |
| $colors[] = crc32(substr($authorId, 0, 2)) % 254 + 1; | |
| $colors[] = crc32(substr($authorId, 2, 2)) % 254 + 1; | |
| $colors[] = crc32(substr($authorId, 4, 2)) % 254 + 1; | |
| if ($asArray) | |
| { | |
| return $colors; | |
| } | |
| return "rgba(" . implode(",", $colors) . ",0.75)"; | |
| } | |
| /** | |
| * Takess the author id background color and determines if we need a white or black text color. | |
| * | |
| * @return string In the format of rgba(xxx,xxx,xxx,x) | |
| */ | |
| public function getAuthorIdForegroundColor() | |
| { | |
| $colors = $this->getAuthorIdBackgroundColor(true); | |
| if (array_sum($colors) < 382) | |
| { | |
| return "rgb(255,255,255)"; | |
| } | |
| foreach ($colors as $color) | |
| { | |
| if ($color > 200) | |
| { | |
| return "rgb(0,0,0)"; | |
| } | |
| } | |
| return "rgb(0,0,0)"; | |
| } | |
| /** | |
| * Returns the raw input for a post for the JSON output. | |
| * | |
| * @return string | |
| */ | |
| public function getAuthorIdAttribute() | |
| { | |
| if ($this->board->getConfig('postsThreadId', false)) | |
| { | |
| return $this->attributes['author_id']; | |
| } | |
| return null; | |
| } | |
| /** | |
| * Returns the fully rendered HTML content of this post. | |
| * | |
| * @param boolean $skipCache | |
| * @return string | |
| */ | |
| public function getBodyFormatted($skipCache = false) | |
| { | |
| if (!$skipCache) | |
| { | |
| // Markdown parsed content | |
| if (!is_null($this->body_html)) | |
| { | |
| if (!mb_check_encoding($this->body_html, 'UTF-8')) | |
| { | |
| return "<tt style=\"color:red;\">Invalid encoding. This should never happen!</tt>"; | |
| } | |
| return $this->body_html; | |
| } | |
| // Raw HTML input | |
| if (!is_null($this->body_parsed)) | |
| { | |
| return $this->body_parsed; | |
| } | |
| } | |
| $ContentFormatter = new ContentFormatter(); | |
| $this->body_too_long = false; | |
| $this->body_parsed = $ContentFormatter->formatPost($this); | |
| $this->body_parsed_preview = null; | |
| $this->body_parsed_at = $this->freshTimestamp(); | |
| $this->body_has_content = $ContentFormatter->hasContent(); | |
| if (!mb_check_encoding($this->body_parsed, 'UTF-8')) | |
| { | |
| return "<tt style=\"color:red;\">Invalid encoding. This should never happen!</tt>"; | |
| } | |
| // If our body is too long, we need to pull the first X characters and do that instead. | |
| // We also set a token indicating this post has hidden content. | |
| if (mb_strlen($this->body) > 1200) | |
| { | |
| $this->body_too_long = true; | |
| $this->body_parsed_preview = $ContentFormatter->formatPost($this, 1000); | |
| } | |
| // We use an update here instead of just saving $post because, in this method | |
| // there will frequently be additional properties on this object that cannot | |
| // be saved. To make life easier, we just touch the object. | |
| static::where(['post_id' => $this->post_id])->update([ | |
| 'body_has_content' => $this->body_has_content, | |
| 'body_too_long' => $this->body_too_long, | |
| 'body_parsed' => $this->body_parsed, | |
| 'body_parsed_preview' => $this->body_parsed_preview, | |
| 'body_parsed_at' => $this->body_parsed_at, | |
| ]); | |
| return $this->body_parsed; | |
| } | |
| /** | |
| * Returns a partially rendered HTML preview of this post. | |
| * | |
| * @param boolean $skipCache | |
| * @return string | |
| */ | |
| public function getBodyPreview($skipCache = false) | |
| { | |
| $body_parsed = $this->getBodyFormatted($skipCache); | |
| if ($this->body_too_long !== true || !isset($this->body_parsed_preview)) | |
| { | |
| return $body_parsed; | |
| } | |
| return $this->body_parsed_preview; | |
| } | |
| /** | |
| * Returns the raw input for a post for the JSON output. | |
| * | |
| * @return string | |
| */ | |
| public function getContentRawAttribute($value) | |
| { | |
| if (!$this->trashed() && isset($this->attributes['body'])) | |
| { | |
| return $this->attributes['body']; | |
| } | |
| return null; | |
| } | |
| /** | |
| * Returns the rendered interior HTML for a post for the JSON output. | |
| * | |
| * @return string | |
| */ | |
| public function getContentHtmlAttribute($value) | |
| { | |
| if (!$this->trashed() && isset($this->attributes['body'])) | |
| { | |
| return $this->getBodyFormatted(); | |
| } | |
| return null; | |
| } | |
| /** | |
| * Returns a name for the country. This is usually the ISO 3166-1 alpha-2 code. | |
| * | |
| * @return string|null | |
| */ | |
| public function getCountryCode() | |
| { | |
| if (!is_null($this->author_country)) | |
| { | |
| if ($this->author_country == "") | |
| { | |
| return "unknown"; | |
| } | |
| return $this->author_country; | |
| } | |
| return null; | |
| } | |
| /** | |
| * Returns the fully rendered HTML of a post in the JSON output. | |
| * | |
| * @return string | |
| */ | |
| public function getHtmlAttribute() | |
| { | |
| if (!$this->trashed()) | |
| { | |
| return $this->toHTML(false, false, false); | |
| } | |
| return null; | |
| } | |
| /** | |
| * Returns the recently created flag for the JSON output. | |
| * | |
| * @return string | |
| */ | |
| public function getRecentlyCreatedAttribute() | |
| { | |
| return $this->wasRecentlyCreated; | |
| } | |
| /** | |
| * Returns a count of current reply relationships. | |
| * | |
| * @return int | |
| */ | |
| public function getReplyCount() | |
| { | |
| return $this->getRelation('replies')->count(); | |
| } | |
| /** | |
| * Returns a count of current reply relationships. | |
| * | |
| * @return int | |
| */ | |
| public function getReplyFileCount() | |
| { | |
| $files = 0; | |
| foreach ($this->getRelation('replies') as $reply) | |
| { | |
| $files += $reply->getRelation('attachments')->count(); | |
| } | |
| return $this->reply_file_count < $files ? $this->reply_file_count : max(0, $files); | |
| } | |
| /** | |
| * Returns a splice of the replies based on the 2channel style input. | |
| * | |
| * @param string $uri | |
| * @return static|boolean Returns $this with modified replies relationship, or false if input error. | |
| */ | |
| public function getReplySplice($splice) | |
| { | |
| // Matches: | |
| // l50 OP and last 50 posts | |
| // l2 OP and last 2 posts | |
| // 600- OP and all posts from 600 onwards | |
| // 10-20 OP and posts ten through twenty | |
| // 600 OP and post 600 only | |
| // -100 OP and first 100 posts | |
| // Indices start at 1, which includes OP. | |
| if (preg_match('/^(?<last>l)?(?<start>\d+)?(?P<between>-)?(?P<end>\d+)?$/', $splice, $m) === 1) | |
| { | |
| $count = $this->replies->count(); | |
| $last = isset($m['last']) && $m['last'] == "l" ? true : false; | |
| $start = isset($m['start']) && $m['start'] != "" ? (int) $m['start'] : false; | |
| $between = isset($m['between']) && $m['between'] == "-" ? true : false; | |
| $end = isset($m['end']) && $m['end'] != "" ? (int) $m['end'] : false; | |
| $length = null; | |
| // Fetching last posts? | |
| if ($last === true) | |
| { | |
| // Pull last X. | |
| if ($start !== false && $between == false && $end === false) | |
| { | |
| $start = $count - $start; | |
| $length = $count; | |
| } | |
| else | |
| { | |
| return false; | |
| } | |
| } | |
| // Pull between two indices. | |
| else if($between === true) | |
| { | |
| // Have we specified an X-Y range? | |
| if ($start !== false && $end !== false) | |
| { | |
| // Abort if we've specified an incorrect range. | |
| if ($start <= 0 || $start > $end) | |
| { | |
| return false; | |
| } | |
| $start -= 2; | |
| $length = $end - $start - 1; | |
| } | |
| // Have we specified a -X (pull first X posts) range? | |
| else if ($start === false && $end !== false) | |
| { | |
| $start = 0; | |
| $length = $end - 1; | |
| if ($length < 0) | |
| { | |
| return false; | |
| } | |
| } | |
| // Have we specified a X- (pull from post X up) range? | |
| else if ($start !== false && $end === false) | |
| { | |
| $start -= 2; | |
| $length = $count; | |
| } | |
| else | |
| { | |
| return false; | |
| } | |
| } | |
| // Pull a single post. | |
| else if($start !== false) | |
| { | |
| if ($start > 1) | |
| { | |
| $length = 1; | |
| } | |
| // If we're requesting OP, we want no children. | |
| else if ($start == 1) | |
| { | |
| $length = 0; | |
| } | |
| else | |
| { | |
| return false; | |
| } | |
| } | |
| else | |
| { | |
| return false; | |
| } | |
| $start = max($start, 0); | |
| return $this->setRelation('replies', $this->replies->splice($start, $length)); | |
| } | |
| return false; | |
| } | |
| /** | |
| * Returns a relative URL for opening this post. | |
| * | |
| * @return string | |
| */ | |
| public function getURL($splice = null) | |
| { | |
| if ($this->reply_to_board_id) | |
| { | |
| $url_id = $this->reply_to_board_id; | |
| $url_hash = $this->board_id; | |
| } | |
| else | |
| { | |
| $url_id = $this->board_id; | |
| $url_hash = $this->board_id; | |
| } | |
| if (is_string($splice)) | |
| { | |
| $splice = "/{$splice}"; | |
| } | |
| else | |
| { | |
| $splice = ""; | |
| } | |
| return url("/{$this->board_uri}/thread/{$url_id}{$splice}#{$url_hash}"); | |
| } | |
| /** | |
| * Determines if the post is made from the client's remote address. | |
| * | |
| * @return boolean | |
| */ | |
| public function isAuthoredByClient() | |
| { | |
| if (is_null($this->author_ip)) | |
| { | |
| return false; | |
| } | |
| return new IP($this->author_ip) === new IP(); | |
| } | |
| /** | |
| * Determines if this is a bumpless post. | |
| * | |
| * @return boolean | |
| */ | |
| public function isBumpless() | |
| { | |
| if ($this->email == "sage") | |
| { | |
| return true; | |
| } | |
| return false; | |
| } | |
| /** | |
| * Determines if this thread cannot be bumped. | |
| * | |
| * @return boolean | |
| */ | |
| public function isBumplocked() | |
| { | |
| return !is_null($this->bumplocked_at); | |
| } | |
| /** | |
| * Determines if this is cyclic. | |
| * | |
| * @return boolean | |
| */ | |
| public function isCyclic() | |
| { | |
| return false; | |
| } | |
| /** | |
| * Determines if this is deleted. | |
| * | |
| * @return boolean | |
| */ | |
| public function isDeleted() | |
| { | |
| return !is_null($this->deleted_at); | |
| } | |
| /** | |
| * Determines if this is the first reply in a thread. | |
| * | |
| * @return boolean | |
| */ | |
| public function isOp() | |
| { | |
| return is_null($this->reply_to); | |
| } | |
| /** | |
| * Determines if this thread is locked. | |
| * | |
| * @return boolean | |
| */ | |
| public function isLocked() | |
| { | |
| return !is_null($this->locked_at); | |
| } | |
| /** | |
| * Determines if this thread is stickied. | |
| * | |
| * @return boolean | |
| */ | |
| public function isStickied() | |
| { | |
| return !is_null($this->stickied_at); | |
| } | |
| /** | |
| * Returns the author IP in a human-readable format. | |
| * | |
| * @return string | |
| */ | |
| public function getAuthorIpAsString() | |
| { | |
| if ($this->hasAuthorIp()) | |
| { | |
| return$this->author_ip->toText(); | |
| } | |
| return false; | |
| } | |
| /** | |
| * Returns author_ip as an instance of the support class. | |
| * | |
| * @return \App\Support\IP|null | |
| */ | |
| public function getAuthorIpAttribute() | |
| { | |
| if (!isset($this->attributes['author_ip'])) | |
| { | |
| return null; | |
| } | |
| if ($this->attributes['author_ip'] instanceof IP) | |
| { | |
| return $this->attributes['author_ip']; | |
| } | |
| $this->attributes['author_ip'] = new IP($this->attributes['author_ip']); | |
| return $this->attributes['author_ip']; | |
| } | |
| /** | |
| * Returns the bit size of the IP. | |
| * | |
| * @return int (32 or 128) | |
| */ | |
| public function getAuthorIpBitSize() | |
| { | |
| if ($this->hasAuthorIp()) | |
| { | |
| return strpos($this->getAuthorIpAsString(), ":") === false ? 32 : 128; | |
| } | |
| return false; | |
| } | |
| /** | |
| * Returns a user-friendly list of ranges available for this IP. | |
| * | |
| * @return array | |
| */ | |
| public function getAuthorIpRangeOptions() | |
| { | |
| $bitsize = $this->getAuthorIpBitSize(); | |
| $range = range(0, $bitsize); | |
| $masks = []; | |
| foreach ($range as $mask) | |
| { | |
| $affectedIps = number_format(pow(2, $bitsize - $mask), 0); | |
| $masks[$mask] = trans_choice("board.ban.ip_range_{$bitsize}", $mask, [ | |
| 'mask' => $mask, | |
| 'ips' => $affectedIps | |
| ]); | |
| } | |
| return $masks; | |
| } | |
| /** | |
| * Returns the board model for this post. | |
| * | |
| * @return \App\Board | |
| */ | |
| public function getBoard() | |
| { | |
| return $this->board() | |
| ->get() | |
| ->first(); | |
| } | |
| /** | |
| * Returns a human-readable capcode string. | |
| * | |
| * @return string | |
| */ | |
| public function getCapcodeName() | |
| { | |
| if ($this->capcode_capcode) | |
| { | |
| return trans_choice((string) $this->capcode_capcode, 0); | |
| } | |
| else if ($this->capcode_id) | |
| { | |
| return $this->capcode->getCapcodeName(); | |
| } | |
| return ""; | |
| } | |
| /** | |
| * Parses the post text for citations. | |
| * | |
| * @return Collection | |
| */ | |
| public function getCitesFromText() | |
| { | |
| return ContentFormatter::getCites($this); | |
| } | |
| /** | |
| * Returns a SHA1 checksum for this post's text. | |
| * | |
| * @param boolean Option. If return should be binary. Defaults false. | |
| * @return string|binary | |
| */ | |
| public function getChecksum($binary = false) | |
| { | |
| return $this->makeChecksum($this->body, $binary); | |
| } | |
| /** | |
| * Returns the last post made by this user across the entire site. | |
| * | |
| * @static | |
| * @param string $ip | |
| * @return \App\Post | |
| */ | |
| public static function getLastPostForIP($ip = null) | |
| { | |
| if (is_null($ip)) | |
| { | |
| $ip = new IP; | |
| } | |
| return Post::whereAuthorIP($ip) | |
| ->orderBy('created_at', 'desc') | |
| ->take(1) | |
| ->get() | |
| ->first(); | |
| } | |
| /** | |
| * Returns the page on which this thread appears. | |
| * If the post is a reply, it will return the page it appears on in the thread, which is always 1. | |
| * | |
| * @return \App\Post | |
| */ | |
| public function getPage() | |
| { | |
| if ($this->isOp()) | |
| { | |
| $board = $this->board()->with('settings')->get()->first(); | |
| $visibleThreads = $board->threads()->op()->where('bumped_last', '>=', $this->bumped_last)->count(); | |
| $threadsPerPage = (int) $board->getConfig('postsPerPage', 10); | |
| return floor(($visibleThreads - 1) / $threadsPerPage) + 1; | |
| } | |
| return 1; | |
| } | |
| /** | |
| * Returns the post model for the most recently featured post. | |
| * | |
| * @static | |
| * @param int $dayRange Optional. Number of days at most that the last most featured post can be in. Defaults 3. | |
| * @return \App\Post | |
| */ | |
| public static function getPostFeatured($dayRange = 3) | |
| { | |
| $oldestPossible = \Carbon\Carbon::now()->subDays($dayRange); | |
| return static::where('featured_at', '>=', $oldestPossible) | |
| ->withEverything() | |
| ->orderBy('featured_at', 'desc') | |
| ->first(); | |
| } | |
| /** | |
| * Returns the post model using the board's URI and the post's local board ID. | |
| * | |
| * @static | |
| * @param string $board_uri | |
| * @param integer $board_id | |
| * @return \App\Post | |
| */ | |
| public static function getPostForBoard($board_uri, $board_id) | |
| { | |
| return static::where([ | |
| 'board_uri' => $board_uri, | |
| 'board_id' => $board_id, | |
| ]) | |
| ->first(); | |
| } | |
| /** | |
| * Returns the model for this post's original post (what it is a reply to). | |
| * | |
| * @return \App\Post | |
| */ | |
| public function getOp() | |
| { | |
| return $this->op() | |
| ->get() | |
| ->first(); | |
| } | |
| /** | |
| * Returns a few posts for the front page. | |
| * | |
| * @static | |
| * @param int $number How many to pull. | |
| * @param bool $sfwOnly If we only want SFW boards. | |
| * @return \Illuminate\Database\Eloquent\Collection of Post | |
| */ | |
| public static function getRecentPosts($number = 16, $sfwOnly = true) | |
| { | |
| return static::where('body_has_content', true) | |
| ->whereHas('board', function($query) use ($sfwOnly) { | |
| $query->where('is_indexed', '=', true); | |
| $query->where('is_overboard', '=', true); | |
| if ($sfwOnly) | |
| { | |
| $query->where('is_worksafe', '=', true); | |
| } | |
| }) | |
| ->with('board') | |
| ->with(['board.assets' => function($query) { | |
| $query->whereBoardIcon(); | |
| }]) | |
| ->limit($number) | |
| ->orderBy('post_id', 'desc') | |
| ->get(); | |
| } | |
| /** | |
| * Returns the latest reply to a post. | |
| * | |
| * @return Post|null | |
| */ | |
| public function getReplyLast() | |
| { | |
| return $this->replies() | |
| ->orderBy('post_id', 'desc') | |
| ->take(1) | |
| ->get() | |
| ->first(); | |
| } | |
| /** | |
| * Returns all replies to a post. | |
| * | |
| * @return \Illuminate\Database\Eloquent\Collection of Post | |
| */ | |
| public function getReplies() | |
| { | |
| if (isset($this->replies)) | |
| { | |
| return $this->replies; | |
| } | |
| return $this->replies() | |
| ->withEverything() | |
| ->orderBy('post_id', 'asc') | |
| ->get(); | |
| } | |
| /** | |
| * Returns the last few replies to a thread for index views. | |
| * | |
| * @return \Illuminate\Database\Eloquent\Collection | |
| */ | |
| public function getRepliesForIndex() | |
| { | |
| return $this->replies() | |
| ->forIndex() | |
| ->get() | |
| ->reverse(); | |
| } | |
| /** | |
| * Returns a set of posts for an update request. | |
| * | |
| * @static | |
| * @param Carbon $sinceTime | |
| * @param Board $board | |
| * @param Post $thread | |
| * @param boolean $includeHTML If the posts should also have very large 'content_html' values. | |
| * @return Collection of Posts | |
| */ | |
| public static function getUpdates($sinceTime, Board $board, Post $thread, $includeHTML = false) | |
| { | |
| $posts = static::whereInUpdate($sinceTime, $board, $thread)->get(); | |
| if ($includeHTML) | |
| { | |
| foreach ($posts as $post) | |
| { | |
| $post->setAppendHTML(true); | |
| } | |
| }; | |
| return $posts; | |
| } | |
| /** | |
| * Returns if this post has an attached IP address. | |
| * | |
| * @return boolean | |
| */ | |
| public function hasAuthorIp() | |
| { | |
| return $this->author_ip !== null; | |
| } | |
| /** | |
| * Determines if this post has a body message. | |
| * | |
| * @return boolean | |
| */ | |
| public function hasBody() | |
| { | |
| $body = false; | |
| $body_html = false; | |
| if (isset($this->attributes['body'])) | |
| { | |
| $body = strlen( trim( (string) $this->attributes['body'] ) ) > 0; | |
| } | |
| if (isset($this->attributes['body_html'])) | |
| { | |
| $body_html = strlen( trim( (string) $this->attributes['body_html'] ) ) > 0; | |
| } | |
| return $body || $body_html; | |
| } | |
| /** | |
| * Get the appends attribute. | |
| * Not normally available to models, but required for API responses. | |
| * | |
| * @param array $appends | |
| * @return array | |
| */ | |
| public function getAppends() | |
| { | |
| return $this->appends; | |
| } | |
| /** | |
| * Pull threads for the overboard. | |
| * | |
| * @static | |
| * @param int $page | |
| * @return Collection of static | |
| */ | |
| public static function getThreadsForOverboard($page = 0) | |
| { | |
| $postsPerPage = 10; | |
| $rememberTags = ["site.overboard.pages"]; | |
| $rememberTimer = 30; | |
| $rememberKey = "site.overboard.page.{$page}"; | |
| $rememberClosure = function() use ($page, $postsPerPage) { | |
| $threads = static::with('board', 'board.settings', 'board.assets') | |
| ->op() | |
| ->withEverything() | |
| ->with(['replies' => function($query) { | |
| $query->forIndex(); | |
| }]) | |
| ->whereHas('board', function($query) { | |
| $query->where('is_indexed', true); | |
| $query->where('is_overboard', true); | |
| }) | |
| ->orderBy('bumped_last', 'desc') | |
| ->skip($postsPerPage * ( $page - 1 )) | |
| ->take($postsPerPage) | |
| ->get(); | |
| // The way that replies are fetched forIndex pulls them in reverse order. | |
| // Fix that. | |
| foreach ($threads as $thread) | |
| { | |
| $replyTake = $thread->stickied_at ? 1 : 5; | |
| $thread->body_parsed = $thread->getBodyFormatted(); | |
| $thread->replies = $thread->replies | |
| ->reverse() | |
| ->splice(-$replyTake, $replyTake); | |
| $thread->prepareForCache(); | |
| } | |
| return $threads; | |
| }; | |
| switch (env('CACHE_DRIVER')) | |
| { | |
| case "file" : | |
| case "database" : | |
| $threads = Cache::remember($rememberKey, $rememberTimer, $rememberClosure); | |
| break; | |
| default : | |
| $threads = Cache::tags($rememberTags)->remember($rememberKey, $rememberTimer, $rememberClosure); | |
| break; | |
| } | |
| return $threads; | |
| } | |
| /** | |
| * Prepares a thread and its relationships for a complete cache. | |
| * | |
| * @return \App\Post | |
| */ | |
| public function prepareForCache($board = null) | |
| { | |
| ## TODO ## | |
| // Find a better way to do this. | |
| // Call these methods so we typecast the IP as an IP class before | |
| // we invoke memory caching. | |
| $this->author_ip; | |
| $board = $this->getRelation('board' ?: $this->load('board')); | |
| foreach ($this->replies as $reply) | |
| { | |
| $reply->author_ip; | |
| $reply->setRelation('board', $board); | |
| } | |
| return $this; | |
| } | |
| /** | |
| * Sets the value of $this->appends to the input. | |
| * Not normally available to models, but required for API responses. | |
| * | |
| * @param array $appends | |
| * @return array | |
| */ | |
| public function setAppends(array $appends) | |
| { | |
| return $this->appends = $appends; | |
| } | |
| /** | |
| * Quickly add html to the append list for this model. | |
| * | |
| * @param boolean $add defaults true | |
| * @return Post | |
| */ | |
| public function setAppendHTML($add = true) | |
| { | |
| $appends = $this->getAppends(); | |
| if ($add) | |
| { | |
| $appends[] = "html"; | |
| } | |
| else if (($key = array_search("html", $appends)) !== false) | |
| { | |
| unset($appends[$key]); | |
| } | |
| $this->setAppends($appends); | |
| return $this; | |
| } | |
| /** | |
| * Stores author_ip as an instance of the support class. | |
| * | |
| * @param \App\Support\IP|string|null $value The IP to store. | |
| * @return \App\Support\IP|null | |
| */ | |
| public function setAuthorIpAttribute($value) | |
| { | |
| if (!is_null($value) && !is_binary($value)) | |
| { | |
| $value = new IP($value); | |
| } | |
| return $this->attributes['author_ip'] = $value; | |
| } | |
| /** | |
| * Sets the bumplock property timestamp. | |
| * | |
| * @param boolean $bumplock | |
| * @return \App\Post | |
| */ | |
| public function setBumplock($bumplock = true) | |
| { | |
| if ($bumplock) | |
| { | |
| $this->bumplocked_at = $this->freshTimestamp(); | |
| } | |
| else | |
| { | |
| $this->bumplocked_at = null; | |
| } | |
| return $this; | |
| } | |
| /** | |
| * Sets the deleted timestamp. | |
| * | |
| * @param boolean $delete | |
| * @return \App\Post | |
| */ | |
| public function setDeleted($delete = true) | |
| { | |
| if ($delete) | |
| { | |
| $this->deleted_at = $this->freshTimestamp(); | |
| } | |
| else | |
| { | |
| $this->deleted_at = null; | |
| } | |
| return $this; | |
| } | |
| /** | |
| * Sets the locked property timestamp. | |
| * | |
| * @param boolean $lock | |
| * @return \App\Post | |
| */ | |
| public function setLocked($lock = true) | |
| { | |
| if ($lock) | |
| { | |
| $this->locked_at = $this->freshTimestamp(); | |
| } | |
| else | |
| { | |
| $this->locked_at = null; | |
| } | |
| return $this; | |
| } | |
| /** | |
| * Sets the sticky property of a post and updates relevant timestamps. | |
| * | |
| * @param boolean $sticky | |
| * @return \App\Post | |
| */ | |
| public function setSticky($sticky = true) | |
| { | |
| if ($sticky) | |
| { | |
| $this->stickied = true; | |
| $this->stickied_at = $this->freshTimestamp(); | |
| } | |
| else | |
| { | |
| $this->stickied = false; | |
| $this->stickied_at = null; | |
| } | |
| return $this; | |
| } | |
| public function scopeAndAttachments($query) | |
| { | |
| return $query->with('attachments'); | |
| } | |
| public function scopeAndBacklinks($query) | |
| { | |
| return $query->with([ | |
| 'backlinks' => function($query) { | |
| $query->has('post'); | |
| $query->orderBy('post_id', 'asc'); | |
| }, | |
| 'backlinks.post' => function($query) { | |
| $query->select('post_id', 'board_uri', 'board_id', 'reply_to', 'reply_to_board_id'); | |
| }, | |
| ]); | |
| } | |
| public function scopeAndBoard($query) | |
| { | |
| return $query->with('board'); | |
| } | |
| public function scopeAndBans($query) | |
| { | |
| return $query->with(['bans' => function($query) | |
| { | |
| $query->orderBy('created_at', 'asc'); | |
| }]); | |
| } | |
| public function scopeAndCapcode($query) | |
| { | |
| return $query | |
| ->leftJoin('roles', function($join) | |
| { | |
| $join->on('roles.role_id', '=', 'posts.capcode_id'); | |
| }) | |
| ->addSelect( | |
| 'roles.capcode as capcode_capcode', | |
| 'roles.role as capcode_role', | |
| 'roles.name as capcode_name' | |
| ); | |
| } | |
| public function scopeAndCites($query) | |
| { | |
| return $query->with('cites', 'cites.cite'); | |
| } | |
| public function scopeAndEditor($query) | |
| { | |
| return $query | |
| ->leftJoin('users', function($join) | |
| { | |
| $join->on('users.user_id', '=', 'posts.updated_by'); | |
| }) | |
| ->addSelect( | |
| 'users.username as updated_by_username' | |
| ); | |
| } | |
| public function scopeAndFlag($query) | |
| { | |
| return $query->with('flag'); | |
| } | |
| public function scopeAndFirstAttachment($query) | |
| { | |
| return $query->with(['attachments' => function($query) | |
| { | |
| $query->limit(1); | |
| }]); | |
| } | |
| public function scopeAndReplies($query) | |
| { | |
| return $query->with(['replies' => function($query) | |
| { | |
| $query->withEverything(); | |
| }]); | |
| } | |
| public function scopeAndPromotedReports($query) | |
| { | |
| return $query->with(['reports' => function($query) | |
| { | |
| $query->whereOpen(); | |
| $query->wherePromoted(); | |
| }]); | |
| } | |
| public function scopeWhereAuthorIP($query, $ip) | |
| { | |
| $ip = new IP($ip); | |
| return $query->where('author_ip', $ip->toSQL()); | |
| } | |
| public function scopeIpString($query, $ip) | |
| { | |
| return $query->whereAuthorIP($ip); | |
| } | |
| public function scopeIpBinary($query, $ip) | |
| { | |
| return $query->whereAuthorIP($ip); | |
| } | |
| public function scopeOp($query) | |
| { | |
| return $query->where('reply_to', null); | |
| } | |
| public function scopeRecent($query) | |
| { | |
| return $query->where('created_at', '>=', static::freshTimestamp()->subHour()); | |
| } | |
| public function scopeForIndex($query) | |
| { | |
| return $query->withEverythingForReplies() | |
| ->orderBy('post_id', 'desc') | |
| ->takePerGroup('reply_to', 5); | |
| } | |
| public function scopeReplyTo($query, $replies = false) | |
| { | |
| if ($replies instanceof \Illuminate\Database\Eloquent\Collection) | |
| { | |
| $thread_ids = []; | |
| foreach ($replies as $thread) | |
| { | |
| $thread_ids[] = (int) $thread->post_id; | |
| } | |
| return $query->whereIn('reply_to', $thread_ids); | |
| } | |
| else if (is_numeric($replies)) | |
| { | |
| return $query->where('reply_to', '=', $replies); | |
| } | |
| else | |
| { | |
| return $query->where('reply_to', 'not', null); | |
| } | |
| } | |
| public function scopeWithEverything($query) | |
| { | |
| return $query | |
| ->withEverythingForReplies() | |
| ->andBoard(); | |
| } | |
| public function scopeWithEverythingAndReplies($query) | |
| { | |
| return $query | |
| ->withEverything() | |
| ->with(['replies' => function($query) { | |
| $query->withEverythingForReplies(); | |
| $query->orderBy('board_id', 'asc'); | |
| }]); | |
| } | |
| public function scopeWithEverythingForReplies($query) | |
| { | |
| return $query | |
| ->addSelect("posts.*") | |
| ->andAttachments() | |
| ->andBans() | |
| ->andBacklinks() | |
| ->andCapcode() | |
| ->andCites() | |
| ->andEditor() | |
| ->andFlag() | |
| ->andPromotedReports(); | |
| } | |
| public function scopeWhereHasReports($query) | |
| { | |
| return $query->whereHas('reports', function($query) | |
| { | |
| $query->whereOpen(); | |
| }); | |
| } | |
| public function scopeWhereHasReportsFor($query, PermissionUser $user) | |
| { | |
| return $query->whereHas('reports', function($query) use ($user) | |
| { | |
| $query->whereOpen(); | |
| $query->whereResponsibleFor($user); | |
| }) | |
| ->with(['reports' => function($query) use ($user) { | |
| $query->whereOpen(); | |
| $query->whereResponsibleFor($user); | |
| }]); | |
| } | |
| public function scopeWhereInThread($query, Post $thread) | |
| { | |
| if ($thread->attributes['reply_to_board_id']) | |
| { | |
| return $query->where(function($query) use ($thread) { | |
| $query->where('board_id', $thread->attributes['reply_to_board_id']); | |
| $query->orWhere('reply_to_board_id', $thread->attributes['reply_to_board_id']); | |
| }); | |
| } | |
| else | |
| { | |
| return $query->where(function($query) use ($thread) { | |
| $query->where('board_id', $thread->attributes['board_id']); | |
| $query->orWhere('reply_to_board_id', $thread->attributes['board_id']); | |
| }); | |
| } | |
| } | |
| /** | |
| * Logic for pulling posts for API updates. | |
| * | |
| * @param DbQuery $query Provided by Laravel. | |
| * @param Board $board | |
| * @param Carbon $sinceTime | |
| * @param Post $thread Board ID. | |
| * @return $query | |
| */ | |
| public function scopeWhereInUpdate($query, $sinceTime, Board $board, Post $thread) | |
| { | |
| // Find posts in this board. | |
| return $query->where('posts.board_uri', $board->board_uri) | |
| // Fetch accessory tables too. | |
| ->withEverything() | |
| // Only pull posts in this thread, or that is this thread. | |
| ->where(function($query) use ($thread) { | |
| $query->where('posts.reply_to_board_id', $thread->board_id); | |
| $query->orWhere('posts.board_id', $thread->board_id); | |
| }) | |
| // Nab posts that've been updated since our sinceTime. | |
| ->where(function($query) use ($sinceTime) { | |
| $query->where('posts.updated_at', '>', $sinceTime); | |
| $query->orWhere('posts.deleted_at', '>', $sinceTime); | |
| }) | |
| // Include deleted posts. | |
| ->withTrashed() | |
| // Order by board id in reverse order (so they appear in the thread right). | |
| ->orderBy('posts.board_id', 'asc'); | |
| } | |
| /** | |
| *Renders a single post. | |
| * | |
| * @return string HTML | |
| */ | |
| public function toHTML($catalog, $multiboard, $preview) | |
| { | |
| $rememberTags = [ | |
| "board.{$this->board->board_uri}", | |
| "post_{$this->post_id}", | |
| "post_html", | |
| ]; | |
| $rememberTimer = 30; | |
| $rememberKey = "board.{$this->board->board_uri}.post_html.{$this->board_id}"; | |
| $rememberClosure = function() use ($catalog, $multiboard, $preview) { | |
| $this->setRelation('attachments', $this->attachments->reverse()); | |
| return \View::make('content.board.post', [ | |
| // Models | |
| 'board' => $this->board, | |
| 'post' => $this, | |
| 'user' => user(), | |
| // Statuses | |
| 'catalog' => $catalog, | |
| 'reply_to' => $this->reply_to ?: false, | |
| 'multiboard' => $multiboard, | |
| 'preview' => $preview, | |
| ])->render(); | |
| }; | |
| if (!user()->isAnonymous()) | |
| { | |
| return $rememberClosure(); | |
| } | |
| if ($catalog) | |
| { | |
| $rememberTags[] = "catalog_post"; | |
| $rememberTimer += 30; | |
| } | |
| if ($multiboard) | |
| { | |
| $rememberTags[] = "multiboard_post"; | |
| $rememberTimer -= 20; | |
| } | |
| if ($preview) | |
| { | |
| $rememberTags[] = "preview_post"; | |
| $rememberTimer -= 20; | |
| } | |
| switch (env('CACHE_DRIVER')) | |
| { | |
| case "file" : | |
| case "database" : | |
| break; | |
| default : | |
| return Cache::tags($rememberTags) | |
| ->remember($rememberKey, $rememberTimer, $rememberClosure); | |
| } | |
| return $rememberClosure(); | |
| } | |
| /** | |
| * Fetches a URL for either this thread or an action. | |
| * | |
| * @param string $action | |
| * @return string | |
| */ | |
| public function url($action = null) | |
| { | |
| $url = ""; | |
| if (is_null($action)) | |
| { | |
| if ($this->reply_to_board_id) | |
| { | |
| $url = "/{$this->board_uri}/thread/{$this->reply_to_board_id}#{$this->board_id}"; | |
| } | |
| else | |
| { | |
| $url = "/{$this->board_uri}/thread/{$this->board_id}"; | |
| } | |
| } | |
| else | |
| { | |
| $url = "/{$this->board_uri}/post/{$this->board_id}/{$action}"; | |
| } | |
| return $url; | |
| } | |
| /** | |
| * Fetches a URL for JSON requests that will update this thread or post. | |
| * | |
| * @param boolean $thread If set to FALSE, will only provide a URl for single post (no reply) updates. | |
| * @return string | |
| */ | |
| public function urlJson($thread = true) | |
| { | |
| $url = ""; | |
| if ($thread) | |
| { | |
| if ($this->reply_to_board_id) | |
| { | |
| $url = "/{$this->board_uri}/thread/{$this->reply_to_board_id}.json"; | |
| } | |
| else | |
| { | |
| $url = "/{$this->board_uri}/thread/{$this->board_id}.json"; | |
| } | |
| } | |
| else | |
| { | |
| $url = "/{$this->board_uri}/post/{$this->board_id}.json"; | |
| } | |
| return $url; | |
| } | |
| /** | |
| * Fetches a URL for this post, with the reply-to hash. | |
| * | |
| * @return string | |
| */ | |
| public function urlReply() | |
| { | |
| $url = ""; | |
| if ($this->reply_to_board_id) | |
| { | |
| $url = "/{$this->board_uri}/thread/{$this->reply_to_board_id}#reply-{$this->board_id}"; | |
| } | |
| else | |
| { | |
| $url = "/{$this->board_uri}/thread/{$this->board_id}#reply-{$this->board_id}"; | |
| } | |
| return $url; | |
| } | |
| /** | |
| * Sends a redirect to the post's page. | |
| * | |
| * @param string $action | |
| * @return Response | |
| */ | |
| public function redirect($action = null) | |
| { | |
| return redirect($this->url($action)); | |
| } | |
| /** | |
| * Pushes the post to the specified board, as a new thread or as a reply. | |
| * This autoatically handles concurrency issues. Creating a new reply without | |
| * using this method is forbidden by the `creating` event in ::boot. | |
| * | |
| * | |
| * @param App\Board &$board | |
| * @param App\Post &$thread | |
| * @return void | |
| */ | |
| public function submitTo(Board &$board, &$thread = null) | |
| { | |
| $this->board_uri = $board->board_uri; | |
| $this->author_ip = new IP; | |
| $this->author_country = $board->getConfig('postsAuthorCountry', false) ? new Geolocation() : null; | |
| $this->reply_last = $this->freshTimestamp(); | |
| $this->bumped_last = $this->reply_last; | |
| $this->setCreatedAt($this->reply_last); | |
| $this->setUpdatedAt($this->reply_last); | |
| if (!is_null($thread) && !($thread instanceof Post)) | |
| { | |
| $thread = $board->getLocalThread($thread); | |
| } | |
| if (Cache::has('posting_now_' . $this->author_ip->toLong())) | |
| { | |
| return abort(429); | |
| } | |
| // Cache what time we're submitting our post for flood checks. | |
| Cache::put('posting_now_' . $this->author_ip->toLong(), true, 1); | |
| Cache::put('last_post_for_' . $this->author_ip->toLong(), $this->created_at->timestamp, 60); | |
| if ($thread instanceof Post) | |
| { | |
| $this->reply_to = $thread->post_id; | |
| $this->reply_to_board_id = $thread->board_id; | |
| Cache::put('last_thread_for_' . $this->author_ip->toLong(), $this->created_at->timestamp, 60); | |
| } | |
| // Handle tripcode, if any. | |
| if (preg_match('/^([^#]+)?(##|#)(.+)$/', $this->author, $match)) | |
| { | |
| // Remove password from name. | |
| $this->author = $match[1]; | |
| // Whether a secure tripcode was requested, currently unused. | |
| $secure_tripcode_requested = ($match[2] == '##'); | |
| // Convert password to tripcode, store tripcode hash in DB. | |
| $this->insecure_tripcode = ContentFormatter::formatInsecureTripcode($match[3]); | |
| } | |
| // Ensure we're using a valid flag. | |
| if (!$this->flag_id || !$board->hasFlag($this->flag_id)) | |
| { | |
| $this->flag_id = null; | |
| } | |
| // Store the post in the database. | |
| DB::transaction(function() use ($board, $thread) | |
| { | |
| // The objective of this transaction is to prevent concurrency issues in the database | |
| // on the unique joint index [`board_uri`,`board_id`] which is generated procedurally | |
| // alongside the primary autoincrement column `post_id`. | |
| // First instruction is to add +1 to posts_total and set the last_post_at on the Board table. | |
| DB::table('boards') | |
| ->where('board_uri', $this->board_uri) | |
| ->increment('posts_total', 1, [ | |
| 'last_post_at' => $this->reply_last, | |
| ]); | |
| // Second, we record this value and lock the table. | |
| $boards = DB::table('boards') | |
| ->where('board_uri', $this->board_uri) | |
| ->lockForUpdate() | |
| ->select('posts_total') | |
| ->get(); | |
| $posts_total = $boards[0]->posts_total; | |
| // Third, we store a unique checksum for this post for duplicate tracking. | |
| $board->checksums()->create([ | |
| 'checksum' => $this->getChecksum(), | |
| ]); | |
| // Optionally, we also expend the adventure. | |
| $adventure = BoardAdventure::getAdventure($board); | |
| if ($adventure) | |
| { | |
| $this->adventure_id = $adventure->adventure_id; | |
| $adventure->expended_at = $this->created_at; | |
| $adventure->save(); | |
| } | |
| // We set our board_id and save the post. | |
| $this->board_id = $posts_total; | |
| $this->author_id = $this->makeAuthorId(); | |
| $this->password = $this->makePassword($this->password); | |
| $this->save(); | |
| // Optionally, the OP of this thread needs a +1 to reply count. | |
| if ($thread instanceof static) | |
| { | |
| // We're not using the Model for this because it fails under high volume. | |
| $threadNewValues = [ | |
| 'updated_at' => $thread->updated_at, | |
| 'reply_last' => $this->created_at, | |
| 'reply_count' => $thread->replies()->count(), | |
| 'reply_file_count' => $thread->replyFiles()->count(), | |
| ]; | |
| if (!$this->isBumpless() && !$thread->isBumplocked()) | |
| { | |
| $threadNewValues['bumped_last'] = $this->created_at; | |
| } | |
| DB::table('posts') | |
| ->where('post_id', $thread->post_id) | |
| ->update($threadNewValues); | |
| } | |
| // Queries and locks are handled automatically after this closure ends. | |
| }); | |
| // Process uploads. | |
| $uploads = []; | |
| // Check file uploads. | |
| if (is_array($files = Input::file('files'))) | |
| { | |
| $uploads = array_filter($files); | |
| if (count($uploads) > 0) | |
| { | |
| foreach ($uploads as $uploadIndex => $upload) | |
| { | |
| if(file_exists($upload->getPathname())) | |
| { | |
| FileStorage::createAttachmentFromUpload($upload, $this); | |
| } | |
| } | |
| } | |
| } | |
| else if(is_array($files = Input::get('files'))) | |
| { | |
| $uniques = []; | |
| $hashes = $files['hash']; | |
| $names = $files['name']; | |
| $spoilers = isset($files['spoiler']) ? $files['spoiler'] : []; | |
| $storages = FileStorage::whereIn('hash', $hashes)->get(); | |
| foreach ($hashes as $index => $hash) | |
| { | |
| if (!isset($uniques[$hash])) | |
| { | |
| $uniques[$hash] = true; | |
| $storage = $storages->where('hash', $hash)->first(); | |
| if ($storage && !$storage->banned) | |
| { | |
| $spoiler = isset($spoilers[$index]) ? $spoilers[$index] == 1 : false; | |
| $upload = $storage->createAttachmentWithThis($this, $names[$index], $spoiler, false); | |
| $upload->position = $index; | |
| $uploads[] = $upload; | |
| } | |
| } | |
| } | |
| $this->attachmentLinks()->saveMany($uploads); | |
| FileStorage::whereIn('hash', $hashes)->increment('upload_count'); | |
| } | |
| // Finally fire event on OP, if it exists. | |
| if ($thread instanceof Post) | |
| { | |
| $thread->setRelation('board', $board); | |
| Event::fire(new ThreadNewReply($thread)); | |
| } | |
| Cache::forget('posting_now_' . $this->author_ip->toLong()); | |
| return $this; | |
| } | |
| /** | |
| * Returns a thread with its replies for a thread view and leverages cache. | |
| * | |
| * @static | |
| * @param string $board_uri Board primary key. | |
| * @param int $board_id Local board id. | |
| * @param string $uri Optional. URI string for splicing the thread. Defaults to null, for no splicing. | |
| * @return static | |
| */ | |
| public static function getForThreadView($board_uri, $board_id, $uri = null) | |
| { | |
| // Prepare the board so that we do not have to make redundant searches. | |
| $board = null; | |
| if ($board_uri instanceof Board) | |
| { | |
| $board = $board_uri; | |
| $board_uri = $board->board_uri; | |
| } | |
| else | |
| { | |
| $board = Board::find($board_uri); | |
| } | |
| $rememberTags = ["board.{$board_uri}", "threads"]; | |
| $rememberTimer = 30; | |
| $rememberKey = "board.{$board_uri}.thread.{$board_id}"; | |
| $rememberClosure = function() use ($board, $board_uri, $board_id) { | |
| $thread = static::where([ | |
| 'posts.board_uri' => $board_uri, | |
| 'posts.board_id' => $board_id, | |
| ])->withEverythingAndReplies()->first(); | |
| if ($thread) | |
| { | |
| $thread->setRelation('attachments', $thread->attachments->reverse()); | |
| $thread->prepareForCache(); | |
| } | |
| return $thread; | |
| }; | |
| switch (env('CACHE_DRIVER')) | |
| { | |
| case "file" : | |
| case "database" : | |
| $thread = Cache::remember($rememberKey, $rememberTimer, $rememberClosure); | |
| break; | |
| default : | |
| $thread = Cache::tags($rememberTags)->remember($rememberKey, $rememberTimer, $rememberClosure); | |
| break; | |
| } | |
| if (!is_null($uri)) | |
| { | |
| return $thread->getReplySplice($uri); | |
| } | |
| return $thread; | |
| } | |
| } |