diff --git a/CHANGELOG.md b/CHANGELOG.md
index 586f1eed6..09aab7fdb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,9 +1,15 @@
# Release Notes
-## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.12.3...dev)
+## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.12.5...dev)
+
+### Added
+- Pinned Posts ([2f655d000](https://github.com/pixelfed/pixelfed/commit/2f655d000))
+
+### Updates
+- Update PublicApiController, use pixelfed entities for /api/pixelfed/v1/accounts/id/statuses with bookmarked state ([5ddb6d842](https://github.com/pixelfed/pixelfed/commit/5ddb6d842))
- ([](https://github.com/pixelfed/pixelfed/commit/))
-## [v0.12.5 (2024-03-23)](https://github.com/pixelfed/pixelfed/compare/v0.12.5...dev)
+## [v0.12.5 (2025-03-23)](https://github.com/pixelfed/pixelfed/compare/v0.12.5...dev)
### Added
- Add app register email verify resends ([dbd1e17](https://github.com/pixelfed/pixelfed/commit/dbd1e17))
diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php
index 7000ace07..9ef510296 100644
--- a/app/Http/Controllers/AccountController.php
+++ b/app/Http/Controllers/AccountController.php
@@ -2,592 +2,592 @@
namespace App\Http\Controllers;
+use App\EmailVerification;
+use App\Follower;
+use App\FollowRequest;
+use App\Jobs\FollowPipeline\FollowAcceptPipeline;
+use App\Jobs\FollowPipeline\FollowPipeline;
+use App\Jobs\FollowPipeline\FollowRejectPipeline;
+use App\Mail\ConfirmEmail;
+use App\Notification;
+use App\Profile;
+use App\Services\AccountService;
+use App\Services\FollowerService;
+use App\Services\NotificationService;
+use App\Services\RelationshipService;
+use App\Services\UserFilterService;
+use App\Transformer\Api\Mastodon\v1\AccountTransformer;
+use App\User;
+use App\UserFilter;
use Auth;
use Cache;
-use Mail;
-use Illuminate\Support\Facades\Redis;
-use Illuminate\Support\Str;
use Carbon\Carbon;
-use App\Mail\ConfirmEmail;
use Illuminate\Http\Request;
-use PragmaRX\Google2FA\Google2FA;
-use App\Jobs\FollowPipeline\FollowPipeline;
-use App\{
- DirectMessage,
- EmailVerification,
- Follower,
- FollowRequest,
- Media,
- Notification,
- Profile,
- User,
- UserDevice,
- UserFilter,
- UserSetting
-};
+use Illuminate\Support\Str;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
-use League\Fractal\Pagination\IlluminatePaginatorAdapter;
-use App\Transformer\Api\Mastodon\v1\AccountTransformer;
-use App\Services\AccountService;
-use App\Services\FollowerService;
-use App\Services\NotificationService;
-use App\Services\UserFilterService;
-use App\Services\RelationshipService;
-use App\Jobs\FollowPipeline\FollowAcceptPipeline;
-use App\Jobs\FollowPipeline\FollowRejectPipeline;
+use Mail;
+use PragmaRX\Google2FA\Google2FA;
class AccountController extends Controller
{
- protected $filters = [
- 'user.mute',
- 'user.block',
- ];
-
- const FILTER_LIMIT_MUTE_TEXT = 'You cannot mute more than ';
- const FILTER_LIMIT_BLOCK_TEXT = 'You cannot block more than ';
-
- public function __construct()
- {
- $this->middleware('auth');
- }
-
- public function notifications(Request $request)
- {
- return view('account.activity');
- }
-
- public function followingActivity(Request $request)
- {
- $this->validate($request, [
- 'page' => 'nullable|min:1|max:3',
- 'a' => 'nullable|alpha_dash',
- ]);
-
- $action = $request->input('a');
- $allowed = ['like', 'follow'];
- $timeago = Carbon::now()->subMonths(3);
-
- $profile = Auth::user()->profile;
- $following = $profile->following->pluck('id');
-
- $notifications = Notification::whereIn('actor_id', $following)
- ->whereIn('action', $allowed)
- ->where('actor_id', '<>', $profile->id)
- ->where('profile_id', '<>', $profile->id)
- ->whereDate('created_at', '>', $timeago)
- ->orderBy('notifications.created_at', 'desc')
- ->simplePaginate(30);
-
- return view('account.following', compact('profile', 'notifications'));
- }
-
- public function verifyEmail(Request $request)
- {
- $recentSent = EmailVerification::whereUserId(Auth::id())
- ->whereDate('created_at', '>', now()->subHours(12))->count();
-
- return view('account.verify_email', compact('recentSent'));
- }
-
- public function sendVerifyEmail(Request $request)
- {
- $recentAttempt = EmailVerification::whereUserId(Auth::id())
- ->whereDate('created_at', '>', now()->subHours(12))->count();
-
- if ($recentAttempt > 0) {
- return redirect()->back()->with('error', 'A verification email has already been sent recently. Please check your email, or try again later.');
- }
-
- EmailVerification::whereUserId(Auth::id())->delete();
-
- $user = User::whereNull('email_verified_at')->find(Auth::id());
- $utoken = Str::uuid() . Str::random(mt_rand(5,9));
- $rtoken = Str::random(mt_rand(64, 70));
-
- $verify = new EmailVerification();
- $verify->user_id = $user->id;
- $verify->email = $user->email;
- $verify->user_token = $utoken;
- $verify->random_token = $rtoken;
- $verify->save();
-
- Mail::to($user->email)->send(new ConfirmEmail($verify));
-
- return redirect()->back()->with('status', 'Verification email sent!');
- }
-
- public function confirmVerifyEmail(Request $request, $userToken, $randomToken)
- {
- $verify = EmailVerification::where('user_token', $userToken)
- ->where('created_at', '>', now()->subHours(24))
- ->where('random_token', $randomToken)
- ->firstOrFail();
-
- if (Auth::id() === $verify->user_id && $verify->user_token === $userToken && $verify->random_token === $randomToken) {
- $user = User::find(Auth::id());
- $user->email_verified_at = Carbon::now();
- $user->save();
-
- return redirect('/');
- } else {
- abort(403);
- }
- }
-
- public function direct()
- {
- return view('account.direct');
- }
-
- public function directMessage(Request $request, $id)
- {
- $profile = Profile::where('id', '!=', $request->user()->profile_id)
- // ->whereNull('domain')
- ->findOrFail($id);
- return view('account.directmessage', compact('id'));
- }
-
- public function mute(Request $request)
- {
- $this->validate($request, [
- 'type' => 'required|string|in:user',
- 'item' => 'required|integer|min:1',
- ]);
-
- $pid = $request->user()->profile_id;
- $count = UserFilterService::muteCount($pid);
- $maxLimit = (int) config_cache('instance.user_filters.max_user_mutes');
- abort_if($count >= $maxLimit, 422, self::FILTER_LIMIT_MUTE_TEXT . $maxLimit . ' accounts');
- if($count == 0) {
- $filterCount = UserFilter::whereUserId($pid)->count();
- abort_if($filterCount >= $maxLimit, 422, self::FILTER_LIMIT_MUTE_TEXT . $maxLimit . ' accounts');
- }
- $type = $request->input('type');
- $item = $request->input('item');
- $action = $type . '.mute';
-
- if (!in_array($action, $this->filters)) {
- return abort(406);
- }
- $filterable = [];
- switch ($type) {
- case 'user':
- $profile = Profile::findOrFail($item);
- if ($profile->id == $pid) {
- return abort(403);
- }
- $class = get_class($profile);
- $filterable['id'] = $profile->id;
- $filterable['type'] = $class;
- break;
- }
-
- $filter = UserFilter::firstOrCreate([
- 'user_id' => $pid,
- 'filterable_id' => $filterable['id'],
- 'filterable_type' => $filterable['type'],
- 'filter_type' => 'mute',
- ]);
-
- UserFilterService::mute($pid, $filterable['id']);
- $res = RelationshipService::refresh($pid, $profile->id);
-
- if($request->wantsJson()) {
- return response()->json($res);
- } else {
- return redirect()->back();
- }
- }
-
- public function unmute(Request $request)
- {
- $this->validate($request, [
- 'type' => 'required|string|in:user',
- 'item' => 'required|integer|min:1',
- ]);
-
- $pid = $request->user()->profile_id;
- $type = $request->input('type');
- $item = $request->input('item');
- $action = $type . '.mute';
-
- if (!in_array($action, $this->filters)) {
- return abort(406);
- }
- $filterable = [];
- switch ($type) {
- case 'user':
- $profile = Profile::findOrFail($item);
- if ($profile->id == $pid) {
- return abort(403);
- }
- $class = get_class($profile);
- $filterable['id'] = $profile->id;
- $filterable['type'] = $class;
- break;
-
- default:
- abort(400);
- break;
- }
-
- $filter = UserFilter::whereUserId($pid)
- ->whereFilterableId($filterable['id'])
- ->whereFilterableType($filterable['type'])
- ->whereFilterType('mute')
- ->first();
-
- if($filter) {
- UserFilterService::unmute($pid, $filterable['id']);
- $filter->delete();
- }
-
- $res = RelationshipService::refresh($pid, $profile->id);
-
- if($request->wantsJson()) {
- return response()->json($res);
- } else {
- return redirect()->back();
- }
- }
-
- public function block(Request $request)
- {
- $this->validate($request, [
- 'type' => 'required|string|in:user',
- 'item' => 'required|integer|min:1',
- ]);
- $pid = $request->user()->profile_id;
- $count = UserFilterService::blockCount($pid);
- $maxLimit = (int) config_cache('instance.user_filters.max_user_blocks');
- abort_if($count >= $maxLimit, 422, self::FILTER_LIMIT_BLOCK_TEXT . $maxLimit . ' accounts');
- if($count == 0) {
- $filterCount = UserFilter::whereUserId($pid)->whereFilterType('block')->count();
- abort_if($filterCount >= $maxLimit, 422, self::FILTER_LIMIT_BLOCK_TEXT . $maxLimit . ' accounts');
- }
- $type = $request->input('type');
- $item = $request->input('item');
- $action = $type.'.block';
- if (!in_array($action, $this->filters)) {
- return abort(406);
- }
- $filterable = [];
- switch ($type) {
- case 'user':
- $profile = Profile::findOrFail($item);
- if ($profile->id == $pid || ($profile->user && $profile->user->is_admin == true)) {
- return abort(403);
- }
- $class = get_class($profile);
- $filterable['id'] = $profile->id;
- $filterable['type'] = $class;
-
- $followed = Follower::whereProfileId($profile->id)->whereFollowingId($pid)->first();
- if($followed) {
- $followed->delete();
- $profile->following_count = Follower::whereProfileId($profile->id)->count();
- $profile->save();
- $selfProfile = $request->user()->profile;
- $selfProfile->followers_count = Follower::whereFollowingId($pid)->count();
- $selfProfile->save();
- FollowerService::remove($profile->id, $pid);
- AccountService::del($pid);
- AccountService::del($profile->id);
- }
-
- $following = Follower::whereProfileId($pid)->whereFollowingId($profile->id)->first();
- if($following) {
- $following->delete();
- $profile->followers_count = Follower::whereFollowingId($profile->id)->count();
- $profile->save();
- $selfProfile = $request->user()->profile;
- $selfProfile->following_count = Follower::whereProfileId($pid)->count();
- $selfProfile->save();
- FollowerService::remove($pid, $profile->pid);
- AccountService::del($pid);
- AccountService::del($profile->id);
- }
-
- Notification::whereProfileId($pid)
- ->whereActorId($profile->id)
- ->get()
- ->map(function($n) use($pid) {
- NotificationService::del($pid, $n['id']);
- $n->forceDelete();
- });
- break;
- }
-
- $filter = UserFilter::firstOrCreate([
- 'user_id' => $pid,
- 'filterable_id' => $filterable['id'],
- 'filterable_type' => $filterable['type'],
- 'filter_type' => 'block',
- ]);
-
- UserFilterService::block($pid, $filterable['id']);
- $res = RelationshipService::refresh($pid, $profile->id);
-
- if($request->wantsJson()) {
- return response()->json($res);
- } else {
- return redirect()->back();
- }
- }
-
- public function unblock(Request $request)
- {
- $this->validate($request, [
- 'type' => 'required|string|in:user',
- 'item' => 'required|integer|min:1',
- ]);
-
- $pid = $request->user()->profile_id;
- $type = $request->input('type');
- $item = $request->input('item');
- $action = $type . '.block';
- if (!in_array($action, $this->filters)) {
- return abort(406);
- }
- $filterable = [];
- switch ($type) {
- case 'user':
- $profile = Profile::findOrFail($item);
- if ($profile->id == $pid) {
- return abort(403);
- }
- $class = get_class($profile);
- $filterable['id'] = $profile->id;
- $filterable['type'] = $class;
- break;
-
- default:
- abort(400);
- break;
- }
-
-
- $filter = UserFilter::whereUserId($pid)
- ->whereFilterableId($filterable['id'])
- ->whereFilterableType($filterable['type'])
- ->whereFilterType('block')
- ->first();
-
- if($filter) {
- $filter->delete();
- UserFilterService::unblock($pid, $filterable['id']);
- }
-
- $res = RelationshipService::refresh($pid, $profile->id);
-
- if($request->wantsJson()) {
- return response()->json($res);
- } else {
- return redirect()->back();
- }
- }
-
- public function followRequests(Request $request)
- {
- $pid = Auth::user()->profile->id;
- $followers = FollowRequest::whereFollowingId($pid)->orderBy('id','desc')->whereIsRejected(0)->simplePaginate(10);
- return view('account.follow-requests', compact('followers'));
- }
-
- public function followRequestsJson(Request $request)
- {
- $pid = Auth::user()->profile_id;
- $followers = FollowRequest::whereFollowingId($pid)->orderBy('id','desc')->whereIsRejected(0)->get();
- $res = [
- 'count' => $followers->count(),
- 'accounts' => $followers->take(10)->map(function($a) {
- $actor = $a->actor;
- return [
- 'rid' => (string) $a->id,
- 'id' => (string) $actor->id,
- 'username' => $actor->username,
- 'avatar' => $actor->avatarUrl(),
- 'url' => $actor->url(),
- 'local' => $actor->domain == null,
- 'account' => AccountService::get($actor->id)
- ];
- })
- ];
- return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
- }
-
- public function followRequestHandle(Request $request)
- {
- $this->validate($request, [
- 'action' => 'required|string|max:10',
- 'id' => 'required|integer|min:1'
- ]);
-
- $pid = Auth::user()->profile->id;
- $action = $request->input('action') === 'accept' ? 'accept' : 'reject';
- $id = $request->input('id');
- $followRequest = FollowRequest::whereFollowingId($pid)->findOrFail($id);
- $follower = $followRequest->follower;
-
- switch ($action) {
- case 'accept':
- $follow = new Follower();
- $follow->profile_id = $follower->id;
- $follow->following_id = $pid;
- $follow->save();
-
- $profile = Profile::findOrFail($pid);
- $profile->followers_count++;
- $profile->save();
- AccountService::del($profile->id);
-
- $profile = Profile::findOrFail($follower->id);
- $profile->following_count++;
- $profile->save();
- AccountService::del($profile->id);
-
- if($follower->domain != null && $follower->private_key === null) {
- FollowAcceptPipeline::dispatch($followRequest)->onQueue('follow');
- } else {
- FollowPipeline::dispatch($follow);
- $followRequest->delete();
- }
- break;
-
- case 'reject':
- if($follower->domain != null && $follower->private_key === null) {
- FollowRejectPipeline::dispatch($followRequest)->onQueue('follow');
- } else {
- $followRequest->delete();
- }
- break;
- }
-
- Cache::forget('profile:follower_count:'.$pid);
- Cache::forget('profile:following_count:'.$pid);
- RelationshipService::refresh($pid, $follower->id);
-
- return response()->json(['msg' => 'success'], 200);
- }
-
- public function sudoMode(Request $request)
- {
- if($request->session()->has('sudoModeAttempts') && $request->session()->get('sudoModeAttempts') >= 3) {
- $request->session()->pull('2fa.session.active');
+ protected $filters = [
+ 'user.mute',
+ 'user.block',
+ ];
+
+ const FILTER_LIMIT_MUTE_TEXT = 'You cannot mute more than ';
+
+ const FILTER_LIMIT_BLOCK_TEXT = 'You cannot block more than ';
+
+ public function __construct()
+ {
+ $this->middleware('auth');
+ }
+
+ public function notifications(Request $request)
+ {
+ return view('account.activity');
+ }
+
+ public function followingActivity(Request $request)
+ {
+ $this->validate($request, [
+ 'page' => 'nullable|min:1|max:3',
+ 'a' => 'nullable|alpha_dash',
+ ]);
+
+ $action = $request->input('a');
+ $allowed = ['like', 'follow'];
+ $timeago = Carbon::now()->subMonths(3);
+
+ $profile = Auth::user()->profile;
+ $following = $profile->following->pluck('id');
+
+ $notifications = Notification::whereIn('actor_id', $following)
+ ->whereIn('action', $allowed)
+ ->where('actor_id', '<>', $profile->id)
+ ->where('profile_id', '<>', $profile->id)
+ ->whereDate('created_at', '>', $timeago)
+ ->orderBy('notifications.created_at', 'desc')
+ ->simplePaginate(30);
+
+ return view('account.following', compact('profile', 'notifications'));
+ }
+
+ public function verifyEmail(Request $request)
+ {
+ $recentSent = EmailVerification::whereUserId(Auth::id())
+ ->whereDate('created_at', '>', now()->subHours(12))->count();
+
+ return view('account.verify_email', compact('recentSent'));
+ }
+
+ public function sendVerifyEmail(Request $request)
+ {
+ $recentAttempt = EmailVerification::whereUserId(Auth::id())
+ ->whereDate('created_at', '>', now()->subHours(12))->count();
+
+ if ($recentAttempt > 0) {
+ return redirect()->back()->with('error', 'A verification email has already been sent recently. Please check your email, or try again later.');
+ }
+
+ EmailVerification::whereUserId(Auth::id())->delete();
+
+ $user = User::whereNull('email_verified_at')->find(Auth::id());
+ $utoken = Str::uuid().Str::random(mt_rand(5, 9));
+ $rtoken = Str::random(mt_rand(64, 70));
+
+ $verify = new EmailVerification;
+ $verify->user_id = $user->id;
+ $verify->email = $user->email;
+ $verify->user_token = $utoken;
+ $verify->random_token = $rtoken;
+ $verify->save();
+
+ Mail::to($user->email)->send(new ConfirmEmail($verify));
+
+ return redirect()->back()->with('status', 'Verification email sent!');
+ }
+
+ public function confirmVerifyEmail(Request $request, $userToken, $randomToken)
+ {
+ $verify = EmailVerification::where('user_token', $userToken)
+ ->where('created_at', '>', now()->subHours(24))
+ ->where('random_token', $randomToken)
+ ->firstOrFail();
+
+ if (Auth::id() === $verify->user_id && $verify->user_token === $userToken && $verify->random_token === $randomToken) {
+ $user = User::find(Auth::id());
+ $user->email_verified_at = Carbon::now();
+ $user->save();
+
+ return redirect('/');
+ } else {
+ abort(403);
+ }
+ }
+
+ public function direct()
+ {
+ return view('account.direct');
+ }
+
+ public function directMessage(Request $request, $id)
+ {
+ $profile = Profile::where('id', '!=', $request->user()->profile_id)
+ ->findOrFail($id);
+
+ return view('account.directmessage', compact('id'));
+ }
+
+ public function mute(Request $request)
+ {
+ $this->validate($request, [
+ 'type' => 'required|string|in:user',
+ 'item' => 'required|integer|min:1',
+ ]);
+
+ $pid = $request->user()->profile_id;
+ $count = UserFilterService::muteCount($pid);
+ $maxLimit = (int) config_cache('instance.user_filters.max_user_mutes');
+ abort_if($count >= $maxLimit, 422, self::FILTER_LIMIT_MUTE_TEXT.$maxLimit.' accounts');
+ if ($count == 0) {
+ $filterCount = UserFilter::whereUserId($pid)->count();
+ abort_if($filterCount >= $maxLimit, 422, self::FILTER_LIMIT_MUTE_TEXT.$maxLimit.' accounts');
+ }
+ $type = $request->input('type');
+ $item = $request->input('item');
+ $action = $type.'.mute';
+
+ if (! in_array($action, $this->filters)) {
+ return abort(406);
+ }
+ $filterable = [];
+ switch ($type) {
+ case 'user':
+ $profile = Profile::findOrFail($item);
+ if ($profile->id == $pid) {
+ return abort(403);
+ }
+ $class = get_class($profile);
+ $filterable['id'] = $profile->id;
+ $filterable['type'] = $class;
+ break;
+ }
+
+ $filter = UserFilter::firstOrCreate([
+ 'user_id' => $pid,
+ 'filterable_id' => $filterable['id'],
+ 'filterable_type' => $filterable['type'],
+ 'filter_type' => 'mute',
+ ]);
+
+ UserFilterService::mute($pid, $filterable['id']);
+ $res = RelationshipService::refresh($pid, $profile->id);
+
+ if ($request->wantsJson()) {
+ return response()->json($res);
+ } else {
+ return redirect()->back();
+ }
+ }
+
+ public function unmute(Request $request)
+ {
+ $this->validate($request, [
+ 'type' => 'required|string|in:user',
+ 'item' => 'required|integer|min:1',
+ ]);
+
+ $pid = $request->user()->profile_id;
+ $type = $request->input('type');
+ $item = $request->input('item');
+ $action = $type.'.mute';
+
+ if (! in_array($action, $this->filters)) {
+ return abort(406);
+ }
+ $filterable = [];
+ switch ($type) {
+ case 'user':
+ $profile = Profile::findOrFail($item);
+ if ($profile->id == $pid) {
+ return abort(403);
+ }
+ $class = get_class($profile);
+ $filterable['id'] = $profile->id;
+ $filterable['type'] = $class;
+ break;
+
+ default:
+ abort(400);
+ break;
+ }
+
+ $filter = UserFilter::whereUserId($pid)
+ ->whereFilterableId($filterable['id'])
+ ->whereFilterableType($filterable['type'])
+ ->whereFilterType('mute')
+ ->first();
+
+ if ($filter) {
+ UserFilterService::unmute($pid, $filterable['id']);
+ $filter->delete();
+ }
+
+ $res = RelationshipService::refresh($pid, $profile->id);
+
+ if ($request->wantsJson()) {
+ return response()->json($res);
+ } else {
+ return redirect()->back();
+ }
+ }
+
+ public function block(Request $request)
+ {
+ $this->validate($request, [
+ 'type' => 'required|string|in:user',
+ 'item' => 'required|integer|min:1',
+ ]);
+ $pid = $request->user()->profile_id;
+ $count = UserFilterService::blockCount($pid);
+ $maxLimit = (int) config_cache('instance.user_filters.max_user_blocks');
+ abort_if($count >= $maxLimit, 422, self::FILTER_LIMIT_BLOCK_TEXT.$maxLimit.' accounts');
+ if ($count == 0) {
+ $filterCount = UserFilter::whereUserId($pid)->whereFilterType('block')->count();
+ abort_if($filterCount >= $maxLimit, 422, self::FILTER_LIMIT_BLOCK_TEXT.$maxLimit.' accounts');
+ }
+ $type = $request->input('type');
+ $item = $request->input('item');
+ $action = $type.'.block';
+ if (! in_array($action, $this->filters)) {
+ return abort(406);
+ }
+ $filterable = [];
+ switch ($type) {
+ case 'user':
+ $profile = Profile::findOrFail($item);
+ if ($profile->id == $pid || ($profile->user && $profile->user->is_admin == true)) {
+ return abort(403);
+ }
+ $class = get_class($profile);
+ $filterable['id'] = $profile->id;
+ $filterable['type'] = $class;
+
+ $followed = Follower::whereProfileId($profile->id)->whereFollowingId($pid)->first();
+ if ($followed) {
+ $followed->delete();
+ $profile->following_count = Follower::whereProfileId($profile->id)->count();
+ $profile->save();
+ $selfProfile = $request->user()->profile;
+ $selfProfile->followers_count = Follower::whereFollowingId($pid)->count();
+ $selfProfile->save();
+ FollowerService::remove($profile->id, $pid);
+ AccountService::del($pid);
+ AccountService::del($profile->id);
+ }
+
+ $following = Follower::whereProfileId($pid)->whereFollowingId($profile->id)->first();
+ if ($following) {
+ $following->delete();
+ $profile->followers_count = Follower::whereFollowingId($profile->id)->count();
+ $profile->save();
+ $selfProfile = $request->user()->profile;
+ $selfProfile->following_count = Follower::whereProfileId($pid)->count();
+ $selfProfile->save();
+ FollowerService::remove($pid, $profile->pid);
+ AccountService::del($pid);
+ AccountService::del($profile->id);
+ }
+
+ Notification::whereProfileId($pid)
+ ->whereActorId($profile->id)
+ ->get()
+ ->map(function ($n) use ($pid) {
+ NotificationService::del($pid, $n['id']);
+ $n->forceDelete();
+ });
+ break;
+ }
+
+ $filter = UserFilter::firstOrCreate([
+ 'user_id' => $pid,
+ 'filterable_id' => $filterable['id'],
+ 'filterable_type' => $filterable['type'],
+ 'filter_type' => 'block',
+ ]);
+
+ UserFilterService::block($pid, $filterable['id']);
+ $res = RelationshipService::refresh($pid, $profile->id);
+
+ if ($request->wantsJson()) {
+ return response()->json($res);
+ } else {
+ return redirect()->back();
+ }
+ }
+
+ public function unblock(Request $request)
+ {
+ $this->validate($request, [
+ 'type' => 'required|string|in:user',
+ 'item' => 'required|integer|min:1',
+ ]);
+
+ $pid = $request->user()->profile_id;
+ $type = $request->input('type');
+ $item = $request->input('item');
+ $action = $type.'.block';
+ if (! in_array($action, $this->filters)) {
+ return abort(406);
+ }
+ $filterable = [];
+ switch ($type) {
+ case 'user':
+ $profile = Profile::findOrFail($item);
+ if ($profile->id == $pid) {
+ return abort(403);
+ }
+ $class = get_class($profile);
+ $filterable['id'] = $profile->id;
+ $filterable['type'] = $class;
+ break;
+
+ default:
+ abort(400);
+ break;
+ }
+
+ $filter = UserFilter::whereUserId($pid)
+ ->whereFilterableId($filterable['id'])
+ ->whereFilterableType($filterable['type'])
+ ->whereFilterType('block')
+ ->first();
+
+ if ($filter) {
+ $filter->delete();
+ UserFilterService::unblock($pid, $filterable['id']);
+ }
+
+ $res = RelationshipService::refresh($pid, $profile->id);
+
+ if ($request->wantsJson()) {
+ return response()->json($res);
+ } else {
+ return redirect()->back();
+ }
+ }
+
+ public function followRequests(Request $request)
+ {
+ $pid = Auth::user()->profile->id;
+ $followers = FollowRequest::whereFollowingId($pid)->orderBy('id', 'desc')->whereIsRejected(0)->simplePaginate(10);
+
+ return view('account.follow-requests', compact('followers'));
+ }
+
+ public function followRequestsJson(Request $request)
+ {
+ $pid = Auth::user()->profile_id;
+ $followers = FollowRequest::whereFollowingId($pid)->orderBy('id', 'desc')->whereIsRejected(0)->get();
+ $res = [
+ 'count' => $followers->count(),
+ 'accounts' => $followers->take(10)->map(function ($a) {
+ $actor = $a->actor;
+
+ return [
+ 'rid' => (string) $a->id,
+ 'id' => (string) $actor->id,
+ 'username' => $actor->username,
+ 'avatar' => $actor->avatarUrl(),
+ 'url' => $actor->url(),
+ 'local' => $actor->domain == null,
+ 'account' => AccountService::get($actor->id),
+ ];
+ }),
+ ];
+
+ return response()->json($res, 200, [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ }
+
+ public function followRequestHandle(Request $request)
+ {
+ $this->validate($request, [
+ 'action' => 'required|string|max:10',
+ 'id' => 'required|integer|min:1',
+ ]);
+
+ $pid = Auth::user()->profile->id;
+ $action = $request->input('action') === 'accept' ? 'accept' : 'reject';
+ $id = $request->input('id');
+ $followRequest = FollowRequest::whereFollowingId($pid)->findOrFail($id);
+ $follower = $followRequest->follower;
+
+ switch ($action) {
+ case 'accept':
+ $follow = new Follower;
+ $follow->profile_id = $follower->id;
+ $follow->following_id = $pid;
+ $follow->save();
+
+ $profile = Profile::findOrFail($pid);
+ $profile->followers_count++;
+ $profile->save();
+ AccountService::del($profile->id);
+
+ $profile = Profile::findOrFail($follower->id);
+ $profile->following_count++;
+ $profile->save();
+ AccountService::del($profile->id);
+
+ if ($follower->domain != null && $follower->private_key === null) {
+ FollowAcceptPipeline::dispatch($followRequest)->onQueue('follow');
+ } else {
+ FollowPipeline::dispatch($follow);
+ $followRequest->delete();
+ }
+ break;
+
+ case 'reject':
+ if ($follower->domain != null && $follower->private_key === null) {
+ FollowRejectPipeline::dispatch($followRequest)->onQueue('follow');
+ } else {
+ $followRequest->delete();
+ }
+ break;
+ }
+
+ Cache::forget('profile:follower_count:'.$pid);
+ Cache::forget('profile:following_count:'.$pid);
+ RelationshipService::refresh($pid, $follower->id);
+
+ return response()->json(['msg' => 'success'], 200);
+ }
+
+ public function sudoMode(Request $request)
+ {
+ if ($request->session()->has('sudoModeAttempts') && $request->session()->get('sudoModeAttempts') >= 3) {
+ $request->session()->pull('2fa.session.active');
$request->session()->pull('redirectNext');
$request->session()->pull('sudoModeAttempts');
Auth::logout();
+
return redirect(route('login'));
}
- return view('auth.sudo');
- }
-
- public function sudoModeVerify(Request $request)
- {
- $this->validate($request, [
- 'password' => 'required|string|max:500',
- 'trustDevice' => 'nullable'
- ]);
-
- $user = Auth::user();
- $password = $request->input('password');
- $trustDevice = $request->input('trustDevice') == 'on';
- $next = $request->session()->get('redirectNext', '/');
- if($request->session()->has('sudoModeAttempts')) {
- $count = (int) $request->session()->get('sudoModeAttempts');
- $request->session()->put('sudoModeAttempts', $count + 1);
- } else {
- $request->session()->put('sudoModeAttempts', 1);
- }
- if(password_verify($password, $user->password) === true) {
- $request->session()->put('sudoMode', time());
- if($trustDevice == true) {
- $request->session()->put('sudoTrustDevice', 1);
- }
-
- //Fix wrong scheme when using reverse proxy
- if(!str_contains($next, 'https') && config('instance.force_https_urls', true)) {
+
+ return view('auth.sudo');
+ }
+
+ public function sudoModeVerify(Request $request)
+ {
+ $this->validate($request, [
+ 'password' => 'required|string|max:500',
+ 'trustDevice' => 'nullable',
+ ]);
+
+ $user = Auth::user();
+ $password = $request->input('password');
+ $trustDevice = $request->input('trustDevice') == 'on';
+ $next = $request->session()->get('redirectNext', '/');
+ if ($request->session()->has('sudoModeAttempts')) {
+ $count = (int) $request->session()->get('sudoModeAttempts');
+ $request->session()->put('sudoModeAttempts', $count + 1);
+ } else {
+ $request->session()->put('sudoModeAttempts', 1);
+ }
+ if (password_verify($password, $user->password) === true) {
+ $request->session()->put('sudoMode', time());
+ if ($trustDevice == true) {
+ $request->session()->put('sudoTrustDevice', 1);
+ }
+
+ // Fix wrong scheme when using reverse proxy
+ if (! str_contains($next, 'https') && config('instance.force_https_urls', true)) {
$next = Str::of($next)->replace('http', 'https')->toString();
}
- return redirect($next);
- } else {
- return redirect()
- ->back()
- ->withErrors(['password' => __('auth.failed')]);
- }
- }
-
- public function twoFactorCheckpoint(Request $request)
- {
- return view('auth.checkpoint');
- }
-
- public function twoFactorVerify(Request $request)
- {
- $this->validate($request, [
- 'code' => 'required|string|max:32'
- ]);
- $user = Auth::user();
- $code = $request->input('code');
- $google2fa = new Google2FA();
- $verify = $google2fa->verifyKey($user->{'2fa_secret'}, $code);
- if($verify) {
- $request->session()->push('2fa.session.active', true);
- return redirect('/');
- } else {
-
- if($this->twoFactorBackupCheck($request, $code, $user)) {
- return redirect('/');
- }
-
- if($request->session()->has('2fa.attempts')) {
- $count = (int) $request->session()->get('2fa.attempts');
- if($count == 3) {
- Auth::logout();
- return redirect('/');
- }
- $request->session()->put('2fa.attempts', $count + 1);
- } else {
- $request->session()->put('2fa.attempts', 1);
- }
- return redirect('/i/auth/checkpoint')->withErrors([
- 'code' => 'Invalid code'
- ]);
- }
- }
-
- protected function twoFactorBackupCheck($request, $code, User $user)
- {
- $backupCodes = $user->{'2fa_backup_codes'};
- if($backupCodes) {
- $codes = json_decode($backupCodes, true);
- foreach ($codes as $c) {
- if(hash_equals($c, $code)) {
- $codes = array_flatten(array_diff($codes, [$code]));
- $user->{'2fa_backup_codes'} = json_encode($codes);
- $user->save();
- $request->session()->push('2fa.session.active', true);
- return true;
- }
- }
+ return redirect($next);
+ } else {
+ return redirect()
+ ->back()
+ ->withErrors(['password' => __('auth.failed')]);
+ }
+ }
+
+ public function twoFactorCheckpoint(Request $request)
+ {
+ return view('auth.checkpoint');
+ }
+
+ public function twoFactorVerify(Request $request)
+ {
+ $this->validate($request, [
+ 'code' => 'required|string|max:32',
+ ]);
+ $user = Auth::user();
+ $code = $request->input('code');
+ $google2fa = new Google2FA;
+ $verify = $google2fa->verifyKey($user->{'2fa_secret'}, $code);
+ if ($verify) {
+ $request->session()->push('2fa.session.active', true);
+
+ return redirect('/');
+ } else {
+
+ if ($this->twoFactorBackupCheck($request, $code, $user)) {
+ return redirect('/');
+ }
+
+ if ($request->session()->has('2fa.attempts')) {
+ $count = (int) $request->session()->get('2fa.attempts');
+ if ($count == 3) {
+ Auth::logout();
+
+ return redirect('/');
+ }
+ $request->session()->put('2fa.attempts', $count + 1);
+ } else {
+ $request->session()->put('2fa.attempts', 1);
+ }
+
+ return redirect('/i/auth/checkpoint')->withErrors([
+ 'code' => 'Invalid code',
+ ]);
+ }
+ }
+
+ protected function twoFactorBackupCheck($request, $code, User $user)
+ {
+ $backupCodes = $user->{'2fa_backup_codes'};
+ if ($backupCodes) {
+ $codes = json_decode($backupCodes, true);
+ foreach ($codes as $c) {
+ if (hash_equals($c, $code)) {
+ $codes = array_flatten(array_diff($codes, [$code]));
+ $user->{'2fa_backup_codes'} = json_encode($codes);
+ $user->save();
+ $request->session()->push('2fa.session.active', true);
+
+ return true;
+ }
+ }
+
return false;
- } else {
- return false;
- }
- }
+ } else {
+ return false;
+ }
+ }
- public function accountRestored(Request $request)
- {
- }
+ public function accountRestored(Request $request) {}
- public function accountMutes(Request $request)
+ public function accountMutes(Request $request)
{
- abort_if(!$request->user(), 403);
+ abort_if(! $request->user(), 403);
$this->validate($request, [
- 'limit' => 'nullable|integer|min:1|max:40'
+ 'limit' => 'nullable|integer|min:1|max:40',
]);
$user = $request->user();
@@ -600,31 +600,32 @@ class AccountController extends Controller
->pluck('filterable_id');
$accounts = Profile::find($mutes);
- $fractal = new Fractal\Manager();
- $fractal->setSerializer(new ArraySerializer());
- $resource = new Fractal\Resource\Collection($accounts, new AccountTransformer());
+ $fractal = new Fractal\Manager;
+ $fractal->setSerializer(new ArraySerializer);
+ $resource = new Fractal\Resource\Collection($accounts, new AccountTransformer);
$res = $fractal->createData($resource)->toArray();
$url = $request->url();
$page = $request->input('page', 1);
$next = $page < 40 ? $page + 1 : 40;
$prev = $page > 1 ? $page - 1 : 1;
$links = '<'.$url.'?page='.$next.'&limit='.$limit.'>; rel="next", <'.$url.'?page='.$prev.'&limit='.$limit.'>; rel="prev"';
+
return response()->json($res, 200, ['Link' => $links]);
}
public function accountBlocks(Request $request)
{
- abort_if(!$request->user(), 403);
+ abort_if(! $request->user(), 403);
$this->validate($request, [
- 'limit' => 'nullable|integer|min:1|max:40',
- 'page' => 'nullable|integer|min:1|max:10'
+ 'limit' => 'nullable|integer|min:1|max:40',
+ 'page' => 'nullable|integer|min:1|max:10',
]);
$user = $request->user();
$limit = $request->input('limit') ?? 40;
- $blocked = UserFilter::select('filterable_id','filterable_type','filter_type','user_id')
+ $blocked = UserFilter::select('filterable_id', 'filterable_type', 'filter_type', 'user_id')
->whereUserId($user->profile_id)
->whereFilterableType('App\Profile')
->whereFilterType('block')
@@ -632,15 +633,16 @@ class AccountController extends Controller
->pluck('filterable_id');
$profiles = Profile::findOrFail($blocked);
- $fractal = new Fractal\Manager();
- $fractal->setSerializer(new ArraySerializer());
- $resource = new Fractal\Resource\Collection($profiles, new AccountTransformer());
+ $fractal = new Fractal\Manager;
+ $fractal->setSerializer(new ArraySerializer);
+ $resource = new Fractal\Resource\Collection($profiles, new AccountTransformer);
$res = $fractal->createData($resource)->toArray();
$url = $request->url();
$page = $request->input('page', 1);
$next = $page < 40 ? $page + 1 : 40;
$prev = $page > 1 ? $page - 1 : 1;
$links = '<'.$url.'?page='.$next.'&limit='.$limit.'>; rel="next", <'.$url.'?page='.$prev.'&limit='.$limit.'>; rel="prev"';
+
return response()->json($res, 200, ['Link' => $links]);
}
diff --git a/app/Http/Controllers/Api/ApiV1Controller.php b/app/Http/Controllers/Api/ApiV1Controller.php
index a6fdaaea5..cb51fdf97 100644
--- a/app/Http/Controllers/Api/ApiV1Controller.php
+++ b/app/Http/Controllers/Api/ApiV1Controller.php
@@ -26,6 +26,7 @@ use App\Jobs\ImageOptimizePipeline\ImageOptimize;
use App\Jobs\LikePipeline\LikePipeline;
use App\Jobs\MediaPipeline\MediaDeletePipeline;
use App\Jobs\MediaPipeline\MediaSyncLicensePipeline;
+use App\Jobs\NotificationPipeline\NotificationWarmUserCache;
use App\Jobs\SharePipeline\SharePipeline;
use App\Jobs\SharePipeline\UndoSharePipeline;
use App\Jobs\StatusPipeline\NewStatusPipeline;
@@ -763,7 +764,8 @@ class ApiV1Controller extends Controller
'reblog_of_id',
'type',
'id',
- 'scope'
+ 'scope',
+ 'pinned_order'
)
->whereProfileId($profile['id'])
->whereNull('in_reply_to_id')
@@ -2387,7 +2389,7 @@ class ApiV1Controller extends Controller
if (empty($res)) {
if (! Cache::has('pf:services:notifications:hasSynced:'.$pid)) {
Cache::put('pf:services:notifications:hasSynced:'.$pid, 1, 1209600);
- NotificationService::warmCache($pid, 400, true);
+ NotificationWarmUserCache::dispatch($pid);
}
}
@@ -4435,4 +4437,61 @@ class ApiV1Controller extends Controller
})
);
}
+
+ /**
+ * GET /api/v1/statuses/{id}/pin
+ */
+ public function statusPin(Request $request, $id)
+ {
+ abort_if(! $request->user(), 403);
+ abort_unless($request->user()->tokenCan('write'), 403);
+ $user = $request->user();
+ $status = Status::whereScope('public')->find($id);
+
+ if (! $status) {
+ return $this->json(['error' => 'Record not found'], 404);
+ }
+
+ if ($status->profile_id != $user->profile_id) {
+ return $this->json(['error' => "Validation failed: Someone else's post cannot be pinned"], 422);
+ }
+
+ $res = StatusService::markPin($status->id);
+
+ if (! $res['success']) {
+ return $this->json([
+ 'error' => $res['error'],
+ ], 422);
+ }
+
+ $statusRes = StatusService::get($status->id, true, true);
+ $status['pinned'] = true;
+
+ return $this->json($statusRes);
+ }
+
+ /**
+ * GET /api/v1/statuses/{id}/unpin
+ */
+ public function statusUnpin(Request $request, $id)
+ {
+ abort_if(! $request->user(), 403);
+ abort_unless($request->user()->tokenCan('write'), 403);
+ $status = Status::whereScope('public')->findOrFail($id);
+ $user = $request->user();
+
+ if ($status->profile_id != $user->profile_id) {
+ return $this->json(['error' => 'Record not found'], 404);
+ }
+
+ $res = StatusService::unmarkPin($status->id);
+ if (! $res) {
+ return $this->json($res, 422);
+ }
+
+ $status = StatusService::get($status->id, true, true);
+ $status['pinned'] = false;
+
+ return $this->json($status);
+ }
}
diff --git a/app/Http/Controllers/Api/BaseApiController.php b/app/Http/Controllers/Api/BaseApiController.php
index 7ac73b4d0..4fd66690e 100644
--- a/app/Http/Controllers/Api/BaseApiController.php
+++ b/app/Http/Controllers/Api/BaseApiController.php
@@ -2,46 +2,22 @@
namespace App\Http\Controllers\Api;
-use Illuminate\Http\Request;
-use App\Http\Controllers\{
- Controller,
- AvatarController
-};
-use Auth, Cache, Storage, URL;
-use Carbon\Carbon;
-use App\{
- Avatar,
- Like,
- Media,
- Notification,
- Profile,
- Status,
- StatusArchived
-};
-use App\Transformer\Api\{
- AccountTransformer,
- NotificationTransformer,
- MediaTransformer,
- MediaDraftTransformer,
- StatusTransformer,
- StatusStatelessTransformer
-};
-use League\Fractal;
-use App\Util\Media\Filter;
-use League\Fractal\Serializer\ArraySerializer;
-use League\Fractal\Pagination\IlluminatePaginatorAdapter;
+use App\Avatar;
+use App\Http\Controllers\AvatarController;
+use App\Http\Controllers\Controller;
use App\Jobs\AvatarPipeline\AvatarOptimize;
-use App\Jobs\ImageOptimizePipeline\ImageOptimize;
-use App\Jobs\VideoPipeline\{
- VideoOptimize,
- VideoPostProcess,
- VideoThumbnail
-};
+use App\Jobs\NotificationPipeline\NotificationWarmUserCache;
use App\Services\AccountService;
use App\Services\NotificationService;
-use App\Services\MediaPathService;
-use App\Services\MediaBlocklistService;
use App\Services\StatusService;
+use App\Status;
+use App\StatusArchived;
+use App\Transformer\Api\StatusStatelessTransformer;
+use Auth;
+use Cache;
+use Illuminate\Http\Request;
+use League\Fractal;
+use League\Fractal\Serializer\ArraySerializer;
class BaseApiController extends Controller
{
@@ -50,47 +26,47 @@ class BaseApiController extends Controller
public function __construct()
{
// $this->middleware('auth');
- $this->fractal = new Fractal\Manager();
- $this->fractal->setSerializer(new ArraySerializer());
+ $this->fractal = new Fractal\Manager;
+ $this->fractal->setSerializer(new ArraySerializer);
}
public function notifications(Request $request)
{
- abort_if(!$request->user(), 403);
-
- $pid = $request->user()->profile_id;
- $limit = $request->input('limit', 20);
-
- $since = $request->input('since_id');
- $min = $request->input('min_id');
- $max = $request->input('max_id');
-
- if(!$since && !$min && !$max) {
- $min = 1;
- }
-
- $maxId = null;
- $minId = null;
-
- if($max) {
- $res = NotificationService::getMax($pid, $max, $limit);
- $ids = NotificationService::getRankedMaxId($pid, $max, $limit);
- if(!empty($ids)) {
- $maxId = max($ids);
- $minId = min($ids);
- }
- } else {
- $res = NotificationService::getMin($pid, $min ?? $since, $limit);
- $ids = NotificationService::getRankedMinId($pid, $min ?? $since, $limit);
- if(!empty($ids)) {
- $maxId = max($ids);
- $minId = min($ids);
- }
- }
-
- if(empty($res) && !Cache::has('pf:services:notifications:hasSynced:'.$pid)) {
- Cache::put('pf:services:notifications:hasSynced:'.$pid, 1, 1209600);
- NotificationService::warmCache($pid, 100, true);
+ abort_if(! $request->user(), 403);
+
+ $pid = $request->user()->profile_id;
+ $limit = $request->input('limit', 20);
+
+ $since = $request->input('since_id');
+ $min = $request->input('min_id');
+ $max = $request->input('max_id');
+
+ if (! $since && ! $min && ! $max) {
+ $min = 1;
+ }
+
+ $maxId = null;
+ $minId = null;
+
+ if ($max) {
+ $res = NotificationService::getMax($pid, $max, $limit);
+ $ids = NotificationService::getRankedMaxId($pid, $max, $limit);
+ if (! empty($ids)) {
+ $maxId = max($ids);
+ $minId = min($ids);
+ }
+ } else {
+ $res = NotificationService::getMin($pid, $min ?? $since, $limit);
+ $ids = NotificationService::getRankedMinId($pid, $min ?? $since, $limit);
+ if (! empty($ids)) {
+ $maxId = max($ids);
+ $minId = min($ids);
+ }
+ }
+
+ if (empty($res) && ! Cache::has('pf:services:notifications:hasSynced:'.$pid)) {
+ Cache::put('pf:services:notifications:hasSynced:'.$pid, 1, 1209600);
+ NotificationWarmUserCache::dispatch($pid);
}
return response()->json($res);
@@ -98,17 +74,17 @@ class BaseApiController extends Controller
public function avatarUpdate(Request $request)
{
- abort_if(!$request->user(), 403);
+ abort_if(! $request->user(), 403);
$this->validate($request, [
- 'upload' => 'required|mimetypes:image/jpeg,image/jpg,image/png|max:'.config('pixelfed.max_avatar_size'),
+ 'upload' => 'required|mimetypes:image/jpeg,image/jpg,image/png|max:'.config('pixelfed.max_avatar_size'),
]);
try {
$user = Auth::user();
$profile = $user->profile;
$file = $request->file('upload');
- $path = (new AvatarController())->getPath($user, $file);
+ $path = (new AvatarController)->getPath($user, $file);
$dir = $path['root'];
$name = $path['name'];
$public = $path['storage'];
@@ -129,13 +105,13 @@ class BaseApiController extends Controller
return response()->json([
'code' => 200,
- 'msg' => 'Avatar successfully updated',
+ 'msg' => 'Avatar successfully updated',
]);
}
public function verifyCredentials(Request $request)
{
- abort_if(!$request->user(), 403);
+ abort_if(! $request->user(), 403);
$user = $request->user();
if ($user->status != null) {
@@ -143,47 +119,51 @@ class BaseApiController extends Controller
abort(403);
}
$res = AccountService::get($user->profile_id);
+
return response()->json($res);
}
public function accountLikes(Request $request)
{
- abort_if(!$request->user(), 403);
+ abort_if(! $request->user(), 403);
$this->validate($request, [
- 'page' => 'sometimes|int|min:1|max:20',
- 'limit' => 'sometimes|int|min:1|max:10'
+ 'page' => 'sometimes|int|min:1|max:20',
+ 'limit' => 'sometimes|int|min:1|max:10',
]);
$user = $request->user();
$limit = $request->input('limit', 10);
$res = \DB::table('likes')
- ->whereProfileId($user->profile_id)
- ->latest()
- ->simplePaginate($limit)
- ->map(function($id) {
- $status = StatusService::get($id->status_id, false);
- $status['favourited'] = true;
- return $status;
- })
- ->filter(function($post) {
- return $post && isset($post['account']);
- })
- ->values();
+ ->whereProfileId($user->profile_id)
+ ->latest()
+ ->simplePaginate($limit)
+ ->map(function ($id) use ($user) {
+ $status = StatusService::get($id->status_id, false);
+ $status['favourited'] = true;
+ $status['reblogged'] = (bool) StatusService::isShared($id->status_id, $user->profile_id);
+
+ return $status;
+ })
+ ->filter(function ($post) {
+ return $post && isset($post['account']);
+ })
+ ->values();
+
return response()->json($res);
}
public function archive(Request $request, $id)
{
- abort_if(!$request->user(), 403);
+ abort_if(! $request->user(), 403);
$status = Status::whereNull('in_reply_to_id')
->whereNull('reblog_of_id')
->whereProfileId($request->user()->profile_id)
->findOrFail($id);
- if($status->scope === 'archived') {
+ if ($status->scope === 'archived') {
return [200];
}
@@ -204,14 +184,14 @@ class BaseApiController extends Controller
public function unarchive(Request $request, $id)
{
- abort_if(!$request->user(), 403);
+ abort_if(! $request->user(), 403);
$status = Status::whereNull('in_reply_to_id')
->whereNull('reblog_of_id')
->whereProfileId($request->user()->profile_id)
->findOrFail($id);
- if($status->scope !== 'archived') {
+ if ($status->scope !== 'archived') {
return [200];
}
@@ -231,16 +211,17 @@ class BaseApiController extends Controller
public function archivedPosts(Request $request)
{
- abort_if(!$request->user(), 403);
+ abort_if(! $request->user(), 403);
$statuses = Status::whereProfileId($request->user()->profile_id)
->whereScope('archived')
->orderByDesc('id')
->simplePaginate(10);
- $fractal = new Fractal\Manager();
- $fractal->setSerializer(new ArraySerializer());
- $resource = new Fractal\Resource\Collection($statuses, new StatusStatelessTransformer());
+ $fractal = new Fractal\Manager;
+ $fractal->setSerializer(new ArraySerializer);
+ $resource = new Fractal\Resource\Collection($statuses, new StatusStatelessTransformer);
+
return $fractal->createData($resource)->toArray();
}
}
diff --git a/app/Http/Controllers/ImportPostController.php b/app/Http/Controllers/ImportPostController.php
index 47456b2b3..e491019f8 100644
--- a/app/Http/Controllers/ImportPostController.php
+++ b/app/Http/Controllers/ImportPostController.php
@@ -103,67 +103,95 @@ class ImportPostController extends Controller
$uid = $request->user()->id;
$pid = $request->user()->profile_id;
+ $successCount = 0;
+ $errors = [];
+
foreach($request->input('files') as $file) {
- $media = $file['media'];
- $c = collect($media);
- $postHash = hash('sha256', $c->toJson());
- $exts = $c->map(function($m) {
- $fn = last(explode('/', $m['uri']));
- return last(explode('.', $fn));
- });
- $postType = 'photo';
-
- if($exts->count() > 1) {
- if($exts->contains('mp4')) {
- if($exts->contains('jpg', 'png')) {
- $postType = 'photo:video:album';
- } else {
- $postType = 'video:album';
- }
- } else {
- $postType = 'photo:album';
+ try {
+ $media = $file['media'];
+ $c = collect($media);
+
+ $firstUri = isset($media[0]['uri']) ? $media[0]['uri'] : '';
+ $postHash = hash('sha256', $c->toJson() . $firstUri);
+
+ $exists = ImportPost::where('user_id', $uid)
+ ->where('post_hash', $postHash)
+ ->exists();
+
+ if ($exists) {
+ $errors[] = "Duplicate post detected. Skipping...";
+ continue;
}
- } else {
- if(in_array($exts[0], ['jpg', 'png'])) {
- $postType = 'photo';
- } else if(in_array($exts[0], ['mp4'])) {
- $postType = 'video';
+
+ $exts = $c->map(function($m) {
+ $fn = last(explode('/', $m['uri']));
+ return last(explode('.', $fn));
+ });
+
+ $postType = $this->determinePostType($exts);
+
+ $ip = new ImportPost;
+ $ip->user_id = $uid;
+ $ip->profile_id = $pid;
+ $ip->post_hash = $postHash;
+ $ip->service = 'instagram';
+ $ip->post_type = $postType;
+ $ip->media_count = $c->count();
+
+ $ip->media = $c->map(function($m) {
+ return [
+ 'uri' => $m['uri'],
+ 'title' => $this->formatHashtags($m['title'] ?? ''),
+ 'creation_timestamp' => $m['creation_timestamp'] ?? null
+ ];
+ })->toArray();
+
+ $ip->caption = $c->count() > 1 ?
+ $this->formatHashtags($file['title'] ?? '') :
+ $this->formatHashtags($ip->media[0]['title'] ?? '');
+
+ $originalFilename = last(explode('/', $ip->media[0]['uri'] ?? ''));
+ $ip->filename = $this->sanitizeFilename($originalFilename);
+
+ $ip->metadata = $c->map(function($m) {
+ return [
+ 'uri' => $m['uri'],
+ 'media_metadata' => isset($m['media_metadata']) ? $m['media_metadata'] : null
+ ];
+ })->toArray();
+
+ $creationTimestamp = $c->count() > 1 ?
+ ($file['creation_timestamp'] ?? null) :
+ ($media[0]['creation_timestamp'] ?? null);
+
+ if ($creationTimestamp) {
+ $ip->creation_date = now()->parse($creationTimestamp);
+ $ip->creation_year = $ip->creation_date->format('y');
+ $ip->creation_month = $ip->creation_date->format('m');
+ $ip->creation_day = $ip->creation_date->format('d');
+ } else {
+ $ip->creation_date = now();
+ $ip->creation_year = now()->format('y');
+ $ip->creation_month = now()->format('m');
+ $ip->creation_day = now()->format('d');
}
- }
- $ip = new ImportPost;
- $ip->user_id = $uid;
- $ip->profile_id = $pid;
- $ip->post_hash = $postHash;
- $ip->service = 'instagram';
- $ip->post_type = $postType;
- $ip->media_count = $c->count();
- $ip->media = $c->map(function($m) {
- return [
- 'uri' => $m['uri'],
- 'title' => $this->formatHashtags($m['title']),
- 'creation_timestamp' => $m['creation_timestamp']
- ];
- })->toArray();
- $ip->caption = $c->count() > 1 ? $this->formatHashtags($file['title']) : $this->formatHashtags($ip->media[0]['title']);
- $ip->filename = last(explode('/', $ip->media[0]['uri']));
- $ip->metadata = $c->map(function($m) {
- return [
- 'uri' => $m['uri'],
- 'media_metadata' => isset($m['media_metadata']) ? $m['media_metadata'] : null
- ];
- })->toArray();
- $ip->creation_date = $c->count() > 1 ? now()->parse($file['creation_timestamp']) : now()->parse($media[0]['creation_timestamp']);
- $ip->creation_year = now()->parse($ip->creation_date)->format('y');
- $ip->creation_month = now()->parse($ip->creation_date)->format('m');
- $ip->creation_day = now()->parse($ip->creation_date)->format('d');
- $ip->save();
-
- ImportService::getImportedFiles($pid, true);
- ImportService::getPostCount($pid, true);
+ $ip->save();
+ $successCount++;
+
+ ImportService::getImportedFiles($pid, true);
+ ImportService::getPostCount($pid, true);
+ } catch (\Exception $e) {
+ $errors[] = $e->getMessage();
+ \Log::error('Import error: ' . $e->getMessage());
+ continue;
+ }
}
+
return [
- 'msg' => 'Success'
+ 'success' => true,
+ 'msg' => 'Successfully imported ' . $successCount . ' posts',
+ 'errors' => $errors
];
}
@@ -173,7 +201,17 @@ class ImportPostController extends Controller
$this->checkPermissions($request);
- $mimes = config('import.instagram.allow_video_posts') ? 'mimetypes:image/png,image/jpeg,video/mp4' : 'mimetypes:image/png,image/jpeg';
+ $allowedMimeTypes = ['image/png', 'image/jpeg'];
+
+ if (config('import.instagram.allow_image_webp') && str_contains(config_cache('pixelfed.media_types'), 'image/webp')) {
+ $allowedMimeTypes[] = 'image/webp';
+ }
+
+ if (config('import.instagram.allow_video_posts')) {
+ $allowedMimeTypes[] = 'video/mp4';
+ }
+
+ $mimes = 'mimetypes:' . implode(',', $allowedMimeTypes);
$this->validate($request, [
'file' => 'required|array|max:10',
@@ -186,7 +224,12 @@ class ImportPostController extends Controller
]);
foreach($request->file('file') as $file) {
- $fileName = $file->getClientOriginalName();
+ $extension = $file->getClientOriginalExtension();
+
+ $originalName = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
+ $safeFilename = preg_replace('/[^a-zA-Z0-9_.-]/', '_', $originalName);
+ $fileName = $safeFilename . '.' . $extension;
+
$file->storeAs('imports/' . $request->user()->id . '/', $fileName);
}
@@ -197,6 +240,46 @@ class ImportPostController extends Controller
];
}
+
+ private function determinePostType($exts)
+ {
+ if ($exts->count() > 1) {
+ if ($exts->contains('mp4')) {
+ if ($exts->contains('jpg', 'png', 'webp')) {
+ return 'photo:video:album';
+ } else {
+ return 'video:album';
+ }
+ } else {
+ return 'photo:album';
+ }
+ } else {
+ if ($exts->isEmpty()) {
+ return 'photo';
+ }
+
+ $ext = $exts[0];
+
+ if (in_array($ext, ['jpg', 'jpeg', 'png', 'webp'])) {
+ return 'photo';
+ } else if (in_array($ext, ['mp4'])) {
+ return 'video';
+ } else {
+ return 'photo';
+ }
+ }
+ }
+
+ private function sanitizeFilename($filename)
+ {
+ $parts = explode('.', $filename);
+ $extension = array_pop($parts);
+ $originalName = implode('.', $parts);
+
+ $safeFilename = preg_replace('/[^a-zA-Z0-9_.-]/', '_', $originalName);
+ return $safeFilename . '.' . $extension;
+ }
+
protected function checkPermissions($request, $abortOnFail = true)
{
$user = $request->user();
diff --git a/app/Http/Controllers/PublicApiController.php b/app/Http/Controllers/PublicApiController.php
index c83fb2a53..bd9a80e68 100644
--- a/app/Http/Controllers/PublicApiController.php
+++ b/app/Http/Controllers/PublicApiController.php
@@ -35,6 +35,11 @@ class PublicApiController extends Controller
$this->fractal->setSerializer(new ArraySerializer);
}
+ public function json($res, $code = 200, $headers = [])
+ {
+ return response()->json($res, $code, $headers, JSON_UNESCAPED_SLASHES);
+ }
+
protected function getUserData($user)
{
if (! $user) {
@@ -667,10 +672,8 @@ class PublicApiController extends Controller
'only_media' => 'nullable',
'pinned' => 'nullable',
'exclude_replies' => 'nullable',
- 'max_id' => 'nullable|integer|min:0|max:'.PHP_INT_MAX,
- 'since_id' => 'nullable|integer|min:0|max:'.PHP_INT_MAX,
- 'min_id' => 'nullable|integer|min:0|max:'.PHP_INT_MAX,
'limit' => 'nullable|integer|min:1|max:24',
+ 'cursor' => 'nullable',
]);
$user = $request->user();
@@ -683,83 +686,198 @@ class PublicApiController extends Controller
}
$limit = $request->limit ?? 9;
- $max_id = $request->max_id;
- $min_id = $request->min_id;
$scope = ['photo', 'photo:album', 'video', 'video:album'];
$onlyMedia = $request->input('only_media', true);
+ $pinned = $request->filled('pinned') && $request->boolean('pinned') == true;
+ $hasCursor = $request->filled('cursor');
+
+ $visibility = $this->determineVisibility($profile, $user);
+
+ if (empty($visibility)) {
+ return response()->json([]);
+ }
+
+ $result = collect();
+ $remainingLimit = $limit;
+
+ if ($pinned && ! $hasCursor) {
+ $pinnedStatuses = Status::whereProfileId($profile['id'])
+ ->whereNotNull('pinned_order')
+ ->orderBy('pinned_order')
+ ->get();
+
+ $pinnedResult = $this->processStatuses($pinnedStatuses, $user, $onlyMedia);
+ $result = $pinnedResult;
+
+ $remainingLimit = max(1, $limit - $pinnedResult->count());
+ }
+
+ $paginator = Status::whereProfileId($profile['id'])
+ ->whereNull('in_reply_to_id')
+ ->whereNull('reblog_of_id')
+ ->when($pinned, function ($query) {
+ return $query->whereNull('pinned_order');
+ })
+ ->whereIn('type', $scope)
+ ->whereIn('scope', $visibility)
+ ->orderByDesc('id')
+ ->cursorPaginate($remainingLimit)
+ ->withQueryString();
+
+ $headers = $this->generatePaginationHeaders($paginator);
+ $regularStatuses = $this->processStatuses($paginator->items(), $user, $onlyMedia);
+ $result = $result->concat($regularStatuses);
+
+ return response()->json($result, 200, $headers);
+ }
+
+ /**
+ * GET /api/pixelfed/v1/statuses/{id}/pin
+ */
+ public function statusPin(Request $request, $id)
+ {
+ abort_if(! $request->user(), 403);
+ $user = $request->user();
+ $status = Status::whereScope('public')->find($id);
+
+ if (! $status) {
+ return $this->json(['error' => 'Record not found'], 404);
+ }
+
+ if ($status->profile_id != $user->profile_id) {
+ return $this->json(['error' => "Validation failed: Someone else's post cannot be pinned"], 422);
+ }
+
+ $res = StatusService::markPin($status->id);
+
+ if (! $res['success']) {
+ return $this->json([
+ 'error' => $res['error'],
+ ], 422);
+ }
+
+ $statusRes = StatusService::get($status->id, true, true);
+ $status['pinned'] = true;
+
+ return $this->json($statusRes);
+ }
+
+ /**
+ * GET /api/pixelfed/v1/statuses/{id}/unpin
+ */
+ public function statusUnpin(Request $request, $id)
+ {
+ abort_if(! $request->user(), 403);
+ $status = Status::whereScope('public')->findOrFail($id);
+ $user = $request->user();
+
+ if ($status->profile_id != $user->profile_id) {
+ return $this->json(['error' => 'Record not found'], 404);
+ }
- if (! $min_id && ! $max_id) {
- $min_id = 1;
+ $res = StatusService::unmarkPin($status->id);
+ if (! $res) {
+ return $this->json($res, 422);
+ }
+
+ $status = StatusService::get($status->id, true, true);
+ $status['pinned'] = false;
+
+ return $this->json($status);
+ }
+
+ private function determineVisibility($profile, $user)
+ {
+ if (! $user || ! isset($user->profile_id)) {
+ return [];
+ }
+
+ if (! $profile || ! isset($profile['id'])) {
+ return [];
+ }
+
+ if ($profile['id'] == $user->profile_id) {
+ return ['public', 'unlisted', 'private'];
}
if ($profile['locked']) {
if (! $user) {
- return response()->json([]);
+ return [];
}
+
$pid = $user->profile_id;
- $following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function () use ($pid) {
- $following = Follower::whereProfileId($pid)->pluck('following_id');
+ $isFollowing = FollowerService::follows($pid, $profile['id']);
- return $following->push($pid)->toArray();
- });
- $visibility = in_array($profile['id'], $following) == true ? ['public', 'unlisted', 'private'] : [];
+ return $isFollowing ? ['public', 'unlisted', 'private'] : ['public'];
} else {
if ($user) {
$pid = $user->profile_id;
- $following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function () use ($pid) {
- $following = Follower::whereProfileId($pid)->pluck('following_id');
+ $isFollowing = FollowerService::follows($pid, $profile['id']);
- return $following->push($pid)->toArray();
- });
- $visibility = in_array($profile['id'], $following) == true ? ['public', 'unlisted', 'private'] : ['public', 'unlisted'];
+ return $isFollowing ? ['public', 'unlisted', 'private'] : ['public', 'unlisted'];
} else {
- $visibility = ['public', 'unlisted'];
+ return ['public', 'unlisted'];
}
}
- $dir = $min_id ? '>' : '<';
- $id = $min_id ?? $max_id;
- $res = Status::whereProfileId($profile['id'])
- ->whereNull('in_reply_to_id')
- ->whereNull('reblog_of_id')
- ->whereIn('type', $scope)
- ->where('id', $dir, $id)
- ->whereIn('scope', $visibility)
- ->limit($limit)
- ->orderByDesc('id')
- ->get()
- ->map(function ($s) use ($user) {
- try {
- $status = StatusService::get($s->id, false);
- if (! $status) {
- return false;
- }
- } catch (\Exception $e) {
- $status = false;
+ }
+
+ private function processStatuses($statuses, $user, $onlyMedia)
+ {
+ return collect($statuses)->map(function ($status) use ($user) {
+ try {
+ $mastodonStatus = StatusService::get($status->id, false);
+ if (! $mastodonStatus) {
+ return null;
}
- if ($user && $status) {
- $status['favourited'] = (bool) LikeService::liked($user->profile_id, $s->id);
+
+ if ($user) {
+ $mastodonStatus['favourited'] = (bool) LikeService::liked($user->profile_id, $status->id);
+ $mastodonStatus['bookmarked'] = (bool) BookmarkService::get($user->profile_id, $status->id);
+ $mastodonStatus['reblogged'] = (bool) StatusService::isShared($status->id, $user->profile_id);
}
- return $status;
- })
- ->filter(function ($s) use ($onlyMedia) {
- if (! $s) {
+ return $mastodonStatus;
+ } catch (\Exception $e) {
+ return null;
+ }
+ })
+ ->filter(function ($status) use ($onlyMedia) {
+ if (! $status) {
return false;
}
+
if ($onlyMedia) {
- if (
- ! isset($s['media_attachments']) ||
- ! is_array($s['media_attachments']) ||
- empty($s['media_attachments'])
- ) {
- return false;
- }
+ return isset($status['media_attachments']) &&
+ is_array($status['media_attachments']) &&
+ ! empty($status['media_attachments']);
}
- return $s;
+ return true;
})
->values();
+ }
- return response()->json($res);
+ /**
+ * Generate pagination link headers from paginator
+ */
+ private function generatePaginationHeaders($paginator)
+ {
+ $link = null;
+
+ if ($paginator->onFirstPage()) {
+ if ($paginator->hasMorePages()) {
+ $link = '<'.$paginator->nextPageUrl().'>; rel="prev"';
+ }
+ } else {
+ if ($paginator->previousPageUrl()) {
+ $link = '<'.$paginator->previousPageUrl().'>; rel="next"';
+ }
+
+ if ($paginator->hasMorePages()) {
+ $link .= ($link ? ', ' : '').'<'.$paginator->nextPageUrl().'>; rel="prev"';
+ }
+ }
+
+ return isset($link) ? ['Link' => $link] : [];
}
}
diff --git a/app/Jobs/NotificationPipeline/NotificationWarmUserCache.php b/app/Jobs/NotificationPipeline/NotificationWarmUserCache.php
new file mode 100644
index 000000000..8cf4b5aaa
--- /dev/null
+++ b/app/Jobs/NotificationPipeline/NotificationWarmUserCache.php
@@ -0,0 +1,90 @@
+pid = $pid;
+ }
+
+ /**
+ * Get the unique ID for the job.
+ */
+ public function uniqueId(): string
+ {
+ return 'notifications:profile_warm_cache:'.$this->pid;
+ }
+
+ /**
+ * Execute the job.
+ */
+ public function handle(): void
+ {
+ try {
+ NotificationService::warmCache($this->pid, 100, true);
+ } catch (\Exception $e) {
+ Log::error('Failed to warm notification cache', [
+ 'profile_id' => $this->pid,
+ 'exception' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'attempt' => $this->attempts(),
+ ]);
+ throw $e;
+ }
+ }
+}
diff --git a/app/Services/StatusService.php b/app/Services/StatusService.php
index de2f4d112..014c76aba 100644
--- a/app/Services/StatusService.php
+++ b/app/Services/StatusService.php
@@ -12,6 +12,8 @@ class StatusService
{
const CACHE_KEY = 'pf:services:status:v1.1:';
+ const MAX_PINNED = 3;
+
public static function key($id, $publicOnly = true)
{
$p = $publicOnly ? 'pub:' : 'all:';
@@ -82,7 +84,6 @@ class StatusService
$status['shortcode'],
$status['taggedPeople'],
$status['thread'],
- $status['pinned'],
$status['account']['header_bg'],
$status['account']['is_admin'],
$status['account']['last_fetched_at'],
@@ -198,4 +199,89 @@ class StatusService
{
return InstanceService::totalLocalStatuses();
}
+
+ public static function isPinned($id)
+ {
+ return Status::whereId($id)->whereNotNull('pinned_order')->exists();
+ }
+
+ public static function totalPins($pid)
+ {
+ return Status::whereProfileId($pid)->whereNotNull('pinned_order')->count();
+ }
+
+ public static function markPin($id)
+ {
+ $status = Status::find($id);
+
+ if (! $status) {
+ return [
+ 'success' => false,
+ 'error' => 'Record not found',
+ ];
+ }
+
+ if ($status->scope != 'public') {
+ return [
+ 'success' => false,
+ 'error' => 'Validation failed: you can only pin public posts',
+ ];
+ }
+
+ if (self::isPinned($id)) {
+ return [
+ 'success' => false,
+ 'error' => 'This post is already pinned',
+ ];
+ }
+
+ $totalPins = self::totalPins($status->profile_id);
+
+ if ($totalPins >= self::MAX_PINNED) {
+ return [
+ 'success' => false,
+ 'error' => 'Validation failed: You have already pinned the max number of posts',
+ ];
+ }
+
+ $status->pinned_order = $totalPins + 1;
+ $status->save();
+
+ self::refresh($id);
+
+ return [
+ 'success' => true,
+ 'error' => null,
+ ];
+ }
+
+ public static function unmarkPin($id)
+ {
+ $status = Status::find($id);
+
+ if (! $status || is_null($status->pinned_order)) {
+ return false;
+ }
+
+ $removedOrder = $status->pinned_order;
+ $profileId = $status->profile_id;
+
+ $status->pinned_order = null;
+ $status->save();
+
+ Status::where('profile_id', $profileId)
+ ->whereNotNull('pinned_order')
+ ->where('pinned_order', '>', $removedOrder)
+ ->orderBy('pinned_order', 'asc')
+ ->chunk(10, function ($statuses) {
+ foreach ($statuses as $s) {
+ $s->pinned_order = $s->pinned_order - 1;
+ $s->save();
+ }
+ });
+
+ self::refresh($id);
+
+ return true;
+ }
}
diff --git a/app/Transformer/Api/StatusStatelessTransformer.php b/app/Transformer/Api/StatusStatelessTransformer.php
index 9f52ab50a..9de16465b 100644
--- a/app/Transformer/Api/StatusStatelessTransformer.php
+++ b/app/Transformer/Api/StatusStatelessTransformer.php
@@ -69,6 +69,7 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract
'tags' => StatusHashtagService::statusTags($status->id),
'poll' => $poll,
'edited_at' => $status->edited_at ? str_replace('+00:00', 'Z', $status->edited_at->format(DATE_RFC3339_EXTENDED)) : null,
+ 'pinned' => (bool) $status->pinned_order,
];
}
}
diff --git a/app/Transformer/Api/StatusTransformer.php b/app/Transformer/Api/StatusTransformer.php
index 4a6dc5d7d..4c8520628 100644
--- a/app/Transformer/Api/StatusTransformer.php
+++ b/app/Transformer/Api/StatusTransformer.php
@@ -71,6 +71,7 @@ class StatusTransformer extends Fractal\TransformerAbstract
'poll' => $poll,
'bookmarked' => BookmarkService::get($pid, $status->id),
'edited_at' => $status->edited_at ? str_replace('+00:00', 'Z', $status->edited_at->format(DATE_RFC3339_EXTENDED)) : null,
+ 'pinned' => (bool) $status->pinned_order,
];
}
}
diff --git a/database/migrations/2025_03_19_022553_add_pinned_columns_statuses_table.php b/database/migrations/2025_03_19_022553_add_pinned_columns_statuses_table.php
new file mode 100644
index 000000000..b7bdc684a
--- /dev/null
+++ b/database/migrations/2025_03_19_022553_add_pinned_columns_statuses_table.php
@@ -0,0 +1,30 @@
+tinyInteger('pinned_order')->nullable()->default(null)->index();
+
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ //
+ Schema::table('statuses', function (Blueprint $table) {
+ $table->dropColumn('pinned_order');
+ });
+ }
+};
diff --git a/docker/README.md b/docker/README.md
index 5230f60fd..5598908c6 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -1,5 +1,5 @@
# Pixelfed + Docker + Docker Compose
-Please see the [Pixelfed Docs (Next)](https://jippi.github.io/pixelfed-docs-next/pr-preview/pr-1/running-pixelfed/) for current documentation on Docker usage.
+Please see the [Pixelfed Docs (Next)](https://jippi.github.io/docker-pixelfed/) for current documentation on Docker usage.
The docs can be [reviewed in the pixelfed/docs-next](https://github.com/pixelfed/docs-next/pull/1) repository.
diff --git a/public/js/account-import.js b/public/js/account-import.js
index 7ed0a3999..e0a4ba6f3 100644
--- a/public/js/account-import.js
+++ b/public/js/account-import.js
@@ -1,2 +1,2 @@
/*! For license information please see account-import.js.LICENSE.txt */
-(self.webpackChunkpixelfed=self.webpackChunkpixelfed||[]).push([[9139],{5316:(t,e,n)=>{"use strict";n.r(e),n.d(e,{default:()=>a});var i=n(11887),r={};for(const t in i)"default"!==t&&(r[t]=()=>i[t]);n.d(e,r);const a=i.default},10889:(t,e,n)=>{"use strict";n.r(e),n.d(e,{default:()=>s});var i=n(85072),r=n.n(i),a=n(52888),o={insert:"head",singleton:!1};r()(a.default,o);const s=a.default.locals||{}},11887:(t,e,n)=>{"use strict";n.r(e),n.d(e,{default:()=>u});var i=n(58285);function r(t){return r="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},r(t)}function a(t){return function(t){if(Array.isArray(t))return o(t)}(t)||function(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)}(t)||function(t,e){if(t){if("string"==typeof t)return o(t,e);var n={}.toString.call(t).slice(8,-1);return"Object"===n&&t.constructor&&(n=t.constructor.name),"Map"===n||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?o(t,e):void 0}}(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function o(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,i=Array(e);n
{{ $t('profile.emptyPosts') }}
-This profile is private
-{{ $t('profile.emptyCollections') }}
-We can't seem to find any posts you have liked
-We can't seem to find any posts you have bookmarked
-We can't seem to find any posts you have archived
-{{ $t('profile.emptyPosts') }}
+{{ $t("profile.private")}}
+{{ $t('profile.emptyCollections') }}
+{{ $t("profile.emptyLikes")}}
+{{ $t("profile.emptyBookmarks")}}
+{{ $t("profile.emptyArchives") }}
+- Follows you - Muted - Blocked + {{ $t("profile.followYou")}} + {{ $t("profile.muted")}} + {{ $t("profile.blocked") }}
@@ -145,7 +145,7 @@ --> {{ $t('profile.editProfile') }} - My Portfolio + {{ $t("profile.myPortifolio") }} NEW @@ -421,10 +421,10 @@ }, getJoinedDate() { - let d = new Date(this.profile.created_at); - let month = new Intl.DateTimeFormat("en-US", { month: "long" }).format(d); - let year = d.getFullYear(); - return `${month} ${year}`; + return new Date(this.profile.created_at).toLocaleDateString(this.$i18n.locale, { + year: 'numeric', + month: 'long', + }); }, follow() { diff --git a/resources/assets/components/partials/timeline/Notification.vue b/resources/assets/components/partials/timeline/Notification.vue index 8f6a011a4..078717a6a 100644 --- a/resources/assets/components/partials/timeline/Notification.vue +++ b/resources/assets/components/partials/timeline/Notification.vue @@ -7,13 +7,13 @@- @{{n.account.acct}} {{ $t('notifications.liked') }} post. + @{{n.account.acct}} {{ $t('notifications.liked') }} {{ $t("notifications.post")}}.
- @{{n.account.acct}} {{ $t('notifications.commented') }} post. + @{{n.account.acct}} {{ $t('notifications.commented') }} {{ $t("notifications.post")}}.
- @{{n.account.acct}} {{ $t('notifications.reacted') }} story. + @{{n.account.acct}} {{ $t('notifications.reacted') }} {{ $t('notifications.story') }}.
{{formatWebsite(profile.website)}}
- Admin + {{ $t("profile.admin") }} - Follows You + {{ $t("profile.followYou") }} - Joined {{joinedAtFormat(profile.created_at)}} + {{$t("profile.joined")}} {{joinedAtFormat(profile.created_at)}}
@@ -156,7 +156,7 @@- +