diff --git a/app/Http/Controllers/Api/ApiV1Controller.php b/app/Http/Controllers/Api/ApiV1Controller.php index b4a7a0781..cae519a2c 100644 --- a/app/Http/Controllers/Api/ApiV1Controller.php +++ b/app/Http/Controllers/Api/ApiV1Controller.php @@ -10,7 +10,9 @@ use App\Util\Media\Filter; use Laravel\Passport\Passport; use Auth, Cache, DB, URL; use App\{ + Avatar, Bookmark, + DirectMessage, Follower, FollowRequest, Hashtag, @@ -38,6 +40,8 @@ use App\Http\Controllers\FollowerController; use League\Fractal\Serializer\ArraySerializer; use League\Fractal\Pagination\IlluminatePaginatorAdapter; use App\Http\Controllers\StatusController; + +use App\Jobs\AvatarPipeline\AvatarOptimize; use App\Jobs\LikePipeline\LikePipeline; use App\Jobs\SharePipeline\SharePipeline; use App\Jobs\StatusPipeline\NewStatusPipeline; @@ -49,8 +53,11 @@ use App\Jobs\VideoPipeline\{ VideoPostProcess, VideoThumbnail }; + use App\Services\{ + AccountService, LikeService, + InstanceService, NotificationService, MediaPathService, PublicTimelineService, @@ -59,9 +66,13 @@ use App\Services\{ SearchApiV2Service, StatusService, MediaBlocklistService, + SnowflakeService, UserFilterService }; use App\Util\Lexer\Autolink; +use App\Util\Localization\Localization; +use App\Util\Media\License; +use App\Jobs\MediaPipeline\MediaSyncLicensePipeline; class ApiV1Controller extends Controller { @@ -166,47 +177,222 @@ class ApiV1Controller extends Controller abort_if(!$request->user(), 403); $this->validate($request, [ + 'avatar' => 'sometimes|mimetypes:image/jpeg,image/png', 'display_name' => 'nullable|string', 'note' => 'nullable|string', 'locked' => 'nullable', + 'website' => 'nullable', // 'source.privacy' => 'nullable|in:unlisted,public,private', // 'source.sensitive' => 'nullable|boolean' ]); $user = $request->user(); $profile = $user->profile; - - $displayName = $request->input('display_name'); - $note = $request->input('note'); - $locked = $request->input('locked'); - // $privacy = $request->input('source.privacy'); - // $sensitive = $request->input('source.sensitive'); + $settings = $user->settings; $changes = false; + $other = array_merge(AccountService::defaultSettings()['other'], $settings->other ?? []); + $syncLicenses = false; + $licenseChanged = false; + $composeSettings = array_merge(AccountService::defaultSettings()['compose_settings'], $settings->compose_settings ?? []); - if($displayName !== $user->name) { - $user->name = $displayName; - $profile->name = $displayName; + // return $request->input('locked'); + + if($request->has('avatar')) { + $av = Avatar::whereProfileId($profile->id)->first(); + if($av) { + $currentAvatar = storage_path('app/'.$av->media_path); + $file = $request->file('avatar'); + $path = "public/avatars/{$profile->id}"; + $name = strtolower(str_random(6)). '.' . $file->guessExtension(); + $request->file('avatar')->storeAs($path, $name); + $av->media_path = "{$path}/{$name}"; + $av->save(); + Cache::forget("avatar:{$profile->id}"); + Cache::forget('user:account:id:'.$user->id); + AvatarOptimize::dispatch($user->profile, $currentAvatar); + } $changes = true; } - if($note !== strip_tags($profile->bio)) { - $profile->bio = Autolink::create()->autolink(strip_tags($note)); - $changes = true; + if($request->has('source[language]')) { + $lang = $request->input('source[language]'); + if(in_array($lang, Localization::languages())) { + $user->language = $lang; + $changes = true; + $other['language'] = $lang; + } } - if(!is_null($locked)) { - $profile->is_private = $locked; - $changes = true; + if($request->has('website')) { + $website = $request->input('website'); + if($website != $profile->website) { + if($website) { + if(!strpos($website, '.')) { + $website = null; + } + + if($website && !strpos($website, '://')) { + $website = 'https://' . $website; + } + + $host = parse_url($website, PHP_URL_HOST); + + $bannedInstances = InstanceService::getBannedDomains(); + if(in_array($host, $bannedInstances)) { + $website = null; + } + } + $profile->website = $website ? $website : null; + $changes = true; + } + } + + if($request->has('display_name')) { + $displayName = $request->input('display_name'); + if($displayName !== $user->name) { + $user->name = $displayName; + $profile->name = $displayName; + $changes = true; + } + } + + if($request->has('note')) { + $note = $request->input('note'); + if($note !== strip_tags($profile->bio)) { + $profile->bio = Autolink::create()->autolink(strip_tags($note)); + $changes = true; + } + } + + if($request->has('locked')) { + $locked = $request->input('locked') == 'true'; + if($profile->is_private != $locked) { + $profile->is_private = $locked; + $changes = true; + } + } + + if($request->has('reduce_motion')) { + $reduced = $request->input('reduce_motion'); + if($settings->reduce_motion != $reduced) { + $settings->reduce_motion = $reduced; + $changes = true; + } + } + + if($request->has('high_contrast_mode')) { + $contrast = $request->input('high_contrast_mode'); + if($settings->high_contrast_mode != $contrast) { + $settings->high_contrast_mode = $contrast; + $changes = true; + } + } + + if($request->has('video_autoplay')) { + $autoplay = $request->input('video_autoplay'); + if($settings->video_autoplay != $autoplay) { + $settings->video_autoplay = $autoplay; + $changes = true; + } + } + + if($request->has('license')) { + $license = $request->input('license'); + abort_if(!in_array($license, License::keys()), 422, 'Invalid media license id'); + $syncLicenses = $request->input('sync_licenses') == true; + abort_if($syncLicenses && Cache::get('pf:settings:mls_recently:'.$user->id) == 2, 422, 'You can only sync licenses twice per 24 hours'); + if($composeSettings['default_license'] != $license) { + $composeSettings['default_license'] = $license; + $licenseChanged = true; + $changes = true; + } + } + + if($request->has('media_descriptions')) { + $md = $request->input('media_descriptions') == true; + if($composeSettings['media_descriptions'] != $md) { + $composeSettings['media_descriptions'] = $md; + $changes = true; + } + } + + if($request->has('crawlable')) { + $crawlable = $request->input('crawlable'); + if($settings->crawlable != $crawlable) { + $settings->crawlable = $crawlable; + $changes = true; + } + } + + if($request->has('show_profile_follower_count')) { + $show_profile_follower_count = $request->input('show_profile_follower_count'); + if($settings->show_profile_follower_count != $show_profile_follower_count) { + $settings->show_profile_follower_count = $show_profile_follower_count; + $changes = true; + } + } + + if($request->has('show_profile_following_count')) { + $show_profile_following_count = $request->input('show_profile_following_count'); + if($settings->show_profile_following_count != $show_profile_following_count) { + $settings->show_profile_following_count = $show_profile_following_count; + $changes = true; + } + } + + if($request->has('public_dm')) { + $public_dm = $request->input('public_dm'); + if($settings->public_dm != $public_dm) { + $settings->public_dm = $public_dm; + $changes = true; + } + } + + if($request->has('source[privacy]')) { + $scope = $request->input('source[privacy]'); + if(in_array($scope, ['public', 'private', 'unlisted'])) { + if($composeSettings['default_scope'] != $scope) { + $composeSettings['default_scope'] = $profile->is_private ? 'private' : $scope; + $changes = true; + } + } + } + + if($request->has('disable_embeds')) { + $disabledEmbeds = $request->input('disable_embeds'); + if($other['disable_embeds'] != $disabledEmbeds) { + $other['disable_embeds'] = $disabledEmbeds; + $changes = true; + } } if($changes) { + $settings->other = $other; + $settings->compose_settings = $composeSettings; + $settings->save(); $user->save(); $profile->save(); + Cache::forget('profile:settings:' . $profile->id); + Cache::forget('user:account:id:' . $profile->user_id); + Cache::forget('profile:follower_count:' . $profile->id); + Cache::forget('profile:following_count:' . $profile->id); + Cache::forget('profile:embed:' . $profile->id); + Cache::forget('profile:compose:settings:' . $user->id); + Cache::forget('profile:view:'.$user->username); + AccountService::del($user->profile_id); } - $resource = new Fractal\Resource\Item($profile, new AccountTransformer()); - $res = $this->fractal->createData($resource)->toArray(); + if($syncLicenses && $licenseChanged) { + $key = 'pf:settings:mls_recently:'.$user->id; + $val = Cache::has($key) ? 2 : 1; + Cache::put($key, $val, 86400); + MediaSyncLicensePipeline::dispatch($user->id, $request->input('license')); + } + + $res = AccountService::get($user->profile_id); + $res['bio'] = strip_tags($res['note']); + $res = array_merge($res, $other); return response()->json($res); } @@ -305,9 +491,11 @@ class ApiV1Controller extends Controller public function accountStatusesById(Request $request, $id) { abort_if(!$request->user(), 403); + $user = $request->user(); $this->validate($request, [ 'only_media' => 'nullable', + 'media_type' => 'sometimes|string|in:photo,video', 'pinned' => 'nullable', 'exclude_replies' => 'nullable', 'max_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX, @@ -316,7 +504,8 @@ class ApiV1Controller extends Controller 'limit' => 'nullable|integer|min:1|max:80' ]); - $profile = Profile::whereNull('status')->findOrFail($id); + $profile = AccountService::get($id); + abort_if(!$profile, 404); $limit = $request->limit ?? 20; $max_id = $request->max_id; @@ -326,77 +515,56 @@ class ApiV1Controller extends Controller ['photo', 'photo:album', 'video', 'video:album'] : ['photo', 'photo:album', 'video', 'video:album', 'share', 'reply']; - if($pid == $profile->id) { + if($request->only_media && $request->has('media_type')) { + $mt = $request->input('media_type'); + if($mt == 'video') { + $scope = ['video', 'video:album']; + } + } + + if($pid == $profile['id']) { $visibility = ['public', 'unlisted', 'private']; - } else if($profile->is_private) { + } else if($profile['locked']) { $following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function() use($pid) { $following = Follower::whereProfileId($pid)->pluck('following_id'); return $following->push($pid)->toArray(); }); - $visibility = true == in_array($profile->id, $following) ? ['public', 'unlisted', 'private'] : []; + $visibility = true == in_array($profile['id'], $following) ? ['public', 'unlisted', 'private'] : []; } else { $following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function() use($pid) { $following = Follower::whereProfileId($pid)->pluck('following_id'); return $following->push($pid)->toArray(); }); - $visibility = true == in_array($profile->id, $following) ? ['public', 'unlisted', 'private'] : ['public', 'unlisted']; + $visibility = true == in_array($profile['id'], $following) ? ['public', 'unlisted', 'private'] : ['public', 'unlisted']; } - if($min_id || $max_id) { - $dir = $min_id ? '>' : '<'; - $id = $min_id ?? $max_id; - $timeline = Status::select( - 'id', - 'uri', - 'caption', - 'rendered', - 'profile_id', - 'type', - 'in_reply_to_id', - 'reblog_of_id', - 'is_nsfw', - 'scope', - 'local', - 'place_id', - 'likes_count', - 'reblogs_count', - 'created_at', - 'updated_at' - )->whereProfileId($profile->id) - ->whereIn('type', $scope) - ->where('id', $dir, $id) - ->whereIn('visibility', $visibility) - ->latest() - ->limit($limit) - ->get(); - } else { - $timeline = Status::select( - 'id', - 'uri', - 'caption', - 'rendered', - 'profile_id', - 'type', - 'in_reply_to_id', - 'reblog_of_id', - 'is_nsfw', - 'scope', - 'local', - 'place_id', - 'likes_count', - 'reblogs_count', - 'created_at', - 'updated_at' - )->whereProfileId($profile->id) - ->whereIn('type', $scope) - ->whereIn('visibility', $visibility) - ->latest() - ->limit($limit) - ->get(); - } + $dir = $min_id ? '>' : '<'; + $id = $min_id ?? $max_id; + $res = Status::whereProfileId($profile['id']) + ->whereNull('in_reply_to_id') + ->whereNull('reblog_of_id') + ->whereIn('type', $scope) + ->where('id', $dir, $id) + ->whereIn('scope', $visibility) + ->limit($limit) + ->orderByDesc('id') + ->get() + ->map(function($s) use($user) { + try { + $status = StatusService::get($s->id, false); + } catch (\Exception $e) { + $status = false; + } + if($user && $status) { + $status['favourited'] = (bool) LikeService::liked($user->profile_id, $s->id); + } + return $status; + }) + ->filter(function($s) { + return $s; + }) + ->values(); - $resource = new Fractal\Resource\Collection($timeline, new StatusTransformer()); - $res = $this->fractal->createData($resource)->toArray(); return response()->json($res); } @@ -417,6 +585,7 @@ class ApiV1Controller extends Controller ->whereNull('status') ->findOrFail($id); + $private = (bool) $target->is_private; $remote = (bool) $target->domain; $blocked = UserFilter::whereUserId($target->id) @@ -435,9 +604,7 @@ class ApiV1Controller extends Controller // Following already, return empty relationship if($isFollowing == true) { - $resource = new Fractal\Resource\Item($target, new RelationshipTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - + $res = RelationshipService::get($user->profile_id, $target->id) ?? []; return response()->json($res); } @@ -471,6 +638,7 @@ class ApiV1Controller extends Controller FollowPipeline::dispatch($follower); } + RelationshipService::refresh($user->profile_id, $target->id); Cache::forget('profile:following:'.$target->id); Cache::forget('profile:followers:'.$target->id); Cache::forget('profile:following:'.$user->profile_id); @@ -483,8 +651,7 @@ class ApiV1Controller extends Controller Cache::forget('profile:following_count:'.$target->id); Cache::forget('profile:following_count:'.$user->profile_id); - $resource = new Fractal\Resource\Item($target, new RelationshipTransformer()); - $res = $this->fractal->createData($resource)->toArray(); + $res = RelationshipService::get($user->profile_id, $target->id); return response()->json($res); } @@ -506,6 +673,8 @@ class ApiV1Controller extends Controller ->whereNull('status') ->findOrFail($id); + RelationshipService::refresh($user->profile_id, $target->id); + $private = (bool) $target->is_private; $remote = (bool) $target->domain; @@ -770,19 +939,53 @@ class ApiV1Controller extends Controller public function accountFavourites(Request $request) { abort_if(!$request->user(), 403); + $this->validate($request, [ + 'limit' => 'sometimes|integer|min:1|max:20' + ]); $user = $request->user(); + $maxId = $request->input('max_id'); + $minId = $request->input('min_id'); + $limit = $request->input('limit') ?? 10; - $limit = $request->input('limit') ?? 20; - $favourites = Like::whereProfileId($user->profile_id) - ->latest() - ->simplePaginate($limit) - ->pluck('status_id'); + $res = Like::whereProfileId($user->profile_id) + ->when($maxId, function($q, $maxId) { + return $q->where('id', '<', $maxId); + }) + ->when($minId, function($q, $minId) { + return $q->where('id', '>', $minId); + }) + ->orderByDesc('id') + ->limit($limit) + ->get() + ->map(function($like) { + $status = StatusService::get($like['status_id'], false); + $status['like_id'] = $like->id; + $status['liked_at'] = $like->created_at->format('c'); + return $status; + }) + ->filter(function($status) { + return $status && isset($status['id'], $status['like_id']); + }) + ->values(); - $statuses = Status::findOrFail($favourites); - $resource = new Fractal\Resource\Collection($statuses, new StatusTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - return response()->json($res); + if($res->count()) { + $ids = $res->map(function($status) { + return $status['like_id']; + }); + $max = $ids->max(); + $min = $ids->min(); + + $baseUrl = config('app.url') . '/api/v1/favourites?limit=' . $limit . '&'; + $link = '<'.$baseUrl.'max_id='.$max.'>; rel="next",<'.$baseUrl.'min_id='.$min.'>; rel="prev"'; + return response() + ->json($res) + ->withHeaders([ + 'Link' => $link, + ]); + } else { + return response()->json($res); + } } /** @@ -1098,6 +1301,17 @@ class ApiV1Controller extends Controller $path = $photo->store($storagePath); $hash = \hash_file('sha256', $photo); $license = null; + $mime = $photo->getMimeType(); + + // if($photo->getMimeType() == 'image/heic') { + // abort_if(config('image.driver') !== 'imagick', 422, 'Invalid media type'); + // abort_if(!in_array('HEIC', \Imagick::queryformats()), 422, 'Unsupported media type'); + // $oldPath = $path; + // $path = str_replace('.heic', '.jpg', $path); + // $mime = 'image/jpeg'; + // \Image::make($photo)->save(storage_path("app/{$path}")); + // @unlink(storage_path("app/{$oldPath}")); + // } $settings = UserSetting::whereUserId($user->id)->first(); @@ -1118,7 +1332,7 @@ class ApiV1Controller extends Controller $media->media_path = $path; $media->original_sha256 = $hash; $media->size = $photo->getSize(); - $media->mime = $photo->getMimeType(); + $media->mime = $mime; $media->caption = $request->input('description'); $media->filter_class = $filterClass; $media->filter_name = $filterName; @@ -1327,7 +1541,7 @@ class ApiV1Controller extends Controller NotificationService::warmCache($pid, 400, true); } - $baseUrl = config('app.url') . '/api/v1/notifications?'; + $baseUrl = config('app.url') . '/api/v1/notifications?limit=' . $limit . '&'; if($minId == $maxId) { $minId = null; @@ -1469,8 +1683,47 @@ class ApiV1Controller extends Controller public function conversations(Request $request) { abort_if(!$request->user(), 403); + $this->validate($request, [ + 'limit' => 'min:1|max:40', + 'scope' => 'nullable|in:inbox,sent,requests' + ]); - return response()->json([]); + $limit = $request->input('limit', 20); + $scope = $request->input('scope', 'inbox'); + $pid = $request->user()->profile_id; + + $dms = DirectMessage::when($scope === 'inbox', function($q, $scope) use($pid) { + return $q->whereIsHidden(false)->whereToId($pid)->orWhere('from_id', $pid)->groupBy('to_id'); + }) + ->when($scope === 'sent', function($q, $scope) use($pid) { + return $q->whereFromId($pid)->groupBy('to_id'); + }) + ->when($scope === 'requests', function($q, $scope) use($pid) { + return $q->whereToId($pid)->whereIsHidden(true); + }) + ->latest() + ->simplePaginate($limit) + ->map(function($dm) use($pid) { + $from = $pid == $dm->to_id ? $dm->from_id : $dm->to_id; + $res = [ + 'id' => $dm->id, + 'unread' => false, + 'accounts' => [ + AccountService::get($from) + ], + 'last_status' => StatusService::getDirectMessage($dm->status_id) + ]; + return $res; + }) + ->filter(function($dm) { + return isset($dm['accounts']) && count($dm['accounts']); + }) + ->unique(function($item, $key) { + return $item['accounts'][0]['id']; + }) + ->values(); + + return response()->json($dms); } /** @@ -1688,9 +1941,16 @@ class ApiV1Controller extends Controller 'limit' => 'nullable|integer|min:1|max:80' ]); + $page = $request->input('page', 1); $limit = $request->input('limit') ?? 40; $user = $request->user(); $status = Status::findOrFail($id); + $offset = $page == 1 ? 0 : ($page * $limit - $limit); + if($offset > 100) { + if($user->profile_id != $status->profile_id) { + return []; + } + } if($status->profile_id !== $user->profile_id) { if($status->scope == 'private') { @@ -1700,9 +1960,27 @@ class ApiV1Controller extends Controller } } - $liked = $status->likedBy()->latest()->simplePaginate($limit); - $resource = new Fractal\Resource\Collection($liked, new AccountTransformer()); - $res = $this->fractal->createData($resource)->toArray(); + $res = DB::table('likes') + ->select('likes.id', 'likes.profile_id', 'likes.status_id', 'followers.created_at') + ->leftJoin('followers', function($join) use($user, $status) { + return $join->on('likes.profile_id', '=', 'followers.following_id') + ->where('followers.profile_id', $user->profile_id) + ->where('likes.status_id', $status->id); + }) + ->whereStatusId($status->id) + ->orderByDesc('followers.created_at') + ->offset($offset) + ->limit($limit) + ->get() + ->map(function($like) { + $account = AccountService::get($like->profile_id); + $account['follows'] = isset($like->created_at); + return $account; + }) + ->filter(function($account) use($user) { + return $account && isset($account['id']) && $account['id'] != $user->profile_id; + }) + ->values(); $url = $request->url(); $page = $request->input('page', 1); @@ -2131,4 +2409,71 @@ class ApiV1Controller extends Controller return SearchApiV2Service::query($request); } + + /** + * GET /api/v1/discover/posts + * + * + * @return array + */ + public function discoverPosts(Request $request) + { + abort_if(!$request->user(), 403); + + $this->validate($request, [ + 'limit' => 'integer|min:1|max:40' + ]); + + $limit = $request->input('limit', 40); + $profile = Auth::user()->profile; + $pid = $profile->id; + + $following = Cache::remember('feature:discover:following:'.$pid, now()->addMinutes(15), function() use ($pid) { + return Follower::whereProfileId($pid)->pluck('following_id')->toArray(); + }); + + $filters = Cache::remember("user:filter:list:$pid", now()->addMinutes(15), function() use($pid) { + $private = Profile::whereIsPrivate(true) + ->orWhere('unlisted', true) + ->orWhere('status', '!=', null) + ->pluck('id') + ->toArray(); + $filters = UserFilter::whereUserId($pid) + ->whereFilterableType('App\Profile') + ->whereIn('filter_type', ['mute', 'block']) + ->pluck('filterable_id') + ->toArray(); + return array_merge($private, $filters); + }); + $following = array_merge($following, $filters); + + $sql = config('database.default') !== 'pgsql'; + $min_id = SnowflakeService::byDate(now()->subMonths(3)); + $res = Status::select( + 'id', + 'is_nsfw', + 'profile_id', + 'type', + 'uri', + ) + ->whereNull('uri') + ->whereIn('type', ['photo','photo:album', 'video']) + ->whereIsNsfw(false) + ->whereVisibility('public') + ->whereNotIn('profile_id', $following) + ->where('id', '>', $min_id) + ->inRandomOrder() + ->take($limit) + ->pluck('id') + ->map(function($post) { + return StatusService::get($post); + }) + ->filter(function($post) { + return $post && isset($post['id']); + }) + ->values() + ->toArray(); + + return response()->json($res); + } }