AdminDirectoryController.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. <?php
  2. namespace App\Http\Controllers\Admin;
  3. use App\Http\Controllers\PixelfedDirectoryController;
  4. use App\Models\ConfigCache;
  5. use App\Services\AccountService;
  6. use App\Services\ConfigCacheService;
  7. use App\Services\StatusService;
  8. use App\Status;
  9. use App\User;
  10. use Cache;
  11. use Illuminate\Http\Request;
  12. use Illuminate\Support\Facades\Http;
  13. use Illuminate\Support\Facades\Storage;
  14. use Illuminate\Support\Facades\Validator;
  15. use Illuminate\Support\Str;
  16. use League\ISO3166\ISO3166;
  17. trait AdminDirectoryController
  18. {
  19. public function directoryHome(Request $request)
  20. {
  21. return view('admin.directory.home');
  22. }
  23. public function directoryInitialData(Request $request)
  24. {
  25. $res = [];
  26. $res['countries'] = collect((new ISO3166)->all())->pluck('name');
  27. $res['admins'] = User::whereIsAdmin(true)
  28. ->where('2fa_enabled', true)
  29. ->get()->map(function ($user) {
  30. return [
  31. 'uid' => (string) $user->id,
  32. 'pid' => (string) $user->profile_id,
  33. 'username' => $user->username,
  34. 'created_at' => $user->created_at,
  35. ];
  36. });
  37. $config = ConfigCache::whereK('pixelfed.directory')->first();
  38. if ($config) {
  39. $data = $config->v ? json_decode($config->v, true) : [];
  40. $res = array_merge($res, $data);
  41. }
  42. if (empty($res['summary'])) {
  43. $summary = ConfigCache::whereK('app.short_description')->pluck('v');
  44. $res['summary'] = $summary ? $summary[0] : null;
  45. }
  46. if (isset($res['banner_image']) && ! empty($res['banner_image'])) {
  47. $res['banner_image'] = url(Storage::url($res['banner_image']));
  48. }
  49. if (isset($res['favourite_posts'])) {
  50. $res['favourite_posts'] = collect($res['favourite_posts'])->map(function ($id) {
  51. return StatusService::get($id);
  52. })
  53. ->filter(function ($post) {
  54. return $post && isset($post['account']);
  55. })
  56. ->values();
  57. }
  58. $res['community_guidelines'] = config_cache('app.rules') ? json_decode(config_cache('app.rules'), true) : [];
  59. $res['curated_onboarding'] = (bool) config_cache('instance.curated_registration.enabled');
  60. $res['open_registration'] = (bool) config_cache('pixelfed.open_registration');
  61. $res['oauth_enabled'] = (bool) config_cache('pixelfed.oauth_enabled') &&
  62. (file_exists(storage_path('oauth-public.key')) || config_cache('passport.public_key')) &&
  63. (file_exists(storage_path('oauth-private.key')) || config_cache('passport.private_key'));
  64. $res['activitypub_enabled'] = (bool) config_cache('federation.activitypub.enabled');
  65. $res['feature_config'] = [
  66. 'media_types' => Str::of(config_cache('pixelfed.media_types'))->explode(','),
  67. 'image_quality' => config_cache('pixelfed.image_quality'),
  68. 'optimize_image' => (bool) config_cache('pixelfed.optimize_image'),
  69. 'max_photo_size' => config_cache('pixelfed.max_photo_size'),
  70. 'max_caption_length' => config_cache('pixelfed.max_caption_length'),
  71. 'max_altext_length' => config_cache('pixelfed.max_altext_length'),
  72. 'enforce_account_limit' => (bool) config_cache('pixelfed.enforce_account_limit'),
  73. 'max_account_size' => config_cache('pixelfed.max_account_size'),
  74. 'max_album_length' => config_cache('pixelfed.max_album_length'),
  75. 'account_deletion' => (bool) config_cache('pixelfed.account_deletion'),
  76. ];
  77. if (config_cache('pixelfed.directory.testimonials')) {
  78. $testimonials = collect(json_decode(config_cache('pixelfed.directory.testimonials'), true))
  79. ->map(function ($t) {
  80. return [
  81. 'profile' => AccountService::get($t['profile_id']),
  82. 'body' => $t['body'],
  83. ];
  84. });
  85. $res['testimonials'] = $testimonials;
  86. }
  87. $validator = Validator::make($res['feature_config'], [
  88. 'media_types' => [
  89. 'required',
  90. function ($attribute, $value, $fail) {
  91. if (! in_array('image/jpeg', $value->toArray()) || ! in_array('image/png', $value->toArray())) {
  92. $fail('You must enable image/jpeg and image/png support.');
  93. }
  94. },
  95. ],
  96. 'image_quality' => 'required_if:optimize_image,true|integer|min:75|max:100',
  97. 'max_altext_length' => 'required|integer|min:1000|max:5000',
  98. 'max_photo_size' => 'required|integer|min:15000|max:100000',
  99. 'max_account_size' => 'required_if:enforce_account_limit,true|integer|min:1000000',
  100. 'max_album_length' => 'required|integer|min:4|max:20',
  101. 'account_deletion' => 'required|accepted',
  102. 'max_caption_length' => 'required|integer|min:500|max:10000',
  103. ]);
  104. $res['requirements_validator'] = $validator->errors();
  105. $res['is_eligible'] = ($res['open_registration'] || $res['curated_onboarding']) &&
  106. $res['oauth_enabled'] &&
  107. $res['activitypub_enabled'] &&
  108. count($res['requirements_validator']) === 0 &&
  109. $this->validVal($res, 'admin') &&
  110. $this->validVal($res, 'summary', null, 10) &&
  111. $this->validVal($res, 'favourite_posts', 3) &&
  112. $this->validVal($res, 'contact_email') &&
  113. $this->validVal($res, 'privacy_pledge') &&
  114. $this->validVal($res, 'location');
  115. $res['has_submitted'] = config_cache('pixelfed.directory.has_submitted') ?? false;
  116. $res['synced'] = config_cache('pixelfed.directory.is_synced') ?? false;
  117. $res['latest_response'] = config_cache('pixelfed.directory.latest_response') ?? null;
  118. $path = base_path('resources/lang');
  119. $langs = collect([]);
  120. foreach (new \DirectoryIterator($path) as $io) {
  121. $name = $io->getFilename();
  122. $skip = ['vendor'];
  123. if ($io->isDot() || in_array($name, $skip)) {
  124. continue;
  125. }
  126. if ($io->isDir()) {
  127. $langs->push(['code' => $name, 'name' => locale_get_display_name($name)]);
  128. }
  129. }
  130. $res['available_languages'] = $langs->sortBy('name')->values();
  131. $res['primary_locale'] = config('app.locale');
  132. $submissionState = Http::withoutVerifying()
  133. ->post('https://pixelfed.org/api/v1/directory/check-submission', [
  134. 'domain' => config('pixelfed.domain.app'),
  135. ]);
  136. $res['submission_state'] = $submissionState->json();
  137. return $res;
  138. }
  139. protected function validVal($res, $val, $count = false, $minLen = false)
  140. {
  141. if (! isset($res[$val])) {
  142. return false;
  143. }
  144. if ($count) {
  145. return count($res[$val]) >= $count;
  146. }
  147. if ($minLen) {
  148. return strlen($res[$val]) >= $minLen;
  149. }
  150. return $res[$val];
  151. }
  152. public function directoryStore(Request $request)
  153. {
  154. $this->validate($request, [
  155. 'location' => 'string|min:1|max:53',
  156. 'summary' => 'string|nullable|max:140',
  157. 'admin_uid' => 'sometimes|nullable',
  158. 'contact_email' => 'sometimes|nullable|email:rfc,dns',
  159. 'favourite_posts' => 'array|max:12',
  160. 'favourite_posts.*' => 'distinct',
  161. 'privacy_pledge' => 'sometimes',
  162. 'banner_image' => 'sometimes|mimes:jpg,png|dimensions:width=1920,height:1080|max:5000',
  163. ]);
  164. $config = ConfigCache::firstOrNew([
  165. 'k' => 'pixelfed.directory',
  166. ]);
  167. $res = $config->v ? json_decode($config->v, true) : [];
  168. $res['summary'] = strip_tags($request->input('summary'));
  169. $res['favourite_posts'] = $request->input('favourite_posts');
  170. $res['admin'] = (string) $request->input('admin_uid');
  171. $res['contact_email'] = $request->input('contact_email');
  172. $res['privacy_pledge'] = (bool) $request->input('privacy_pledge');
  173. if ($request->filled('location')) {
  174. $exists = (new ISO3166)->name($request->location);
  175. if ($exists) {
  176. $res['location'] = $request->input('location');
  177. }
  178. }
  179. if ($request->hasFile('banner_image')) {
  180. collect(Storage::files('public/headers'))
  181. ->filter(function ($name) {
  182. $protected = [
  183. 'public/headers/.gitignore',
  184. 'public/headers/default.jpg',
  185. 'public/headers/missing.png',
  186. ];
  187. return ! in_array($name, $protected);
  188. })
  189. ->each(function ($name) {
  190. Storage::delete($name);
  191. });
  192. $path = $request->file('banner_image')->storePublicly('public/headers');
  193. $res['banner_image'] = $path;
  194. ConfigCacheService::put('app.banner_image', url(Storage::url($path)));
  195. Cache::forget('api:v1:instance-data-response-v1');
  196. }
  197. $config->v = json_encode($res);
  198. $config->save();
  199. ConfigCacheService::put('pixelfed.directory', $config->v);
  200. $updated = json_decode($config->v, true);
  201. if (isset($updated['banner_image'])) {
  202. $updated['banner_image'] = url(Storage::url($updated['banner_image']));
  203. }
  204. return $updated;
  205. }
  206. public function directoryHandleServerSubmission(Request $request)
  207. {
  208. $reqs = [];
  209. $reqs['feature_config'] = [
  210. 'open_registration' => (bool) config_cache('pixelfed.open_registration'),
  211. 'curated_onboarding' => (bool) config_cache('instance.curated_registration.enabled'),
  212. 'activitypub_enabled' => config_cache('federation.activitypub.enabled'),
  213. 'oauth_enabled' => (bool) config_cache('pixelfed.oauth_enabled'),
  214. 'media_types' => Str::of(config_cache('pixelfed.media_types'))->explode(','),
  215. 'image_quality' => config_cache('pixelfed.image_quality'),
  216. 'optimize_image' => config_cache('pixelfed.optimize_image'),
  217. 'max_photo_size' => config_cache('pixelfed.max_photo_size'),
  218. 'max_caption_length' => config_cache('pixelfed.max_caption_length'),
  219. 'max_altext_length' => config_cache('pixelfed.max_altext_length'),
  220. 'enforce_account_limit' => config_cache('pixelfed.enforce_account_limit'),
  221. 'max_account_size' => config_cache('pixelfed.max_account_size'),
  222. 'max_album_length' => config_cache('pixelfed.max_album_length'),
  223. 'account_deletion' => config_cache('pixelfed.account_deletion'),
  224. ];
  225. $validator = Validator::make($reqs['feature_config'], [
  226. 'open_registration' => 'required_unless:curated_onboarding,true',
  227. 'curated_onboarding' => 'required_unless:open_registration,true',
  228. 'activitypub_enabled' => 'required|accepted',
  229. 'oauth_enabled' => 'required|accepted',
  230. 'media_types' => [
  231. 'required',
  232. function ($attribute, $value, $fail) {
  233. if (! in_array('image/jpeg', $value->toArray()) || ! in_array('image/png', $value->toArray())) {
  234. $fail('You must enable image/jpeg and image/png support.');
  235. }
  236. },
  237. ],
  238. 'image_quality' => 'required_if:optimize_image,true|integer|min:75|max:100',
  239. 'max_altext_length' => 'required|integer|min:1000|max:5000',
  240. 'max_photo_size' => 'required|integer|min:15000|max:100000',
  241. 'max_account_size' => 'required_if:enforce_account_limit,true|integer|min:1000000',
  242. 'max_album_length' => 'required|integer|min:4|max:20',
  243. 'account_deletion' => 'required|accepted',
  244. 'max_caption_length' => 'required|integer|min:500|max:10000',
  245. ]);
  246. if (! $validator->validate()) {
  247. return response()->json($validator->errors(), 422);
  248. }
  249. ConfigCacheService::put('pixelfed.directory.submission-key', Str::random(random_int(40, 69)));
  250. ConfigCacheService::put('pixelfed.directory.submission-ts', now());
  251. $data = (new PixelfedDirectoryController())->buildListing();
  252. $res = Http::withoutVerifying()->post('https://pixelfed.org/api/v1/directory/submission', $data);
  253. return 200;
  254. }
  255. public function directoryDeleteBannerImage(Request $request)
  256. {
  257. $bannerImage = ConfigCache::whereK('app.banner_image')->first();
  258. $directory = ConfigCache::whereK('pixelfed.directory')->first();
  259. if (! $bannerImage && ! $directory || empty($directory->v)) {
  260. return;
  261. }
  262. $directoryArr = json_decode($directory->v, true);
  263. $path = isset($directoryArr['banner_image']) ? $directoryArr['banner_image'] : false;
  264. $protected = [
  265. 'public/headers/.gitignore',
  266. 'public/headers/default.jpg',
  267. 'public/headers/missing.png',
  268. ];
  269. if (! $path || in_array($path, $protected)) {
  270. return;
  271. }
  272. if (Storage::exists($directoryArr['banner_image'])) {
  273. Storage::delete($directoryArr['banner_image']);
  274. }
  275. $directoryArr['banner_image'] = 'public/headers/default.jpg';
  276. $directory->v = $directoryArr;
  277. $directory->save();
  278. $bannerImage->v = url(Storage::url('public/headers/default.jpg'));
  279. $bannerImage->save();
  280. Cache::forget('api:v1:instance-data-response-v1');
  281. ConfigCacheService::put('pixelfed.directory', $directory);
  282. return $bannerImage->v;
  283. }
  284. public function directoryGetPopularPosts(Request $request)
  285. {
  286. $ids = Cache::remember('admin:api:popular_posts', 86400, function () {
  287. return Status::whereLocal(true)
  288. ->whereScope('public')
  289. ->whereType('photo')
  290. ->whereNull(['in_reply_to_id', 'reblog_of_id'])
  291. ->orderByDesc('likes_count')
  292. ->take(50)
  293. ->pluck('id');
  294. });
  295. $res = $ids->map(function ($id) {
  296. return StatusService::get($id);
  297. })
  298. ->filter(function ($post) {
  299. return $post && isset($post['account']);
  300. })
  301. ->values();
  302. return response()->json($res, 200, [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
  303. }
  304. public function directoryGetAddPostByIdSearch(Request $request)
  305. {
  306. $this->validate($request, [
  307. 'q' => 'required|integer',
  308. ]);
  309. $id = $request->input('q');
  310. $status = Status::whereLocal(true)
  311. ->whereType('photo')
  312. ->whereNull(['in_reply_to_id', 'reblog_of_id'])
  313. ->findOrFail($id);
  314. $res = StatusService::get($status->id);
  315. return $res;
  316. }
  317. public function directoryDeleteTestimonial(Request $request)
  318. {
  319. $this->validate($request, [
  320. 'profile_id' => 'required',
  321. ]);
  322. $profile_id = $request->input('profile_id');
  323. $testimonials = ConfigCache::whereK('pixelfed.directory.testimonials')->firstOrFail();
  324. $existing = collect(json_decode($testimonials->v, true))
  325. ->filter(function ($t) use ($profile_id) {
  326. return $t['profile_id'] !== $profile_id;
  327. })
  328. ->values();
  329. ConfigCacheService::put('pixelfed.directory.testimonials', $existing);
  330. return $existing;
  331. }
  332. public function directorySaveTestimonial(Request $request)
  333. {
  334. $this->validate($request, [
  335. 'username' => 'required',
  336. 'body' => 'required|string|min:5|max:500',
  337. ]);
  338. $user = User::whereUsername($request->input('username'))->whereNull('status')->firstOrFail();
  339. $configCache = ConfigCache::firstOrCreate([
  340. 'k' => 'pixelfed.directory.testimonials',
  341. ]);
  342. $testimonials = $configCache->v ? collect(json_decode($configCache->v, true)) : collect([]);
  343. abort_if($testimonials->contains('profile_id', $user->profile_id), 422, 'Testimonial already exists');
  344. abort_if($testimonials->count() == 10, 422, 'You can only have 10 active testimonials');
  345. $testimonials->push([
  346. 'profile_id' => (string) $user->profile_id,
  347. 'username' => $request->input('username'),
  348. 'body' => $request->input('body'),
  349. ]);
  350. $configCache->v = json_encode($testimonials->toArray());
  351. $configCache->save();
  352. ConfigCacheService::put('pixelfed.directory.testimonials', $configCache->v);
  353. $res = [
  354. 'profile' => AccountService::get($user->profile_id),
  355. 'body' => $request->input('body'),
  356. ];
  357. return $res;
  358. }
  359. public function directoryUpdateTestimonial(Request $request)
  360. {
  361. $this->validate($request, [
  362. 'profile_id' => 'required',
  363. 'body' => 'required|string|min:5|max:500',
  364. ]);
  365. $profile_id = $request->input('profile_id');
  366. $body = $request->input('body');
  367. $user = User::whereProfileId($profile_id)->firstOrFail();
  368. $configCache = ConfigCache::firstOrCreate([
  369. 'k' => 'pixelfed.directory.testimonials',
  370. ]);
  371. $testimonials = $configCache->v ? collect(json_decode($configCache->v, true)) : collect([]);
  372. $updated = $testimonials->map(function ($t) use ($profile_id, $body) {
  373. if ($t['profile_id'] == $profile_id) {
  374. $t['body'] = $body;
  375. }
  376. return $t;
  377. })
  378. ->values();
  379. $configCache->v = json_encode($updated);
  380. $configCache->save();
  381. ConfigCacheService::put('pixelfed.directory.testimonials', $configCache->v);
  382. return $updated;
  383. }
  384. }