StoryApiV1Controller.php 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773
  1. <?php
  2. namespace App\Http\Controllers\Stories;
  3. use App\DirectMessage;
  4. use App\Follower;
  5. use App\Http\Controllers\Controller;
  6. use App\Http\Resources\StoryView as StoryViewResource;
  7. use App\Jobs\StoryPipeline\StoryDelete;
  8. use App\Jobs\StoryPipeline\StoryFanout;
  9. use App\Jobs\StoryPipeline\StoryReplyDeliver;
  10. use App\Jobs\StoryPipeline\StoryViewDeliver;
  11. use App\Models\Conversation;
  12. use App\Notification;
  13. use App\Services\AccountService;
  14. use App\Services\MediaPathService;
  15. use App\Services\StoryIndexService;
  16. use App\Services\StoryService;
  17. use App\Status;
  18. use App\Story;
  19. use App\StoryView;
  20. use Illuminate\Http\Request;
  21. use Illuminate\Support\Arr;
  22. use Illuminate\Support\Facades\Cache;
  23. use Illuminate\Support\Facades\DB;
  24. use Illuminate\Support\Facades\Storage;
  25. use Illuminate\Support\Str;
  26. use Illuminate\Validation\Rule;
  27. use Illuminate\Validation\Rules\File;
  28. use Illuminate\Validation\ValidationException;
  29. class StoryApiV1Controller extends Controller
  30. {
  31. const RECENT_KEY = 'pf:stories:recent-by-id:';
  32. const RECENT_TTL = 300;
  33. public function carousel(Request $request)
  34. {
  35. abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
  36. $pid = $request->user()->profile_id;
  37. if (config('database.default') == 'pgsql') {
  38. $s = Cache::remember(self::RECENT_KEY.$pid, self::RECENT_TTL, function () use ($pid) {
  39. return Story::select('stories.*', 'followers.following_id')
  40. ->leftJoin('followers', 'followers.following_id', 'stories.profile_id')
  41. ->where('followers.profile_id', $pid)
  42. ->where('stories.active', true)
  43. ->map(function ($s) {
  44. $r = new \StdClass;
  45. $r->id = $s->id;
  46. $r->profile_id = $s->profile_id;
  47. $r->type = $s->type;
  48. $r->path = $s->path;
  49. return $r;
  50. })
  51. ->unique('profile_id');
  52. });
  53. } else {
  54. $s = Cache::remember(self::RECENT_KEY.$pid, self::RECENT_TTL, function () use ($pid) {
  55. return Story::select('stories.*', 'followers.following_id')
  56. ->leftJoin('followers', 'followers.following_id', 'stories.profile_id')
  57. ->where('followers.profile_id', $pid)
  58. ->where('stories.active', true)
  59. ->orderBy('id')
  60. ->get();
  61. });
  62. }
  63. $nodes = $s->map(function ($s) use ($pid) {
  64. $profile = AccountService::get($s->profile_id, true);
  65. if (! $profile || ! isset($profile['id'])) {
  66. return false;
  67. }
  68. return [
  69. 'id' => (string) $s->id,
  70. 'pid' => (string) $s->profile_id,
  71. 'type' => $s->type,
  72. 'src' => url(Storage::url($s->path)),
  73. 'duration' => $s->duration ?? 3,
  74. 'seen' => StoryService::hasSeen($pid, $s->id),
  75. 'created_at' => $s->created_at->format('c'),
  76. ];
  77. })
  78. ->filter()
  79. ->groupBy('pid')
  80. ->map(function ($item) use ($pid) {
  81. $profile = AccountService::get($item[0]['pid'], true);
  82. $url = $profile['local'] ? url("/stories/{$profile['username']}") :
  83. url("/i/rs/{$profile['id']}");
  84. return [
  85. 'id' => 'pfs:'.$profile['id'],
  86. 'user' => [
  87. 'id' => (string) $profile['id'],
  88. 'username' => $profile['username'],
  89. 'username_acct' => $profile['acct'],
  90. 'avatar' => $profile['avatar'],
  91. 'local' => $profile['local'],
  92. 'is_author' => $profile['id'] == $pid,
  93. ],
  94. 'nodes' => $item,
  95. 'url' => $url,
  96. 'seen' => StoryService::hasSeen($pid, StoryService::latest($profile['id'])),
  97. ];
  98. })
  99. ->sortBy('seen')
  100. ->values();
  101. $res = [
  102. 'self' => [],
  103. 'nodes' => $nodes,
  104. ];
  105. if (Story::whereProfileId($pid)->whereActive(true)->exists()) {
  106. $selfStories = Story::whereProfileId($pid)
  107. ->whereActive(true)
  108. ->get()
  109. ->map(function ($s) {
  110. return [
  111. 'id' => (string) $s->id,
  112. 'type' => $s->type,
  113. 'src' => url(Storage::url($s->path)),
  114. 'duration' => $s->duration,
  115. 'seen' => true,
  116. 'created_at' => $s->created_at->format('c'),
  117. ];
  118. })
  119. ->sortBy('id')
  120. ->values();
  121. $selfProfile = AccountService::get($pid, true);
  122. $res['self'] = [
  123. 'user' => [
  124. 'id' => (string) $selfProfile['id'],
  125. 'username' => $selfProfile['acct'],
  126. 'avatar' => $selfProfile['avatar'],
  127. 'local' => $selfProfile['local'],
  128. 'is_author' => true,
  129. ],
  130. 'nodes' => $selfStories,
  131. ];
  132. }
  133. return response()->json($res, 200, [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
  134. }
  135. public function selfCarousel(Request $request)
  136. {
  137. abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
  138. $pid = $request->user()->profile_id;
  139. if (config('database.default') == 'pgsql') {
  140. $s = Cache::remember(self::RECENT_KEY.$pid, self::RECENT_TTL, function () use ($pid) {
  141. return Story::select('stories.*', 'followers.following_id')
  142. ->leftJoin('followers', 'followers.following_id', 'stories.profile_id')
  143. ->where('followers.profile_id', $pid)
  144. ->where('stories.active', true)
  145. ->map(function ($s) {
  146. $r = new \StdClass;
  147. $r->id = $s->id;
  148. $r->profile_id = $s->profile_id;
  149. $r->type = $s->type;
  150. $r->path = $s->path;
  151. return $r;
  152. })
  153. ->unique('profile_id');
  154. });
  155. } else {
  156. $s = Cache::remember(self::RECENT_KEY.$pid, self::RECENT_TTL, function () use ($pid) {
  157. return Story::select('stories.*', 'followers.following_id')
  158. ->leftJoin('followers', 'followers.following_id', 'stories.profile_id')
  159. ->where('followers.profile_id', $pid)
  160. ->where('stories.active', true)
  161. ->orderBy('id')
  162. ->get();
  163. });
  164. }
  165. $nodes = $s->map(function ($s) use ($pid) {
  166. $profile = AccountService::get($s->profile_id, true);
  167. if (! $profile || ! isset($profile['id'])) {
  168. return false;
  169. }
  170. return [
  171. 'id' => (string) $s->id,
  172. 'pid' => (string) $s->profile_id,
  173. 'type' => $s->type,
  174. 'src' => url(Storage::url($s->path)),
  175. 'duration' => $s->duration ?? 3,
  176. 'seen' => StoryService::hasSeen($pid, $s->id),
  177. 'created_at' => $s->created_at->format('c'),
  178. ];
  179. })
  180. ->filter()
  181. ->groupBy('pid')
  182. ->map(function ($item) use ($pid) {
  183. $profile = AccountService::get($item[0]['pid'], true);
  184. $url = $profile['local'] ? url("/stories/{$profile['username']}") :
  185. url("/i/rs/{$profile['id']}");
  186. return [
  187. 'id' => 'pfs:'.$profile['id'],
  188. 'user' => [
  189. 'id' => (string) $profile['id'],
  190. 'username' => $profile['username'],
  191. 'username_acct' => $profile['acct'],
  192. 'avatar' => $profile['avatar'],
  193. 'local' => $profile['local'],
  194. 'is_author' => $profile['id'] == $pid,
  195. ],
  196. 'nodes' => $item,
  197. 'url' => $url,
  198. 'seen' => StoryService::hasSeen($pid, StoryService::latest($profile['id'])),
  199. ];
  200. })
  201. ->sortBy('seen')
  202. ->values();
  203. $selfProfile = AccountService::get($pid, true);
  204. $res = [
  205. 'self' => [
  206. 'user' => [
  207. 'id' => (string) $selfProfile['id'],
  208. 'username' => $selfProfile['acct'],
  209. 'avatar' => $selfProfile['avatar'],
  210. 'local' => $selfProfile['local'],
  211. 'is_author' => true,
  212. ],
  213. 'nodes' => [],
  214. ],
  215. 'nodes' => $nodes,
  216. ];
  217. if (Story::whereProfileId($pid)->whereActive(true)->exists()) {
  218. $selfStories = Story::whereProfileId($pid)
  219. ->whereActive(true)
  220. ->get()
  221. ->map(function ($s) {
  222. return [
  223. 'id' => (string) $s->id,
  224. 'type' => $s->type,
  225. 'src' => url(Storage::url($s->path)),
  226. 'duration' => $s->duration,
  227. 'seen' => true,
  228. 'created_at' => $s->created_at->format('c'),
  229. ];
  230. })
  231. ->sortBy('id')
  232. ->values();
  233. $res['self']['nodes'] = $selfStories;
  234. }
  235. return response()->json($res, 200, [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
  236. }
  237. public function add(Request $request)
  238. {
  239. abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
  240. $this->validate($request, [
  241. 'file' => function () {
  242. return [
  243. 'required',
  244. 'mimetypes:image/jpeg,image/jpg,image/png,video/mp4',
  245. 'max:'.config_cache('pixelfed.max_photo_size'),
  246. ];
  247. },
  248. 'duration' => 'sometimes|integer|min:0|max:30',
  249. ]);
  250. $user = $request->user();
  251. $count = Story::whereProfileId($user->profile_id)
  252. ->whereActive(true)
  253. ->where('expires_at', '>', now())
  254. ->count();
  255. if ($count >= Story::MAX_PER_DAY) {
  256. abort(418, 'You have reached your limit for new Stories today.');
  257. }
  258. $photo = $request->file('file');
  259. $path = $this->storeMedia($photo, $user);
  260. $story = new Story;
  261. $story->duration = $request->input('duration', 3);
  262. $story->profile_id = $user->profile_id;
  263. $story->type = Str::endsWith($photo->getMimeType(), 'mp4') ? 'video' : 'photo';
  264. $story->mime = $photo->getMimeType();
  265. $story->path = $path;
  266. $story->local = true;
  267. $story->size = $photo->getSize();
  268. $story->bearcap_token = str_random(64);
  269. $story->expires_at = now()->addMinutes(1440);
  270. $story->save();
  271. $url = $story->path;
  272. $res = [
  273. 'code' => 200,
  274. 'msg' => 'Successfully added',
  275. 'media_id' => (string) $story->id,
  276. 'media_url' => url(Storage::url($url)).'?v='.time(),
  277. 'media_type' => $story->type,
  278. ];
  279. return $res;
  280. }
  281. public function publish(Request $request)
  282. {
  283. abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
  284. $this->validate($request, [
  285. 'media_id' => 'required',
  286. 'duration' => 'required|integer|min:0|max:30',
  287. 'can_reply' => 'required|boolean',
  288. 'can_react' => 'required|boolean',
  289. ]);
  290. $id = $request->input('media_id');
  291. $user = $request->user();
  292. $story = Story::whereProfileId($user->profile_id)
  293. ->findOrFail($id);
  294. $story->active = true;
  295. $story->duration = $request->input('duration', 10);
  296. $story->can_reply = $request->input('can_reply');
  297. $story->can_react = $request->input('can_react');
  298. $story->save();
  299. $index = app(StoryIndexService::class);
  300. $index->indexStory($story);
  301. StoryService::delLatest($story->profile_id);
  302. StoryFanout::dispatch($story)->onQueue('story');
  303. StoryService::addRotateQueue($story->id);
  304. return [
  305. 'code' => 200,
  306. 'msg' => 'Successfully published',
  307. ];
  308. }
  309. public function carouselNext(Request $request)
  310. {
  311. abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
  312. $pid = (int) $request->user()->profile_id;
  313. $index = app(StoryIndexService::class);
  314. $profileHydrator = function (array $ids) {
  315. $out = [];
  316. foreach ($ids as $id) {
  317. $p = AccountService::get($id, true);
  318. if ($p && isset($p['id'])) {
  319. $out[(int) $p['id']] = $p;
  320. }
  321. }
  322. return $out;
  323. };
  324. $nodes = $index->fetchCarouselNodes($pid, $profileHydrator);
  325. return response()->json(
  326. [
  327. 'nodes' => array_values($nodes),
  328. ],
  329. 200,
  330. [],
  331. JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
  332. );
  333. }
  334. public function publishNext(Request $request)
  335. {
  336. abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
  337. $validated = $this->validate($request, [
  338. 'image' => [
  339. 'required',
  340. 'image',
  341. 'mimes:jpeg,jpg,png',
  342. File::image()
  343. ->min(10)
  344. ->max(((int) config_cache('pixelfed.max_photo_size')) ?: (6 * 1024))
  345. ->dimensions(Rule::dimensions()->width(1080)->height(1920)),
  346. ],
  347. 'overlays' => 'nullable|array|min:0|max:4',
  348. 'overlays.*.absoluteScale' => 'numeric|min:0.1|max:5',
  349. 'overlays.*.absoluteX' => 'numeric',
  350. 'overlays.*.absoluteY' => 'numeric',
  351. 'overlays.*.color' => 'hex_color',
  352. 'overlays.*.backgroundColor' => 'string|in:transparent,#FFFFFF,#000000',
  353. 'overlays.*.content' => 'string|min:1|max:250',
  354. 'overlays.*.fontSize' => 'numeric|min:10|max:180',
  355. 'overlays.*.fontFamily' => 'string|in:default,serif,mono,rounded,bold',
  356. 'overlays.*.rotation' => 'numeric|min:-360|max:360',
  357. 'overlays.*.scale' => 'numeric|min:0.1|max:5',
  358. 'overlays.*.x' => 'numeric',
  359. 'overlays.*.y' => 'numeric',
  360. 'overlays.*.type' => 'string|in:text,mention,url,hashtag',
  361. ]);
  362. $user = $request->user();
  363. $pid = $user->profile_id;
  364. $count = Story::whereProfileId($user->profile_id)
  365. ->whereActive(true)
  366. ->where('expires_at', '>', now())
  367. ->count();
  368. if ($count >= Story::MAX_PER_DAY) {
  369. return response()->json([
  370. 'code' => 418,
  371. 'error' => 'You’ve reached your daily limit of '.Story::MAX_PER_DAY.' Stories.',
  372. ], 418, [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
  373. }
  374. DB::beginTransaction();
  375. try {
  376. $photo = $validated['image'];
  377. $path = $this->storeMedia($photo, $user);
  378. $allowedOverlayFields = [
  379. 'absoluteScale', 'absoluteX', 'absoluteY', 'color',
  380. 'content', 'fontSize', 'rotation', 'scale', 'x', 'y', 'type',
  381. ];
  382. $filteredOverlays = [];
  383. if (isset($validated['overlays'])) {
  384. foreach ($validated['overlays'] as $index => $overlay) {
  385. $filteredOverlay = Arr::only($overlay, $allowedOverlayFields);
  386. if (isset($filteredOverlay['type']) && isset($filteredOverlay['content'])) {
  387. $content = $filteredOverlay['content'];
  388. $type = $filteredOverlay['type'];
  389. switch ($type) {
  390. case 'text':
  391. if (! preg_match('/^[a-zA-Z0-9\s\p{P}\p{S}]*$/u', $content)) {
  392. throw ValidationException::withMessages([
  393. "overlays.{$index}.content" => 'Text overlays contain unsupported characters.',
  394. ]);
  395. }
  396. break;
  397. case 'hashtag':
  398. if (! preg_match('/^#[A-Za-z0-9_]{1,29}$/', $content)) {
  399. throw ValidationException::withMessages([
  400. "overlays.{$index}.content" => 'Invalid hashtag overlay.',
  401. ]);
  402. }
  403. break;
  404. case 'mention':
  405. $username = ltrim($content, '@');
  406. $doesFollow = DB::table('followers as f')
  407. ->where('f.following_id', $pid)
  408. ->whereExists(function ($q) use ($username) {
  409. $q->select(DB::raw(1))
  410. ->from('profiles as p')
  411. ->whereColumn('p.id', 'f.profile_id')
  412. ->where('p.username', $username);
  413. })
  414. ->exists();
  415. if (! $doesFollow) {
  416. throw ValidationException::withMessages([
  417. "overlays.{$index}.content" => 'The mentioned user does not exist.',
  418. ]);
  419. }
  420. $filteredOverlay['content'] = $username;
  421. break;
  422. case 'url':
  423. if (! filter_var($content, FILTER_VALIDATE_URL)) {
  424. throw ValidationException::withMessages([
  425. "overlays.{$index}.content" => 'Invalid URL format.',
  426. ]);
  427. }
  428. $parsedUrl = parse_url($content);
  429. if (! in_array($parsedUrl['scheme'] ?? '', ['https'])) {
  430. throw ValidationException::withMessages([
  431. "overlays.{$index}.content" => 'Only HTTP and HTTPS URLs are allowed.',
  432. ]);
  433. }
  434. break;
  435. default:
  436. throw ValidationException::withMessages([
  437. "overlays.{$index}.type" => 'Invalid overlay type.',
  438. ]);
  439. }
  440. }
  441. $filteredOverlays[] = $filteredOverlay;
  442. }
  443. }
  444. $story = new Story;
  445. $story->duration = 7;
  446. $story->profile_id = $user->profile_id;
  447. $story->type = 'photo';
  448. $story->mime = $photo->getMimeType();
  449. $story->path = $path;
  450. $story->local = true;
  451. $story->size = $photo->getSize();
  452. $story->bearcap_token = Str::random(64);
  453. $story->expires_at = now()->addDay();
  454. $story->active = true;
  455. $story->story = ['overlays' => $filteredOverlays];
  456. $story->can_reply = false;
  457. $story->can_react = false;
  458. $story->save();
  459. StoryService::delLatest($story->profile_id);
  460. StoryFanout::dispatch($story)->onQueue('story');
  461. StoryService::addRotateQueue($story->id);
  462. DB::commit();
  463. $index = app(StoryIndexService::class);
  464. $index->indexStory($story);
  465. $res = [
  466. 'code' => 200,
  467. 'msg' => 'Successfully added',
  468. ];
  469. return response()->json($res);
  470. } catch (\Exception $e) {
  471. DB::rollback();
  472. \Log::error('Story creation failed', [
  473. 'user_id' => $user->id,
  474. 'error' => $e->getMessage(),
  475. ]);
  476. $res = [
  477. 'code' => 500,
  478. 'msg' => 'Failed to create story',
  479. ];
  480. return response()->json($res, 500);
  481. }
  482. }
  483. public function mentionAutocomplete(Request $request)
  484. {
  485. abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
  486. $data = $request->validate([
  487. 'q' => ['required', 'string', 'max:120'],
  488. ]);
  489. $pid = $request->user()->profile_id;
  490. $q = str_starts_with($data['q'], '@') ? substr($data['q'], 1) : $data['q'];
  491. $rows = DB::table('profiles as p')
  492. ->select('p.id', 'p.username')
  493. ->where('p.username', 'like', $q.'%')
  494. ->whereExists(function ($sub) use ($pid) {
  495. $sub->select(DB::raw(1))
  496. ->from('followers as f')
  497. ->whereColumn('f.profile_id', 'p.id')
  498. ->where('f.following_id', $pid);
  499. })
  500. ->orderBy('p.username')
  501. ->limit(10)
  502. ->get()
  503. ->map(function ($item) {
  504. if ($item && $item->id) {
  505. return AccountService::get($item->id, true);
  506. }
  507. })
  508. ->filter()
  509. ->values();
  510. return response()->json($rows);
  511. }
  512. public function delete(Request $request, $id)
  513. {
  514. abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
  515. $user = $request->user();
  516. $story = Story::whereProfileId($user->profile_id)
  517. ->findOrFail($id);
  518. $story->active = false;
  519. $story->save();
  520. $index = app(StoryIndexService::class);
  521. $index->removeStory($id, $story->profile_id);
  522. StoryDelete::dispatch($story)->onQueue('story');
  523. return [
  524. 'code' => 200,
  525. 'msg' => 'Successfully deleted',
  526. ];
  527. }
  528. public function viewed(Request $request)
  529. {
  530. abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
  531. $this->validate($request, [
  532. 'id' => 'required|min:1',
  533. ]);
  534. $id = $request->input('id');
  535. $pid = $request->user()->profile_id;
  536. $authed = $request->user()->profile;
  537. $story = Story::whereActive(true)->findOrFail($id);
  538. $profile = $story->profile;
  539. if ($story->profile_id == $pid) {
  540. return [];
  541. }
  542. $following = Follower::whereProfileId($pid)->whereFollowingId($story->profile_id)->exists();
  543. abort_if(! $following, 403, 'Invalid permission');
  544. $v = StoryView::firstOrCreate([
  545. 'story_id' => $id,
  546. 'profile_id' => $pid,
  547. ]);
  548. $index = app(StoryIndexService::class);
  549. $index->markSeen($pid, $story->profile_id, $story->id, $story->created_at);
  550. if ($v->wasRecentlyCreated) {
  551. Story::findOrFail($story->id)->increment('view_count');
  552. if ($story->local == false) {
  553. StoryViewDeliver::dispatch($story, $authed)->onQueue('story');
  554. }
  555. Cache::forget('stories:recent:by_id:'.$pid);
  556. StoryService::addSeen($pid, $story->id);
  557. }
  558. return ['code' => 200];
  559. }
  560. public function comment(Request $request)
  561. {
  562. abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
  563. $this->validate($request, [
  564. 'sid' => 'required',
  565. 'caption' => 'required|string',
  566. ]);
  567. $pid = $request->user()->profile_id;
  568. $text = $request->input('caption');
  569. $story = Story::findOrFail($request->input('sid'));
  570. abort_if(! $story->can_reply, 422);
  571. $status = new Status;
  572. $status->type = 'story:reply';
  573. $status->profile_id = $pid;
  574. $status->caption = $text;
  575. $status->scope = 'direct';
  576. $status->visibility = 'direct';
  577. $status->in_reply_to_profile_id = $story->profile_id;
  578. $status->entities = json_encode([
  579. 'story_id' => $story->id,
  580. ]);
  581. $status->save();
  582. $dm = new DirectMessage;
  583. $dm->to_id = $story->profile_id;
  584. $dm->from_id = $pid;
  585. $dm->type = 'story:comment';
  586. $dm->status_id = $status->id;
  587. $dm->meta = json_encode([
  588. 'story_username' => $story->profile->username,
  589. 'story_actor_username' => $request->user()->username,
  590. 'story_id' => $story->id,
  591. 'story_media_url' => url(Storage::url($story->path)),
  592. 'caption' => $text,
  593. ]);
  594. $dm->save();
  595. Conversation::updateOrInsert(
  596. [
  597. 'to_id' => $story->profile_id,
  598. 'from_id' => $pid,
  599. ],
  600. [
  601. 'type' => 'story:comment',
  602. 'status_id' => $status->id,
  603. 'dm_id' => $dm->id,
  604. 'is_hidden' => false,
  605. ]
  606. );
  607. if ($story->local) {
  608. $n = new Notification;
  609. $n->profile_id = $dm->to_id;
  610. $n->actor_id = $dm->from_id;
  611. $n->item_id = $dm->id;
  612. $n->item_type = 'App\DirectMessage';
  613. $n->action = 'story:comment';
  614. $n->save();
  615. } else {
  616. StoryReplyDeliver::dispatch($story, $status)->onQueue('story');
  617. }
  618. return [
  619. 'code' => 200,
  620. 'msg' => 'Sent!',
  621. ];
  622. }
  623. protected function storeMedia($photo, $user)
  624. {
  625. $mimes = explode(',', config_cache('pixelfed.media_types'));
  626. if (in_array($photo->getMimeType(), [
  627. 'image/jpeg',
  628. 'image/png',
  629. 'video/mp4',
  630. ]) == false) {
  631. abort(400, 'Invalid media type');
  632. return;
  633. }
  634. $storagePath = MediaPathService::story($user->profile);
  635. $path = $photo->storePubliclyAs($storagePath, Str::random(random_int(2, 12)).'_'.Str::random(random_int(32, 35)).'_'.Str::random(random_int(1, 14)).'.'.$photo->extension());
  636. return $path;
  637. }
  638. public function viewers(Request $request)
  639. {
  640. abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
  641. $this->validate($request, [
  642. 'sid' => 'required|string|min:1|max:50',
  643. ]);
  644. $pid = $request->user()->profile_id;
  645. $sid = $request->input('sid');
  646. $story = Story::whereProfileId($pid)
  647. ->whereActive(true)
  648. ->findOrFail($sid);
  649. $viewers = StoryView::whereStoryId($story->id)
  650. ->orderByDesc('id')
  651. ->cursorPaginate(10)
  652. ->withQueryString();
  653. return StoryViewResource::collection($viewers);
  654. }
  655. }