|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Models;
|
|
|
|
|
|
|
|
|
|
use App\HasSnowflakePrimary;
|
|
|
|
|
use App\Services\FollowerService;
|
|
|
|
|
use App\Util\Lexer\PrettyNumber;
|
feat: add critical path test suite and fix auth/config issues
Test Infrastructure:
- Modernize phpunit.xml (bootstrap, source block, Laravel 12 env vars)
- Configure tests/Pest.php with pest()->extend(TestCase::class)->in('Feature')
- Add docker-compose.test.yml (Redis for test suite)
- Add composer test/test:quick scripts
- Rename CACHE_DRIVER to CACHE_STORE across config (backwards compatible)
- Update .env.testing for in-memory SQLite + Docker Redis
Test Coverage (190 tests):
- CriticalRoutes: public routes, auth routes, API endpoints, middleware, schedule
- Auth/LoginTest: login, logout, rate limiting, redirect behavior
- Auth/RegisterTest: registration flow, validation, disabled registration
- Auth/PasswordResetTest: reset request, token validation, password update
- Auth/TwoFactorTest: 2FA checkpoint, setup behind password confirmation
- Auth/PasswordConfirmationTest: sudo mode flow via Laravel password.confirm
- Api/ScopeTest: scope enforcement, public endpoints, admin access
Bugs Fixed:
- Fix unauthenticated API returning 500 instead of 401 (AuthenticationException
not handled in custom exception renderer in bootstrap/app.php)
- Replace custom DangerZone middleware with Laravel password.confirm
- Add HasFactory trait to Profile model for test factories
Bugs Documented (known-bugs group):
- Registration crashes with str_ends_with TypeError (RegisterController:82)
- OAuth routes use legacy array syntax causing ReflectionFunction TypeError
4 weeks ago
|
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
|
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
fix: add return type declarations to Eloquent relation methods
Larastan 3.x requires explicit return types on relation methods to
verify relation existence when using with(), has(), etc. This adds
the appropriate return type declarations to all relation methods
flagged by the larastan.relationExistence rule.
Models fixed:
- Profile (avatar, statuses)
- User (profile)
- Status (profile, media, hashtags)
- DirectMessage (status, author, recipient)
- Report (reporter, status, reportedUser)
- Like (actor, status)
- Media (status)
- Notification (item)
- HashtagFollow (hashtag)
- OauthClient (user)
- Story (profile)
- StatusHashtag (status, hashtag, profile, media)
- AccountInterstitial (user)
- Hashtag (posts)
- CustomFilter (keywords)
- CustomFilterKeyword (customFilter)
- AdminShadowFilter (profile)
- ImportPost (status)
4 weeks ago
|
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
|
|
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
|
|
|
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
|
|
|
use Illuminate\Support\Carbon;
|
|
|
|
|
use Illuminate\Support\Facades\Cache;
|
|
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @property int $id
|
|
|
|
|
* @property int|null $user_id
|
|
|
|
|
* @property string $username
|
|
|
|
|
* @property string|null $name
|
|
|
|
|
* @property string|null $domain
|
|
|
|
|
* @property string|null $remote_url
|
|
|
|
|
* @property bool $is_private
|
|
|
|
|
* @property int $status_count
|
|
|
|
|
* @property int $following_count
|
|
|
|
|
* @property int $followers_count
|
|
|
|
|
* @property Carbon|null $last_fetched_at
|
|
|
|
|
* @property Carbon|null $last_status_at
|
|
|
|
|
* @property Carbon|null $created_at
|
|
|
|
|
* @property Carbon|null $updated_at
|
|
|
|
|
* @property Carbon|null $deleted_at
|
|
|
|
|
* @property-read User|null $user
|
|
|
|
|
* @property-read Avatar $avatar
|
|
|
|
|
*/
|
|
|
|
|
class Profile extends Model
|
|
|
|
|
{
|
feat: add critical path test suite and fix auth/config issues
Test Infrastructure:
- Modernize phpunit.xml (bootstrap, source block, Laravel 12 env vars)
- Configure tests/Pest.php with pest()->extend(TestCase::class)->in('Feature')
- Add docker-compose.test.yml (Redis for test suite)
- Add composer test/test:quick scripts
- Rename CACHE_DRIVER to CACHE_STORE across config (backwards compatible)
- Update .env.testing for in-memory SQLite + Docker Redis
Test Coverage (190 tests):
- CriticalRoutes: public routes, auth routes, API endpoints, middleware, schedule
- Auth/LoginTest: login, logout, rate limiting, redirect behavior
- Auth/RegisterTest: registration flow, validation, disabled registration
- Auth/PasswordResetTest: reset request, token validation, password update
- Auth/TwoFactorTest: 2FA checkpoint, setup behind password confirmation
- Auth/PasswordConfirmationTest: sudo mode flow via Laravel password.confirm
- Api/ScopeTest: scope enforcement, public endpoints, admin access
Bugs Fixed:
- Fix unauthenticated API returning 500 instead of 401 (AuthenticationException
not handled in custom exception renderer in bootstrap/app.php)
- Replace custom DangerZone middleware with Laravel password.confirm
- Add HasFactory trait to Profile model for test factories
Bugs Documented (known-bugs group):
- Registration crashes with str_ends_with TypeError (RegisterController:82)
- OAuth routes use legacy array syntax causing ReflectionFunction TypeError
4 weeks ago
|
|
|
use HasFactory, HasSnowflakePrimary, SoftDeletes;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Indicates if the IDs are auto-incrementing.
|
|
|
|
|
*
|
|
|
|
|
* @var bool
|
|
|
|
|
*/
|
|
|
|
|
public $incrementing = false;
|
|
|
|
|
|
|
|
|
|
protected $hidden = ['private_key'];
|
|
|
|
|
|
|
|
|
|
protected $visible = ['id', 'user_id', 'username', 'name'];
|
|
|
|
|
|
|
|
|
|
protected $guarded = [];
|
|
|
|
|
|
|
|
|
|
protected function casts(): array
|
|
|
|
|
{
|
|
|
|
|
return [
|
|
|
|
|
'deleted_at' => 'datetime',
|
|
|
|
|
'last_fetched_at' => 'datetime',
|
|
|
|
|
'last_status_at' => 'datetime',
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function user(): BelongsTo
|
|
|
|
|
{
|
|
|
|
|
return $this->belongsTo(User::class);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function url($suffix = null)
|
|
|
|
|
{
|
|
|
|
|
return $this->remote_url ?? url($this->username.$suffix);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function localUrl($suffix = null)
|
|
|
|
|
{
|
|
|
|
|
return url($this->username.$suffix);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function permalink($suffix = null)
|
|
|
|
|
{
|
|
|
|
|
return $this->remote_url ?? url('users/'.$this->username.$suffix);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function emailUrl()
|
|
|
|
|
{
|
|
|
|
|
if ($this->domain) {
|
|
|
|
|
return $this->username;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$domain = parse_url(config('app.url'), PHP_URL_HOST);
|
|
|
|
|
|
|
|
|
|
return $this->username.'@'.$domain;
|
|
|
|
|
}
|
|
|
|
|
|
fix: add return type declarations to Eloquent relation methods
Larastan 3.x requires explicit return types on relation methods to
verify relation existence when using with(), has(), etc. This adds
the appropriate return type declarations to all relation methods
flagged by the larastan.relationExistence rule.
Models fixed:
- Profile (avatar, statuses)
- User (profile)
- Status (profile, media, hashtags)
- DirectMessage (status, author, recipient)
- Report (reporter, status, reportedUser)
- Like (actor, status)
- Media (status)
- Notification (item)
- HashtagFollow (hashtag)
- OauthClient (user)
- Story (profile)
- StatusHashtag (status, hashtag, profile, media)
- AccountInterstitial (user)
- Hashtag (posts)
- CustomFilter (keywords)
- CustomFilterKeyword (customFilter)
- AdminShadowFilter (profile)
- ImportPost (status)
4 weeks ago
|
|
|
public function statuses(): HasMany
|
|
|
|
|
{
|
|
|
|
|
return $this->hasMany(Status::class);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function followingCount($short = false)
|
|
|
|
|
{
|
|
|
|
|
$count = Cache::remember('profile:following_count:'.$this->id, now()->addMonths(1), function () {
|
|
|
|
|
if ($this->domain == null && $this->user->settings->show_profile_following_count == false) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
$count = DB::table('followers')->where('profile_id', $this->id)->count();
|
|
|
|
|
if ($this->following_count != $count) {
|
|
|
|
|
$this->following_count = $count;
|
|
|
|
|
$this->save();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $count;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return $short ? PrettyNumber::convert($count) : $count;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function followerCount($short = false)
|
|
|
|
|
{
|
|
|
|
|
$count = Cache::remember('profile:follower_count:'.$this->id, now()->addMonths(1), function () {
|
|
|
|
|
if ($this->domain == null && $this->user->settings->show_profile_follower_count == false) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
$count = DB::table('followers')->where('following_id', $this->id)->count();
|
|
|
|
|
if ($this->followers_count != $count) {
|
|
|
|
|
$this->followers_count = $count;
|
|
|
|
|
$this->save();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $count;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return $short ? PrettyNumber::convert($count) : $count;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function statusCount()
|
|
|
|
|
{
|
|
|
|
|
return $this->status_count;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function following()
|
|
|
|
|
{
|
|
|
|
|
return $this->belongsToMany(
|
|
|
|
|
self::class,
|
|
|
|
|
'followers',
|
|
|
|
|
'profile_id',
|
|
|
|
|
'following_id'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function followers()
|
|
|
|
|
{
|
|
|
|
|
return $this->belongsToMany(
|
|
|
|
|
self::class,
|
|
|
|
|
'followers',
|
|
|
|
|
'following_id',
|
|
|
|
|
'profile_id'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function follows($profile): bool
|
|
|
|
|
{
|
|
|
|
|
return Follower::whereProfileId($this->id)->whereFollowingId($profile->id)->exists();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function followedBy($profile): bool
|
|
|
|
|
{
|
|
|
|
|
return Follower::whereProfileId($profile->id)->whereFollowingId($this->id)->exists();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function bookmarks()
|
|
|
|
|
{
|
|
|
|
|
return $this->belongsToMany(
|
|
|
|
|
Status::class,
|
|
|
|
|
'bookmarks',
|
|
|
|
|
'profile_id',
|
|
|
|
|
'status_id'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function likes()
|
|
|
|
|
{
|
|
|
|
|
return $this->hasMany(Like::class);
|
|
|
|
|
}
|
|
|
|
|
|
fix: add return type declarations to Eloquent relation methods
Larastan 3.x requires explicit return types on relation methods to
verify relation existence when using with(), has(), etc. This adds
the appropriate return type declarations to all relation methods
flagged by the larastan.relationExistence rule.
Models fixed:
- Profile (avatar, statuses)
- User (profile)
- Status (profile, media, hashtags)
- DirectMessage (status, author, recipient)
- Report (reporter, status, reportedUser)
- Like (actor, status)
- Media (status)
- Notification (item)
- HashtagFollow (hashtag)
- OauthClient (user)
- Story (profile)
- StatusHashtag (status, hashtag, profile, media)
- AccountInterstitial (user)
- Hashtag (posts)
- CustomFilter (keywords)
- CustomFilterKeyword (customFilter)
- AdminShadowFilter (profile)
- ImportPost (status)
4 weeks ago
|
|
|
public function avatar(): HasOne
|
|
|
|
|
{
|
|
|
|
|
return $this->hasOne(Avatar::class)->withDefault([
|
|
|
|
|
'media_path' => 'public/avatars/default.jpg',
|
|
|
|
|
'change_count' => 0,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function avatarUrl()
|
|
|
|
|
{
|
|
|
|
|
$url = Cache::remember('avatar:'.$this->id, 1209600, function () {
|
|
|
|
|
$avatar = $this->avatar;
|
|
|
|
|
|
|
|
|
|
if (! $avatar) {
|
|
|
|
|
return url('/storage/avatars/default.jpg');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($avatar->cdn_url) {
|
|
|
|
|
if (str_starts_with($avatar->cdn_url, 'https://')) {
|
|
|
|
|
return $avatar->cdn_url;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return url('/storage/avatars/default.jpg');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$path = $avatar->media_path;
|
|
|
|
|
|
|
|
|
|
if (! $path) {
|
|
|
|
|
return url('/storage/avatars/default.jpg');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($avatar->is_remote &&
|
|
|
|
|
$avatar->remote_url &&
|
|
|
|
|
boolval(config_cache('federation.avatars.store_local')) === true
|
|
|
|
|
) {
|
|
|
|
|
return $avatar->remote_url;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($path === 'public/avatars/default.jpg') {
|
|
|
|
|
return url('/storage/avatars/default.jpg');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!str_starts_with($path, 'public')) {
|
|
|
|
|
return url('/storage/avatars/default.jpg');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (config('filesystems.default') !== 'local') {
|
|
|
|
|
return Storage::url($path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$path = "{$path}?v={$avatar->change_count}";
|
|
|
|
|
|
|
|
|
|
return url(Storage::url($path));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return $url;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// deprecated
|
|
|
|
|
public function recommendFollowers()
|
|
|
|
|
{
|
|
|
|
|
return collect([]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function keyId()
|
|
|
|
|
{
|
|
|
|
|
if ($this->remote_url) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->permalink('#main-key');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function mutedIds()
|
|
|
|
|
{
|
|
|
|
|
return UserFilter::whereUserId($this->id)
|
|
|
|
|
->whereFilterableType(Profile::class)
|
|
|
|
|
->whereFilterType('mute')
|
|
|
|
|
->pluck('filterable_id');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function blockedIds()
|
|
|
|
|
{
|
|
|
|
|
return UserFilter::whereUserId($this->id)
|
|
|
|
|
->whereFilterableType(Profile::class)
|
|
|
|
|
->whereFilterType('block')
|
|
|
|
|
->pluck('filterable_id');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function mutedProfileUrls()
|
|
|
|
|
{
|
|
|
|
|
$ids = $this->mutedIds();
|
|
|
|
|
|
|
|
|
|
return $this->whereIn('id', $ids)->get()->map(function ($i) {
|
|
|
|
|
return $i->url();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function blockedProfileUrls()
|
|
|
|
|
{
|
|
|
|
|
$ids = $this->blockedIds();
|
|
|
|
|
|
|
|
|
|
return $this->whereIn('id', $ids)->get()->map(function ($i) {
|
|
|
|
|
return $i->url();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function reports()
|
|
|
|
|
{
|
|
|
|
|
return $this->hasMany(Report::class, 'profile_id');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function media()
|
|
|
|
|
{
|
|
|
|
|
return $this->hasMany(Media::class, 'profile_id');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function inboxUrl()
|
|
|
|
|
{
|
|
|
|
|
return $this->inbox_url ?? $this->permalink('/inbox');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function outboxUrl()
|
|
|
|
|
{
|
|
|
|
|
return $this->outbox_url ?? $this->permalink('/outbox');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function sharedInbox()
|
|
|
|
|
{
|
|
|
|
|
return $this->sharedInbox ?? $this->inboxUrl();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function getDefaultScope()
|
|
|
|
|
{
|
|
|
|
|
return $this->is_private == true ? 'private' : 'public';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function getAudience($scope = false)
|
|
|
|
|
{
|
|
|
|
|
if ($this->remote_url) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
$scope = $scope ?? $this->getDefaultScope();
|
|
|
|
|
$audience = [];
|
|
|
|
|
switch ($scope) {
|
|
|
|
|
case 'public':
|
|
|
|
|
$audience = [
|
|
|
|
|
'to' => [
|
|
|
|
|
'https://www.w3.org/ns/activitystreams#Public',
|
|
|
|
|
],
|
|
|
|
|
'cc' => [
|
|
|
|
|
$this->permalink('/followers'),
|
|
|
|
|
],
|
|
|
|
|
];
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'unlisted':
|
|
|
|
|
$audience = [
|
|
|
|
|
'to' => [
|
|
|
|
|
],
|
|
|
|
|
'cc' => [
|
|
|
|
|
'https://www.w3.org/ns/activitystreams#Public',
|
|
|
|
|
$this->permalink('/followers'),
|
|
|
|
|
],
|
|
|
|
|
];
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'private':
|
|
|
|
|
$audience = [
|
|
|
|
|
'to' => [
|
|
|
|
|
$this->permalink('/followers'),
|
|
|
|
|
],
|
|
|
|
|
'cc' => [
|
|
|
|
|
],
|
|
|
|
|
];
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $audience;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function getAudienceInbox($scope = 'public')
|
|
|
|
|
{
|
|
|
|
|
return FollowerService::audience($this->id, $scope);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function circles()
|
|
|
|
|
{
|
|
|
|
|
return $this->hasMany(Circle::class);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function hashtags()
|
|
|
|
|
{
|
|
|
|
|
return $this->hasManyThrough(
|
|
|
|
|
Hashtag::class,
|
|
|
|
|
StatusHashtag::class,
|
|
|
|
|
'profile_id',
|
|
|
|
|
'id',
|
|
|
|
|
'id',
|
|
|
|
|
'hashtag_id'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function hashtagFollowing()
|
|
|
|
|
{
|
|
|
|
|
return $this->hasMany(HashtagFollow::class);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function collections()
|
|
|
|
|
{
|
|
|
|
|
return $this->hasMany(Collection::class);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function hasFollowRequestById(int $id)
|
|
|
|
|
{
|
|
|
|
|
return FollowRequest::whereFollowerId($id)
|
|
|
|
|
->whereFollowingId($this->id)
|
|
|
|
|
->exists();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function stories()
|
|
|
|
|
{
|
|
|
|
|
return $this->hasMany(Story::class);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function reported()
|
|
|
|
|
{
|
|
|
|
|
return $this->hasMany(Report::class, 'object_id');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function aliases()
|
|
|
|
|
{
|
|
|
|
|
return $this->hasMany(ProfileAlias::class);
|
|
|
|
|
}
|
|
|
|
|
}
|