StoryApiV1Controller.php 17 KB

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