diff --git a/app/Http/Controllers/StatusEditController.php b/app/Http/Controllers/StatusEditController.php new file mode 100644 index 000000000..1d0a22396 --- /dev/null +++ b/app/Http/Controllers/StatusEditController.php @@ -0,0 +1,59 @@ +middleware('auth'); + abort_if(!config('exp.pue'), 404, 'Post editing is not enabled on this server.'); + } + + public function store(StoreStatusEditRequest $request, $id) + { + $validated = $request->validated(); + + $status = Status::findOrFail($id); + abort_if(StatusEdit::whereStatusId($status->id)->count() >= 10, 400, 'You cannot edit your post more than 10 times.'); + $res = UpdateStatusService::call($status, $validated); + + $status = Status::findOrFail($id); + StatusLocalUpdateActivityPubDeliverPipeline::dispatch($status)->delay(now()->addMinutes(1)); + return $res; + } + + public function history(Request $request, $id) + { + abort_if(!$request->user(), 403); + $status = Status::whereNull('reblog_of_id')->findOrFail($id); + abort_if(!in_array($status->scope, ['public', 'unlisted']), 403); + if(!$status->edits()->count()) { + return []; + } + $cached = StatusService::get($status->id, false); + + $res = $status->edits->map(function($edit) use($cached) { + return [ + 'content' => Autolink::create()->autolink($edit->caption), + 'spoiler_text' => $edit->spoiler_text, + 'sensitive' => (bool) $edit->is_nsfw, + 'created_at' => str_replace('+00:00', 'Z', $edit->created_at->format(DATE_RFC3339_EXTENDED)), + 'account' => $cached['account'], + 'media_attachments' => $cached['media_attachments'], + 'emojis' => $cached['emojis'], + ]; + })->reverse()->values()->toArray(); + return $res; + } +} diff --git a/app/Http/Requests/Status/StoreStatusEditRequest.php b/app/Http/Requests/Status/StoreStatusEditRequest.php new file mode 100644 index 000000000..aa9364ca6 --- /dev/null +++ b/app/Http/Requests/Status/StoreStatusEditRequest.php @@ -0,0 +1,69 @@ +user()->profile; + if($profile->status != null) { + return false; + } + if($profile->unlisted == true && $profile->cw == true) { + return false; + } + $types = [ + "photo", + "photo:album", + "photo:video:album", + "reply", + "text", + "video", + "video:album" + ]; + $scopes = ['public', 'unlisted', 'private']; + $status = Status::whereNull('reblog_of_id')->whereIn('type', $types)->whereIn('scope', $scopes)->find($this->route('id')); + return $status && $this->user()->profile_id === $status->profile_id; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules(): array + { + return [ + 'status' => 'sometimes|max:'.config('pixelfed.max_caption_length', 500), + 'spoiler_text' => 'nullable|string|max:140', + 'sensitive' => 'sometimes|boolean', + 'media_ids' => [ + 'nullable', + 'required_without:status', + 'array', + 'max:' . config('pixelfed.max_album_length'), + function (string $attribute, mixed $value, Closure $fail) { + Media::whereProfileId($this->user()->profile_id) + ->where(function($query) { + return $query->whereNull('status_id') + ->orWhere('status_id', '=', $this->route('id')); + }) + ->findOrFail($value); + }, + ], + 'location' => 'sometimes|nullable', + 'location.id' => 'sometimes|integer|min:1|max:128769', + 'location.country' => 'required_with:location.id', + 'location.name' => 'required_with:location.id', + ]; + } +} diff --git a/app/Jobs/StatusPipeline/StatusLocalUpdateActivityPubDeliverPipeline.php b/app/Jobs/StatusPipeline/StatusLocalUpdateActivityPubDeliverPipeline.php new file mode 100644 index 000000000..745f5f5ff --- /dev/null +++ b/app/Jobs/StatusPipeline/StatusLocalUpdateActivityPubDeliverPipeline.php @@ -0,0 +1,129 @@ +status = $status; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $status = $this->status; + $profile = $status->profile; + + // ignore group posts + // if($status->group_id != null) { + // return; + // } + + if($status->local == false || $status->url || $status->uri) { + return; + } + + $audience = $status->profile->getAudienceInbox(); + + if(empty($audience) || !in_array($status->scope, ['public', 'unlisted', 'private'])) { + // Return on profiles with no remote followers + return; + } + + switch($status->type) { + case 'poll': + // Polls not yet supported + return; + break; + + default: + $activitypubObject = new UpdateNote(); + break; + } + + + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Item($status, $activitypubObject); + $activity = $fractal->createData($resource)->toArray(); + + $payload = json_encode($activity); + + $client = new Client([ + 'timeout' => config('federation.activitypub.delivery.timeout') + ]); + + $version = config('pixelfed.version'); + $appUrl = config('app.url'); + $userAgent = "(Pixelfed/{$version}; +{$appUrl})"; + + $requests = function($audience) use ($client, $activity, $profile, $payload, $userAgent) { + foreach($audience as $url) { + $headers = HttpSignature::sign($profile, $url, $activity, [ + 'Content-Type' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + 'User-Agent' => $userAgent, + ]); + yield function() use ($client, $url, $headers, $payload) { + return $client->postAsync($url, [ + 'curl' => [ + CURLOPT_HTTPHEADER => $headers, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HEADER => true, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false + ] + ]); + }; + } + }; + + $pool = new Pool($client, $requests($audience), [ + 'concurrency' => config('federation.activitypub.delivery.concurrency'), + 'fulfilled' => function ($response, $index) { + }, + 'rejected' => function ($reason, $index) { + } + ]); + + $promise = $pool->promise(); + + $promise->wait(); + } +} diff --git a/app/Models/StatusEdit.php b/app/Models/StatusEdit.php new file mode 100644 index 000000000..5c9ec5695 --- /dev/null +++ b/app/Models/StatusEdit.php @@ -0,0 +1,19 @@ + 'array', + 'media_descriptions' => 'array', + 'poll_options' => 'array' + ]; + + protected $guarded = []; +} diff --git a/app/Services/Status/UpdateStatusService.php b/app/Services/Status/UpdateStatusService.php new file mode 100644 index 000000000..be50bf70f --- /dev/null +++ b/app/Services/Status/UpdateStatusService.php @@ -0,0 +1,137 @@ +id); + } + + public static function updateMediaAttachements(Status $status, $attributes) + { + $count = $status->media()->count(); + if($count === 0 || $count === 1) { + return; + } + + $oids = $status->media()->orderBy('order')->pluck('id')->map(function($m) { return (string) $m; }); + $nids = collect($attributes['media_ids']); + + if($oids->toArray() === $nids->toArray()) { + return; + } + + foreach($oids->diff($nids)->values()->toArray() as $mid) { + $media = Media::find($mid); + if(!$media) { + continue; + } + $media->status_id = null; + $media->save(); + MediaStorageService::delete($media, true); + } + + $nids->each(function($nid, $idx) { + $media = Media::find($nid); + if(!$media) { + return; + } + $media->order = $idx; + $media->save(); + }); + MediaService::del($status->id); + } + + public static function handleImmediateAttributes(Status $status, $attributes) + { + if(isset($attributes['status'])) { + $cleaned = Purify::clean($attributes['status']); + $status->caption = $cleaned; + $status->rendered = Autolink::create()->autolink($cleaned); + } else { + $status->caption = null; + $status->rendered = null; + } + if(isset($attributes['sensitive'])) { + if($status->is_nsfw != (bool) $attributes['sensitive'] && + (bool) $attributes['sensitive'] == false) + { + $exists = ModLog::whereObjectType('App\Status::class') + ->whereObjectId($status->id) + ->whereAction('admin.status.moderate') + ->exists(); + if(!$exists) { + $status->is_nsfw = (bool) $attributes['sensitive']; + } + } else { + $status->is_nsfw = (bool) $attributes['sensitive']; + } + } + if(isset($attributes['spoiler_text'])) { + $status->cw_summary = Purify::clean($attributes['spoiler_text']); + } else { + $status->cw_summary = null; + } + if(isset($attributes['location'])) { + if (isset($attributes['location']['id'])) { + $status->place_id = $attributes['location']['id']; + } else { + $status->place_id = null; + } + } + if($status->cw_summary && !$status->is_nsfw) { + $status->cw_summary = null; + } + $status->edited_at = now(); + $status->save(); + StatusService::del($status->id); + } + + public static function createPreviousEdit(Status $status) + { + if(!$status->edits()->count()) { + StatusEdit::create([ + 'status_id' => $status->id, + 'profile_id' => $status->profile_id, + 'caption' => $status->caption, + 'spoiler_text' => $status->cw_summary, + 'is_nsfw' => $status->is_nsfw, + 'ordered_media_attachment_ids' => $status->media()->orderBy('order')->pluck('id')->toArray(), + 'created_at' => $status->created_at + ]); + } + } + + public static function createEdit(Status $status, $attributes) + { + $cleaned = isset($attributes['status']) ? Purify::clean($attributes['status']) : null; + $spoiler_text = isset($attributes['spoiler_text']) ? Purify::clean($attributes['spoiler_text']) : null; + $sensitive = isset($attributes['sensitive']) ? $attributes['sensitive'] : null; + $mids = $status->media()->count() ? $status->media()->orderBy('order')->pluck('id')->toArray() : null; + StatusEdit::create([ + 'status_id' => $status->id, + 'profile_id' => $status->profile_id, + 'caption' => $cleaned, + 'spoiler_text' => $spoiler_text, + 'is_nsfw' => $sensitive, + 'ordered_media_attachment_ids' => $mids + ]); + } +} diff --git a/app/Status.php b/app/Status.php index 183c18023..4148a4f99 100644 --- a/app/Status.php +++ b/app/Status.php @@ -9,6 +9,7 @@ use App\Http\Controllers\StatusController; use Illuminate\Database\Eloquent\SoftDeletes; use App\Models\Poll; use App\Services\AccountService; +use App\Models\StatusEdit; class Status extends Model { @@ -27,7 +28,8 @@ class Status extends Model * @var array */ protected $casts = [ - 'deleted_at' => 'datetime' + 'deleted_at' => 'datetime', + 'edited_at' => 'datetime' ]; protected $guarded = []; @@ -393,4 +395,9 @@ class Status extends Model { return $this->hasOne(Poll::class); } + + public function edits() + { + return $this->hasMany(StatusEdit::class); + } } diff --git a/app/Transformer/ActivityPub/Verb/UpdateNote.php b/app/Transformer/ActivityPub/Verb/UpdateNote.php new file mode 100644 index 000000000..bdbb20c45 --- /dev/null +++ b/app/Transformer/ActivityPub/Verb/UpdateNote.php @@ -0,0 +1,133 @@ +mentions->map(function ($mention) { + $webfinger = $mention->emailUrl(); + $name = Str::startsWith($webfinger, '@') ? + $webfinger : + '@' . $webfinger; + return [ + 'type' => 'Mention', + 'href' => $mention->permalink(), + 'name' => $name + ]; + })->toArray(); + + if($status->in_reply_to_id != null) { + $parent = $status->parent()->profile; + if($parent) { + $webfinger = $parent->emailUrl(); + $name = Str::startsWith($webfinger, '@') ? + $webfinger : + '@' . $webfinger; + $reply = [ + 'type' => 'Mention', + 'href' => $parent->permalink(), + 'name' => $name + ]; + $mentions = array_merge($reply, $mentions); + } + } + + $hashtags = $status->hashtags->map(function ($hashtag) { + return [ + 'type' => 'Hashtag', + 'href' => $hashtag->url(), + 'name' => "#{$hashtag->name}", + ]; + })->toArray(); + + $emojis = CustomEmoji::scan($status->caption, true) ?? []; + $emoji = array_merge($emojis, $mentions); + $tags = array_merge($emoji, $hashtags); + + $latestEdit = $status->edits()->latest()->first(); + + return [ + '@context' => [ + 'https://w3id.org/security/v1', + 'https://www.w3.org/ns/activitystreams', + [ + 'Hashtag' => 'as:Hashtag', + 'sensitive' => 'as:sensitive', + 'schema' => 'http://schema.org/', + 'pixelfed' => 'http://pixelfed.org/ns#', + 'commentsEnabled' => [ + '@id' => 'pixelfed:commentsEnabled', + '@type' => 'schema:Boolean' + ], + 'capabilities' => [ + '@id' => 'pixelfed:capabilities', + '@container' => '@set' + ], + 'announce' => [ + '@id' => 'pixelfed:canAnnounce', + '@type' => '@id' + ], + 'like' => [ + '@id' => 'pixelfed:canLike', + '@type' => '@id' + ], + 'reply' => [ + '@id' => 'pixelfed:canReply', + '@type' => '@id' + ], + 'toot' => 'http://joinmastodon.org/ns#', + 'Emoji' => 'toot:Emoji' + ] + ], + 'id' => $status->permalink('#updates/' . $latestEdit->id), + 'type' => 'Update', + 'actor' => $status->profile->permalink(), + 'published' => $latestEdit->created_at->toAtomString(), + 'to' => $status->scopeToAudience('to'), + 'cc' => $status->scopeToAudience('cc'), + 'object' => [ + 'id' => $status->url(), + 'type' => 'Note', + 'summary' => $status->is_nsfw ? $status->cw_summary : null, + 'content' => $status->rendered ?? $status->caption, + 'inReplyTo' => $status->in_reply_to_id ? $status->parent()->url() : null, + 'published' => $status->created_at->toAtomString(), + 'url' => $status->url(), + 'attributedTo' => $status->profile->permalink(), + 'to' => $status->scopeToAudience('to'), + 'cc' => $status->scopeToAudience('cc'), + 'sensitive' => (bool) $status->is_nsfw, + 'attachment' => $status->media()->orderBy('order')->get()->map(function ($media) { + return [ + 'type' => $media->activityVerb(), + 'mediaType' => $media->mime, + 'url' => $media->url(), + 'name' => $media->caption, + ]; + })->toArray(), + 'tag' => $tags, + 'commentsEnabled' => (bool) !$status->comments_disabled, + 'updated' => $latestEdit->created_at->toAtomString(), + 'capabilities' => [ + 'announce' => 'https://www.w3.org/ns/activitystreams#Public', + 'like' => 'https://www.w3.org/ns/activitystreams#Public', + 'reply' => $status->comments_disabled == true ? '[]' : 'https://www.w3.org/ns/activitystreams#Public' + ], + 'location' => $status->place_id ? [ + 'type' => 'Place', + 'name' => $status->place->name, + 'longitude' => $status->place->long, + 'latitude' => $status->place->lat, + 'country' => $status->place->country + ] : null, + ] + ]; + } +} diff --git a/app/Transformer/Api/StatusStatelessTransformer.php b/app/Transformer/Api/StatusStatelessTransformer.php index 38a3fedf8..acb522446 100644 --- a/app/Transformer/Api/StatusStatelessTransformer.php +++ b/app/Transformer/Api/StatusStatelessTransformer.php @@ -65,7 +65,8 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract 'media_attachments' => MediaService::get($status->id), 'account' => AccountService::get($status->profile_id, true), 'tags' => StatusHashtagService::statusTags($status->id), - 'poll' => $poll + 'poll' => $poll, + 'edited_at' => $status->edited_at ? str_replace('+00:00', 'Z', $status->edited_at->format(DATE_RFC3339_EXTENDED)) : null, ]; } } diff --git a/app/Transformer/Api/StatusTransformer.php b/app/Transformer/Api/StatusTransformer.php index 61c5f875b..f735a57be 100644 --- a/app/Transformer/Api/StatusTransformer.php +++ b/app/Transformer/Api/StatusTransformer.php @@ -70,6 +70,7 @@ class StatusTransformer extends Fractal\TransformerAbstract 'tags' => StatusHashtagService::statusTags($status->id), 'poll' => $poll, 'bookmarked' => BookmarkService::get($pid, $status->id), + 'edited_at' => $status->edited_at ? str_replace('+00:00', 'Z', $status->edited_at->format(DATE_RFC3339_EXTENDED)) : null, ]; } } diff --git a/app/Util/ActivityPub/Inbox.php b/app/Util/ActivityPub/Inbox.php index 790a48e8d..5ac29f916 100644 --- a/app/Util/ActivityPub/Inbox.php +++ b/app/Util/ActivityPub/Inbox.php @@ -28,6 +28,7 @@ use App\Jobs\DeletePipeline\DeleteRemoteProfilePipeline; use App\Jobs\DeletePipeline\DeleteRemoteStatusPipeline; use App\Jobs\StoryPipeline\StoryExpire; use App\Jobs\StoryPipeline\StoryFetch; +use App\Jobs\StatusPipeline\StatusRemoteUpdatePipeline; use App\Util\ActivityPub\Validator\Accept as AcceptValidator; use App\Util\ActivityPub\Validator\Add as AddValidator; @@ -128,9 +129,9 @@ class Inbox $this->handleFlagActivity(); break; - // case 'Update': - // (new UpdateActivity($this->payload, $this->profile))->handle(); - // break; + case 'Update': + $this->handleUpdateActivity(); + break; default: // TODO: decide how to handle invalid verbs. @@ -1207,4 +1208,23 @@ class Inbox return; } + + public function handleUpdateActivity() + { + $activity = $this->payload['object']; + $actor = $this->actorFirstOrCreate($this->payload['actor']); + if(!$actor || $actor->domain == null) { + return; + } + + if(!isset($activity['type'], $activity['id'])) { + return; + } + + if($activity['type'] === 'Note') { + if(Status::whereObjectUrl($activity['id'])->exists()) { + StatusRemoteUpdatePipeline::dispatch($actor, $activity); + } + } + } } diff --git a/config/exp.php b/config/exp.php index 0c9f83706..589572b5e 100644 --- a/config/exp.php +++ b/config/exp.php @@ -25,6 +25,8 @@ return [ // Cached public timeline for larger instances (beta) 'cached_public_timeline' => env('EXP_CPT', false), + 'cached_home_timeline' => env('EXP_CHT', false), + // Groups (unreleased) 'gps' => env('EXP_GPS', false), @@ -33,4 +35,10 @@ return [ // Enforce Mastoapi Compatibility (alpha) 'emc' => env('EXP_EMC', true), + + // HLS Live Streaming + 'hls' => env('HLS_LIVE', false), + + // Post Update/Edits + 'pue' => env('EXP_PUE', false), ]; diff --git a/resources/assets/components/Post.vue b/resources/assets/components/Post.vue index 842b15dd5..1cc57c84e 100644 --- a/resources/assets/components/Post.vue +++ b/resources/assets/components/Post.vue @@ -26,7 +26,7 @@

