StoryApiV1Controller.php 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. <?php
  2. namespace App\Http\Controllers\Stories;
  3. use App\Http\Controllers\Controller;
  4. use Illuminate\Http\Request;
  5. use Illuminate\Support\Str;
  6. use Illuminate\Support\Facades\Cache;
  7. use Illuminate\Support\Facades\Storage;
  8. use App\Models\Conversation;
  9. use App\DirectMessage;
  10. use App\Notification;
  11. use App\Story;
  12. use App\Status;
  13. use App\StoryView;
  14. use App\Jobs\StoryPipeline\StoryDelete;
  15. use App\Jobs\StoryPipeline\StoryFanout;
  16. use App\Jobs\StoryPipeline\StoryReplyDeliver;
  17. use App\Jobs\StoryPipeline\StoryViewDeliver;
  18. use App\Services\AccountService;
  19. use App\Services\MediaPathService;
  20. use App\Services\StoryService;
  21. use App\Http\Resources\StoryView as StoryViewResource;
  22. class StoryApiV1Controller extends Controller
  23. {
  24. public function carousel(Request $request)
  25. {
  26. abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
  27. $pid = $request->user()->profile_id;
  28. if(config('database.default') == 'pgsql') {
  29. $s = Story::select('stories.*', 'followers.following_id')
  30. ->leftJoin('followers', 'followers.following_id', 'stories.profile_id')
  31. ->where('followers.profile_id', $pid)
  32. ->where('stories.active', true)
  33. ->get();
  34. } else {
  35. $s = Story::select('stories.*', 'followers.following_id')
  36. ->leftJoin('followers', 'followers.following_id', 'stories.profile_id')
  37. ->where('followers.profile_id', $pid)
  38. ->where('stories.active', true)
  39. ->orderBy('id')
  40. ->get();
  41. }
  42. $nodes = $s->map(function($s) use($pid) {
  43. $profile = AccountService::get($s->profile_id, true);
  44. if(!$profile || !isset($profile['id'])) {
  45. return false;
  46. }
  47. return [
  48. 'id' => (string) $s->id,
  49. 'pid' => (string) $s->profile_id,
  50. 'type' => $s->type,
  51. 'src' => url(Storage::url($s->path)),
  52. 'duration' => $s->duration ?? 3,
  53. 'seen' => StoryService::hasSeen($pid, $s->id),
  54. 'created_at' => $s->created_at->format('c')
  55. ];
  56. })
  57. ->filter()
  58. ->groupBy('pid')
  59. ->map(function($item) use($pid) {
  60. $profile = AccountService::get($item[0]['pid'], true);
  61. $url = $profile['local'] ? url("/stories/{$profile['username']}") :
  62. url("/i/rs/{$profile['id']}");
  63. return [
  64. 'id' => 'pfs:' . $profile['id'],
  65. 'user' => [
  66. 'id' => (string) $profile['id'],
  67. 'username' => $profile['username'],
  68. 'username_acct' => $profile['acct'],
  69. 'avatar' => $profile['avatar'],
  70. 'local' => $profile['local'],
  71. 'is_author' => $profile['id'] == $pid
  72. ],
  73. 'nodes' => $item,
  74. 'url' => $url,
  75. 'seen' => StoryService::hasSeen($pid, StoryService::latest($profile['id'])),
  76. ];
  77. })
  78. ->sortBy('seen')
  79. ->values();
  80. $res = [
  81. 'self' => [],
  82. 'nodes' => $nodes,
  83. ];
  84. if(Story::whereProfileId($pid)->whereActive(true)->exists()) {
  85. $selfStories = Story::whereProfileId($pid)
  86. ->whereActive(true)
  87. ->get()
  88. ->map(function($s) use($pid) {
  89. return [
  90. 'id' => (string) $s->id,
  91. 'type' => $s->type,
  92. 'src' => url(Storage::url($s->path)),
  93. 'duration' => $s->duration,
  94. 'seen' => true,
  95. 'created_at' => $s->created_at->format('c')
  96. ];
  97. })
  98. ->sortBy('id')
  99. ->values();
  100. $selfProfile = AccountService::get($pid, true);
  101. $res['self'] = [
  102. 'user' => [
  103. 'id' => (string) $selfProfile['id'],
  104. 'username' => $selfProfile['acct'],
  105. 'avatar' => $selfProfile['avatar'],
  106. 'local' => $selfProfile['local'],
  107. 'is_author' => true
  108. ],
  109. 'nodes' => $selfStories,
  110. ];
  111. }
  112. return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
  113. }
  114. public function add(Request $request)
  115. {
  116. abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
  117. $this->validate($request, [
  118. 'file' => function() {
  119. return [
  120. 'required',
  121. 'mimetypes:image/jpeg,image/png,video/mp4',
  122. 'max:' . config_cache('pixelfed.max_photo_size'),
  123. ];
  124. },
  125. 'duration' => 'sometimes|integer|min:0|max:30'
  126. ]);
  127. $user = $request->user();
  128. $count = Story::whereProfileId($user->profile_id)
  129. ->whereActive(true)
  130. ->where('expires_at', '>', now())
  131. ->count();
  132. if($count >= Story::MAX_PER_DAY) {
  133. abort(418, 'You have reached your limit for new Stories today.');
  134. }
  135. $photo = $request->file('file');
  136. $path = $this->storeMedia($photo, $user);
  137. $story = new Story();
  138. $story->duration = $request->input('duration', 3);
  139. $story->profile_id = $user->profile_id;
  140. $story->type = Str::endsWith($photo->getMimeType(), 'mp4') ? 'video' :'photo';
  141. $story->mime = $photo->getMimeType();
  142. $story->path = $path;
  143. $story->local = true;
  144. $story->size = $photo->getSize();
  145. $story->bearcap_token = str_random(64);
  146. $story->expires_at = now()->addMinutes(1440);
  147. $story->save();
  148. $url = $story->path;
  149. $res = [
  150. 'code' => 200,
  151. 'msg' => 'Successfully added',
  152. 'media_id' => (string) $story->id,
  153. 'media_url' => url(Storage::url($url)) . '?v=' . time(),
  154. 'media_type' => $story->type
  155. ];
  156. return $res;
  157. }
  158. public function publish(Request $request)
  159. {
  160. abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
  161. $this->validate($request, [
  162. 'media_id' => 'required',
  163. 'duration' => 'required|integer|min:0|max:30',
  164. 'can_reply' => 'required|boolean',
  165. 'can_react' => 'required|boolean'
  166. ]);
  167. $id = $request->input('media_id');
  168. $user = $request->user();
  169. $story = Story::whereProfileId($user->profile_id)
  170. ->findOrFail($id);
  171. $story->active = true;
  172. $story->duration = $request->input('duration', 10);
  173. $story->can_reply = $request->input('can_reply');
  174. $story->can_react = $request->input('can_react');
  175. $story->save();
  176. StoryService::delLatest($story->profile_id);
  177. StoryFanout::dispatch($story)->onQueue('story');
  178. StoryService::addRotateQueue($story->id);
  179. return [
  180. 'code' => 200,
  181. 'msg' => 'Successfully published',
  182. ];
  183. }
  184. public function delete(Request $request, $id)
  185. {
  186. abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
  187. $user = $request->user();
  188. $story = Story::whereProfileId($user->profile_id)
  189. ->findOrFail($id);
  190. $story->active = false;
  191. $story->save();
  192. StoryDelete::dispatch($story)->onQueue('story');
  193. return [
  194. 'code' => 200,
  195. 'msg' => 'Successfully deleted'
  196. ];
  197. }
  198. public function viewed(Request $request)
  199. {
  200. abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
  201. $this->validate($request, [
  202. 'id' => 'required|min:1',
  203. ]);
  204. $id = $request->input('id');
  205. $authed = $request->user()->profile;
  206. $story = Story::with('profile')
  207. ->findOrFail($id);
  208. $exp = $story->expires_at;
  209. $profile = $story->profile;
  210. if($story->profile_id == $authed->id) {
  211. return [];
  212. }
  213. $publicOnly = (bool) $profile->followedBy($authed);
  214. abort_if(!$publicOnly, 403);
  215. $v = StoryView::firstOrCreate([
  216. 'story_id' => $id,
  217. 'profile_id' => $authed->id
  218. ]);
  219. if($v->wasRecentlyCreated) {
  220. Story::findOrFail($story->id)->increment('view_count');
  221. if($story->local == false) {
  222. StoryViewDeliver::dispatch($story, $authed)->onQueue('story');
  223. }
  224. }
  225. Cache::forget('stories:recent:by_id:' . $authed->id);
  226. StoryService::addSeen($authed->id, $story->id);
  227. return ['code' => 200];
  228. }
  229. public function comment(Request $request)
  230. {
  231. abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
  232. $this->validate($request, [
  233. 'sid' => 'required',
  234. 'caption' => 'required|string'
  235. ]);
  236. $pid = $request->user()->profile_id;
  237. $text = $request->input('caption');
  238. $story = Story::findOrFail($request->input('sid'));
  239. abort_if(!$story->can_reply, 422);
  240. $status = new Status;
  241. $status->type = 'story:reply';
  242. $status->profile_id = $pid;
  243. $status->caption = $text;
  244. $status->rendered = $text;
  245. $status->scope = 'direct';
  246. $status->visibility = 'direct';
  247. $status->in_reply_to_profile_id = $story->profile_id;
  248. $status->entities = json_encode([
  249. 'story_id' => $story->id
  250. ]);
  251. $status->save();
  252. $dm = new DirectMessage;
  253. $dm->to_id = $story->profile_id;
  254. $dm->from_id = $pid;
  255. $dm->type = 'story:comment';
  256. $dm->status_id = $status->id;
  257. $dm->meta = json_encode([
  258. 'story_username' => $story->profile->username,
  259. 'story_actor_username' => $request->user()->username,
  260. 'story_id' => $story->id,
  261. 'story_media_url' => url(Storage::url($story->path)),
  262. 'caption' => $text
  263. ]);
  264. $dm->save();
  265. Conversation::updateOrInsert(
  266. [
  267. 'to_id' => $story->profile_id,
  268. 'from_id' => $pid
  269. ],
  270. [
  271. 'type' => 'story:comment',
  272. 'status_id' => $status->id,
  273. 'dm_id' => $dm->id,
  274. 'is_hidden' => false
  275. ]
  276. );
  277. if($story->local) {
  278. $n = new Notification;
  279. $n->profile_id = $dm->to_id;
  280. $n->actor_id = $dm->from_id;
  281. $n->item_id = $dm->id;
  282. $n->item_type = 'App\DirectMessage';
  283. $n->action = 'story:comment';
  284. $n->save();
  285. } else {
  286. StoryReplyDeliver::dispatch($story, $status)->onQueue('story');
  287. }
  288. return [
  289. 'code' => 200,
  290. 'msg' => 'Sent!'
  291. ];
  292. }
  293. protected function storeMedia($photo, $user)
  294. {
  295. $mimes = explode(',', config_cache('pixelfed.media_types'));
  296. if(in_array($photo->getMimeType(), [
  297. 'image/jpeg',
  298. 'image/png',
  299. 'video/mp4'
  300. ]) == false) {
  301. abort(400, 'Invalid media type');
  302. return;
  303. }
  304. $storagePath = MediaPathService::story($user->profile);
  305. $path = $photo->storePubliclyAs($storagePath, Str::random(random_int(2, 12)) . '_' . Str::random(random_int(32, 35)) . '_' . Str::random(random_int(1, 14)) . '.' . $photo->extension());
  306. return $path;
  307. }
  308. public function viewers(Request $request)
  309. {
  310. abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
  311. $this->validate($request, [
  312. 'sid' => 'required|string|min:1|max:50'
  313. ]);
  314. $pid = $request->user()->profile_id;
  315. $sid = $request->input('sid');
  316. $story = Story::whereProfileId($pid)
  317. ->whereActive(true)
  318. ->findOrFail($sid);
  319. $viewers = StoryView::whereStoryId($story->id)
  320. ->orderByDesc('id')
  321. ->cursorPaginate(10);
  322. return StoryViewResource::collection($viewers);
  323. }
  324. }