ImportPostController.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. <?php
  2. namespace App\Http\Controllers;
  3. use Illuminate\Http\Request;
  4. use App\Models\ImportPost;
  5. use App\Services\ImportService;
  6. use App\Services\StatusService;
  7. use App\Http\Resources\ImportStatus;
  8. use App\Follower;
  9. use App\User;
  10. class ImportPostController extends Controller
  11. {
  12. public function __construct()
  13. {
  14. $this->middleware('auth');
  15. }
  16. public function getConfig(Request $request)
  17. {
  18. return [
  19. 'enabled' => config('import.instagram.enabled'),
  20. 'limits' => [
  21. 'max_posts' => config('import.instagram.limits.max_posts'),
  22. 'max_attempts' => config('import.instagram.limits.max_attempts'),
  23. ],
  24. 'allow_video_posts' => config('import.instagram.allow_video_posts'),
  25. 'allow_image_webp' => config('import.instagram.allow_image_webp') && str_contains(config_cache('pixelfed.media_types'), 'image/webp'),
  26. 'permissions' => [
  27. 'admins_only' => config('import.instagram.permissions.admins_only'),
  28. 'admin_follows_only' => config('import.instagram.permissions.admin_follows_only'),
  29. 'min_account_age' => config('import.instagram.permissions.min_account_age'),
  30. 'min_follower_count' => config('import.instagram.permissions.min_follower_count'),
  31. ],
  32. 'allowed' => $this->checkPermissions($request, false)
  33. ];
  34. }
  35. public function getProcessingCount(Request $request)
  36. {
  37. abort_unless(config('import.instagram.enabled'), 404);
  38. $processing = ImportPost::whereProfileId($request->user()->profile_id)
  39. ->whereNull('status_id')
  40. ->whereSkipMissingMedia(false)
  41. ->count();
  42. $finished = ImportPost::whereProfileId($request->user()->profile_id)
  43. ->whereNotNull('status_id')
  44. ->whereSkipMissingMedia(false)
  45. ->count();
  46. return response()->json([
  47. 'processing_count' => $processing,
  48. 'finished_count' => $finished,
  49. ]);
  50. }
  51. public function getImportedFiles(Request $request)
  52. {
  53. abort_unless(config('import.instagram.enabled'), 404);
  54. return response()->json(
  55. ImportService::getImportedFiles($request->user()->profile_id),
  56. 200,
  57. [],
  58. JSON_UNESCAPED_SLASHES
  59. );
  60. }
  61. public function getImportedPosts(Request $request)
  62. {
  63. abort_unless(config('import.instagram.enabled'), 404);
  64. return ImportStatus::collection(
  65. ImportPost::whereProfileId($request->user()->profile_id)
  66. ->has('status')
  67. ->cursorPaginate(9)
  68. );
  69. }
  70. public function formatHashtags($val = false)
  71. {
  72. if(!$val || !strlen($val)) {
  73. return null;
  74. }
  75. $groupedHashtagRegex = '/#\w+(?=#)/';
  76. return preg_replace($groupedHashtagRegex, '$0 ', $val);
  77. }
  78. public function store(Request $request)
  79. {
  80. abort_unless(config('import.instagram.enabled'), 404);
  81. $this->checkPermissions($request);
  82. $uid = $request->user()->id;
  83. $pid = $request->user()->profile_id;
  84. $successCount = 0;
  85. $errors = [];
  86. foreach($request->input('files') as $file) {
  87. try {
  88. $media = $file['media'];
  89. $c = collect($media);
  90. $firstUri = isset($media[0]['uri']) ? $media[0]['uri'] : '';
  91. $postHash = hash('sha256', $c->toJson() . $firstUri);
  92. $exists = ImportPost::where('user_id', $uid)
  93. ->where('post_hash', $postHash)
  94. ->exists();
  95. if ($exists) {
  96. $errors[] = "Duplicate post detected. Skipping...";
  97. continue;
  98. }
  99. $exts = $c->map(function($m) {
  100. $fn = last(explode('/', $m['uri']));
  101. return last(explode('.', $fn));
  102. });
  103. $postType = $this->determinePostType($exts);
  104. $ip = new ImportPost;
  105. $ip->user_id = $uid;
  106. $ip->profile_id = $pid;
  107. $ip->post_hash = $postHash;
  108. $ip->service = 'instagram';
  109. $ip->post_type = $postType;
  110. $ip->media_count = $c->count();
  111. $ip->media = $c->map(function($m) {
  112. return [
  113. 'uri' => $m['uri'],
  114. 'title' => $this->formatHashtags($m['title'] ?? ''),
  115. 'creation_timestamp' => $m['creation_timestamp'] ?? null
  116. ];
  117. })->toArray();
  118. $ip->caption = $c->count() > 1 ?
  119. $this->formatHashtags($file['title'] ?? '') :
  120. $this->formatHashtags($ip->media[0]['title'] ?? '');
  121. $originalFilename = last(explode('/', $ip->media[0]['uri'] ?? ''));
  122. $ip->filename = $this->sanitizeFilename($originalFilename);
  123. $ip->metadata = $c->map(function($m) {
  124. return [
  125. 'uri' => $m['uri'],
  126. 'media_metadata' => isset($m['media_metadata']) ? $m['media_metadata'] : null
  127. ];
  128. })->toArray();
  129. $creationTimestamp = $c->count() > 1 ?
  130. ($file['creation_timestamp'] ?? null) :
  131. ($media[0]['creation_timestamp'] ?? null);
  132. if ($creationTimestamp) {
  133. $ip->creation_date = now()->parse($creationTimestamp);
  134. $ip->creation_year = $ip->creation_date->format('y');
  135. $ip->creation_month = $ip->creation_date->format('m');
  136. $ip->creation_day = $ip->creation_date->format('d');
  137. } else {
  138. $ip->creation_date = now();
  139. $ip->creation_year = now()->format('y');
  140. $ip->creation_month = now()->format('m');
  141. $ip->creation_day = now()->format('d');
  142. }
  143. $ip->save();
  144. $successCount++;
  145. ImportService::getImportedFiles($pid, true);
  146. ImportService::getPostCount($pid, true);
  147. } catch (\Exception $e) {
  148. $errors[] = $e->getMessage();
  149. \Log::error('Import error: ' . $e->getMessage());
  150. continue;
  151. }
  152. }
  153. return [
  154. 'success' => true,
  155. 'msg' => 'Successfully imported ' . $successCount . ' posts',
  156. 'errors' => $errors
  157. ];
  158. }
  159. public function storeMedia(Request $request)
  160. {
  161. abort_unless(config('import.instagram.enabled'), 404);
  162. $this->checkPermissions($request);
  163. $allowedMimeTypes = ['image/png', 'image/jpeg', 'image/jpg'];
  164. if (config('import.instagram.allow_image_webp') && str_contains(config_cache('pixelfed.media_types'), 'image/webp')) {
  165. $allowedMimeTypes[] = 'image/webp';
  166. }
  167. if (config('import.instagram.allow_video_posts')) {
  168. $allowedMimeTypes[] = 'video/mp4';
  169. }
  170. $mimes = 'mimetypes:' . implode(',', $allowedMimeTypes);
  171. $this->validate($request, [
  172. 'file' => 'required|array|max:10',
  173. 'file.*' => [
  174. 'required',
  175. 'file',
  176. $mimes,
  177. 'max:' . config_cache('pixelfed.max_photo_size')
  178. ]
  179. ]);
  180. foreach($request->file('file') as $file) {
  181. $extension = $file->getClientOriginalExtension();
  182. $originalName = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
  183. $safeFilename = preg_replace('/[^a-zA-Z0-9_.-]/', '_', $originalName);
  184. $fileName = $safeFilename . '.' . $extension;
  185. $file->storeAs('imports/' . $request->user()->id . '/', $fileName);
  186. }
  187. ImportService::getImportedFiles($request->user()->profile_id, true);
  188. return [
  189. 'msg' => 'Success'
  190. ];
  191. }
  192. private function determinePostType($exts)
  193. {
  194. if ($exts->count() > 1) {
  195. if ($exts->contains('mp4')) {
  196. if ($exts->contains('jpg', 'png', 'webp')) {
  197. return 'photo:video:album';
  198. } else {
  199. return 'video:album';
  200. }
  201. } else {
  202. return 'photo:album';
  203. }
  204. } else {
  205. if ($exts->isEmpty()) {
  206. return 'photo';
  207. }
  208. $ext = $exts[0];
  209. if (in_array($ext, ['jpg', 'jpeg', 'png', 'webp'])) {
  210. return 'photo';
  211. } else if (in_array($ext, ['mp4'])) {
  212. return 'video';
  213. } else {
  214. return 'photo';
  215. }
  216. }
  217. }
  218. private function sanitizeFilename($filename)
  219. {
  220. $parts = explode('.', $filename);
  221. $extension = array_pop($parts);
  222. $originalName = implode('.', $parts);
  223. $safeFilename = preg_replace('/[^a-zA-Z0-9_.-]/', '_', $originalName);
  224. return $safeFilename . '.' . $extension;
  225. }
  226. protected function checkPermissions($request, $abortOnFail = true)
  227. {
  228. $user = $request->user();
  229. if($abortOnFail) {
  230. abort_unless(config('import.instagram.enabled'), 404);
  231. }
  232. if($user->is_admin) {
  233. if(!$abortOnFail) {
  234. return true;
  235. } else {
  236. return;
  237. }
  238. }
  239. $admin = User::whereIsAdmin(true)->first();
  240. if(config('import.instagram.permissions.admins_only')) {
  241. if($abortOnFail) {
  242. abort_unless($user->is_admin, 404, 'Only admins can use this feature.');
  243. } else {
  244. if(!$user->is_admin) {
  245. return false;
  246. }
  247. }
  248. }
  249. if(config('import.instagram.permissions.admin_follows_only')) {
  250. $exists = Follower::whereProfileId($admin->profile_id)
  251. ->whereFollowingId($user->profile_id)
  252. ->exists();
  253. if($abortOnFail) {
  254. abort_unless(
  255. $exists,
  256. 404,
  257. 'Only admins, and accounts they follow can use this feature'
  258. );
  259. } else {
  260. if(!$exists) {
  261. return false;
  262. }
  263. }
  264. }
  265. if(config('import.instagram.permissions.min_account_age')) {
  266. $res = $user->created_at->lt(
  267. now()->subDays(config('import.instagram.permissions.min_account_age'))
  268. );
  269. if($abortOnFail) {
  270. abort_unless(
  271. $res,
  272. 404,
  273. 'Your account is too new to use this feature'
  274. );
  275. } else {
  276. if(!$res) {
  277. return false;
  278. }
  279. }
  280. }
  281. if(config('import.instagram.permissions.min_follower_count')) {
  282. $res = Follower::whereFollowingId($user->profile_id)->count() >= config('import.instagram.permissions.min_follower_count');
  283. if($abortOnFail) {
  284. abort_unless(
  285. $res,
  286. 404,
  287. 'You don\'t have enough followers to use this feature'
  288. );
  289. } else {
  290. if(!$res) {
  291. return false;
  292. }
  293. }
  294. }
  295. if(intval(config('import.instagram.limits.max_posts')) > 0) {
  296. $res = ImportService::getPostCount($user->profile_id) >= intval(config('import.instagram.limits.max_posts'));
  297. if($abortOnFail) {
  298. abort_if(
  299. $res,
  300. 404,
  301. 'You have reached the limit of post imports and cannot import any more posts'
  302. );
  303. } else {
  304. if($res) {
  305. return false;
  306. }
  307. }
  308. }
  309. if(intval(config('import.instagram.limits.max_attempts')) > 0) {
  310. $res = ImportService::getAttempts($user->profile_id) >= intval(config('import.instagram.limits.max_attempts'));
  311. if($abortOnFail) {
  312. abort_if(
  313. $res,
  314. 404,
  315. 'You have reached the limit of post import attempts and cannot import any more posts'
  316. );
  317. } else {
  318. if($res) {
  319. return false;
  320. }
  321. }
  322. }
  323. if(!$abortOnFail) {
  324. return true;
  325. }
  326. }
  327. }