+ + @@ -119,6 +125,7 @@ import LikesModal from './partials/post/LikeModal.vue'; import SharesModal from './partials/post/ShareModal.vue'; import ReportModal from './partials/modal/ReportPost.vue'; + import PostEditModal from './partials/post/PostEditModal.vue'; export default { props: { @@ -140,7 +147,8 @@ "likes-modal": LikesModal, "shares-modal": SharesModal, "rightbar": Rightbar, - "report-modal": ReportModal + "report-modal": ReportModal, + "post-edit-modal": PostEditModal }, data() { @@ -156,7 +164,8 @@ isReply: false, reply: {}, showSharesModal: false, - postStateError: false + postStateError: false, + forceUpdateIdx: 0 } }, @@ -405,6 +414,17 @@ break; } }, + + handleEdit(status) { + this.$refs.editModal.show(status); + }, + + mergeUpdatedPost(post) { + this.post = post; + this.$nextTick(() => { + this.forceUpdateIdx++; + }); + } } } diff --git a/resources/assets/components/partials/post/EditHistoryModal.vue b/resources/assets/components/partials/post/EditHistoryModal.vue new file mode 100644 index 000000000..9d0a1366d --- /dev/null +++ b/resources/assets/components/partials/post/EditHistoryModal.vue @@ -0,0 +1,233 @@ + + + + + diff --git a/resources/assets/components/partials/post/PostHeader.vue b/resources/assets/components/partials/post/PostHeader.vue new file mode 100644 index 000000000..ddbbf740c --- /dev/null +++ b/resources/assets/components/partials/post/PostHeader.vue @@ -0,0 +1,348 @@ + + + diff --git a/resources/assets/sass/admin.scss b/resources/assets/sass/admin.scss index 93c513f82..cb01e4c69 100644 --- a/resources/assets/sass/admin.scss +++ b/resources/assets/sass/admin.scss @@ -13,3 +13,23 @@ body, button, input, textarea { font-size: 30px; } } + +.nav-pills .nav-item { + padding-right: 1rem; +} + +.list-fade-bottom { + position: relative; + + &:after { + content: ""; + position: absolute; + z-index: 1; + bottom: 0; + left: 0; + pointer-events: none; + background-image: linear-gradient(to bottom, rgba(255,255,255, 0), rgba(255,255,255, 1) 90%); + width: 100%; + height: 10em; + } +} diff --git a/resources/assets/sass/spa.scss b/resources/assets/sass/spa.scss index 3fea3e7e2..e2ed0e054 100644 --- a/resources/assets/sass/spa.scss +++ b/resources/assets/sass/spa.scss @@ -210,6 +210,7 @@ a.text-dark:hover { .autocomplete-result-list { background: var(--light) !important; + z-index: 2 !important; } .dropdown-menu, @@ -261,7 +262,8 @@ span.twitter-typeahead .tt-suggestion:focus { border-color: var(--border-color) !important; } -.modal-header { +.modal-header, +.modal-footer { border-color: var(--border-color); } @@ -328,3 +330,66 @@ span.twitter-typeahead .tt-suggestion:focus { } } } + +.compose-modal-component { + .form-control:focus { + color: var(--body-color); + } +} + +.modal-body { + .nav-tabs .nav-link.active, + .nav-tabs .nav-item.show .nav-link { + background-color: transparent; + border-color: var(--border-color); + } + + .nav-tabs .nav-link:hover, + .nav-tabs .nav-link:focus { + border-color: var(--border-color); + } + + .form-control:focus { + color: var(--body-color); + } +} + +.tribute-container { + border: 0; + + ul { + margin-top: 0; + border-color: var(--border-color); + } + + li { + padding: 0.5rem 1rem; + border-top: 0; + border-left: 0; + border-right: 0; + font-size: 13px; + + &:not(:last-child) { + border-bottom: 1px solid var(--border-color); + } + + &.highlight, + &:hover { + color: var(--body-color); + font-weight: bold; + background: rgba(44, 120, 191, 0.25); + } + } +} + +.timeline-status-component { + .username { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + margin-bottom: -3px; + word-break: break-word; + + @media (min-width: 768px) { + font-size: 17px; + } + } +} diff --git a/routes/api.php b/routes/api.php index 7e996df1d..a0b8b5155 100644 --- a/routes/api.php +++ b/routes/api.php @@ -94,6 +94,9 @@ Route::group(['prefix' => 'api'], function() use($middleware) { Route::post('tags/{id}/follow', 'Api\ApiV1Controller@followHashtag')->middleware($middleware); Route::post('tags/{id}/unfollow', 'Api\ApiV1Controller@unfollowHashtag')->middleware($middleware); Route::get('tags/{id}', 'Api\ApiV1Controller@getHashtag')->middleware($middleware); + + Route::get('statuses/{id}/history', 'StatusEditController@history')->middleware($middleware); + Route::put('statuses/{id}', 'StatusEditController@store')->middleware($middleware); }); Route::group(['prefix' => 'v2'], function() use($middleware) {