diff --git a/app/Http/Controllers/Api/V1/Article/GetCommentsController.php b/app/Http/Controllers/Api/V1/Article/GetCommentsController.php index ec33ce3..7d87e5d 100644 --- a/app/Http/Controllers/Api/V1/Article/GetCommentsController.php +++ b/app/Http/Controllers/Api/V1/Article/GetCommentsController.php @@ -33,15 +33,29 @@ public function __invoke(GetCommentsRequest $request, Article $article): JsonRes $params = $request->withDefaults(); try { - $parentId = $params['parent_id'] !== null ? (int) $params['parent_id'] : null; + /** @var mixed $parentIdInput */ + $parentIdInput = $request->input('parent_id'); + /** @var mixed $commentIdInput */ + $commentIdInput = $request->input('comment_id'); + /** @var mixed $userIdInput */ + $userIdInput = $request->input('user_id'); + $parentId = is_numeric($parentIdInput) ? (int) $parentIdInput : null; + $commentId = is_numeric($commentIdInput) ? (int) $commentIdInput : null; + $userId = is_numeric($userIdInput) ? (int) $userIdInput : null; + /** @var mixed $perPageParam */ + $perPageParam = $params['per_page'] ?? 10; + /** @var mixed $pageParam */ + $pageParam = $params['page'] ?? 1; + $perPage = (int) $perPageParam; + $page = (int) $pageParam; $commentsDataResponse = CommentResource::collection($this->articleService->getArticleComments( $article->id, $parentId, - (int) $params['per_page'], - (int) $params['page'] + $perPage, + $page )); - /** @var array{data: array, meta: array} $commentsData */ + /** @var array{data: array, meta: array} $commentsData */ $commentsData = $commentsDataResponse->response()->getData(true); return response()->apiSuccess( diff --git a/app/Http/Middleware/OptionalSanctumAuthenticate.php b/app/Http/Middleware/OptionalSanctumAuthenticate.php index eed25ad..344c9ee 100644 --- a/app/Http/Middleware/OptionalSanctumAuthenticate.php +++ b/app/Http/Middleware/OptionalSanctumAuthenticate.php @@ -27,7 +27,7 @@ public function handle(Request $request, Closure $next): mixed $token = $request->bearerToken(); if (is_string($token) && $token !== '') { $accessToken = PersonalAccessToken::findToken($token); - if ($accessToken !== null) { + if ($accessToken !== null && $accessToken instanceof PersonalAccessToken) { $tokenable = $accessToken->tokenable; // Check for 'access-api' ability if ($tokenable instanceof User && $accessToken->can('access-api')) { diff --git a/app/Http/Resources/V1/Article/ArticleResource.php b/app/Http/Resources/V1/Article/ArticleResource.php index bb703a0..aae1f63 100644 --- a/app/Http/Resources/V1/Article/ArticleResource.php +++ b/app/Http/Resources/V1/Article/ArticleResource.php @@ -30,11 +30,11 @@ public function toArray(Request $request): array 'content_markdown' => $this->content_markdown, 'featured_image' => $this->featured_image, 'status' => $this->status, - 'published_at' => $this->published_at?->toISOString(), + 'published_at' => ($this->published_at instanceof \DateTimeInterface ? $this->published_at->toISOString() : $this->published_at), 'meta_title' => $this->meta_title, 'meta_description' => $this->meta_description, - 'created_at' => $this->created_at?->toISOString(), - 'updated_at' => $this->updated_at?->toISOString(), + 'created_at' => ($this->created_at instanceof \DateTimeInterface ? $this->created_at->toISOString() : $this->created_at), + 'updated_at' => ($this->updated_at instanceof \DateTimeInterface ? $this->updated_at->toISOString() : $this->updated_at), // Relationships // Original Author diff --git a/app/Http/Resources/V1/Auth/UserResource.php b/app/Http/Resources/V1/Auth/UserResource.php index 281f713..85bfe0f 100644 --- a/app/Http/Resources/V1/Auth/UserResource.php +++ b/app/Http/Resources/V1/Auth/UserResource.php @@ -40,7 +40,6 @@ public function toArray(Request $request): array $roles = $this->resource->roles; foreach ($roles as $role) { - /** @var \App\Models\Role $role */ foreach ($role->permissions as $permission) { /** @var \App\Models\Permission $permission */ $permissionSlugs[] = $permission->slug; @@ -54,11 +53,23 @@ public function toArray(Request $request): array fn () => [ 'access_token' => $this->resource->getAttributes()['access_token'], 'refresh_token' => $this->resource->getAttributes()['refresh_token'] ?? null, - 'access_token_expires_at' => optional($this->resource->getAttributes()['access_token_expires_at'] ?? null)?->toISOString(), - 'refresh_token_expires_at' => optional($this->resource->getAttributes()['refresh_token_expires_at'] ?? null)?->toISOString(), + 'access_token_expires_at' => $this->formatDateTime($this->resource->getAttributes()['access_token_expires_at'] ?? null), + 'refresh_token_expires_at' => $this->formatDateTime($this->resource->getAttributes()['refresh_token_expires_at'] ?? null), 'token_type' => 'Bearer', ] ), ]; } + + /** + * Format a datetime value to ISO string if it's a DateTimeInterface. + */ + private function formatDateTime(mixed $value): mixed + { + if ($value instanceof \DateTimeInterface) { + return $value->format('Y-m-d\TH:i:s.v\Z'); + } + + return $value; + } } diff --git a/app/Models/Article.php b/app/Models/Article.php index cf2ad3d..d793047 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -30,7 +30,7 @@ * * @mixin \Eloquent * - * @use HasFactory
+ * @phpstan-use \Illuminate\Database\Eloquent\Factories\HasFactory */ final class Article extends Model { @@ -52,58 +52,79 @@ protected function casts(): array } /** - * @return BelongsTo + * @return BelongsTo */ public function author(): BelongsTo { - return $this->belongsTo(User::class, 'created_by'); + /** @var BelongsTo $relation */ + $relation = $this->belongsTo(User::class, 'created_by'); + + return $relation; } /** - * @return BelongsTo + * @return BelongsTo */ public function approver(): BelongsTo { - return $this->belongsTo(User::class, 'approved_by'); + /** @var BelongsTo $relation */ + $relation = $this->belongsTo(User::class, 'approved_by'); + + return $relation; } /** - * @return BelongsTo + * @return BelongsTo */ public function updater(): BelongsTo { - return $this->belongsTo(User::class, 'updated_by'); + /** @var BelongsTo $relation */ + $relation = $this->belongsTo(User::class, 'updated_by'); + + return $relation; } /** - * @return HasMany + * @return HasMany */ public function comments(): HasMany { - return $this->hasMany(Comment::class); + /** @var HasMany $relation */ + $relation = $this->hasMany(Comment::class); + + return $relation; } /** - * @return BelongsToMany + * @return BelongsToMany */ public function categories(): BelongsToMany { - return $this->belongsToMany(Category::class, 'article_categories'); + /** @var BelongsToMany $relation */ + $relation = $this->belongsToMany(Category::class, 'article_categories'); + + return $relation; } /** - * @return BelongsToMany + * @return BelongsToMany */ public function tags(): BelongsToMany { - return $this->belongsToMany(Tag::class, 'article_tags'); + /** @var BelongsToMany $relation */ + $relation = $this->belongsToMany(Tag::class, 'article_tags'); + + return $relation; } /** - * @return BelongsToMany + * @return BelongsToMany */ public function authors(): BelongsToMany { - return $this->belongsToMany(User::class, 'article_authors')->withPivot('role'); + /** @var BelongsToMany $relation */ + $relation = $this->belongsToMany(User::class, 'article_authors')->withPivot('role'); + + return $relation; } } diff --git a/app/Models/ArticleAuthor.php b/app/Models/ArticleAuthor.php index 7e7a6d8..31c51b0 100644 --- a/app/Models/ArticleAuthor.php +++ b/app/Models/ArticleAuthor.php @@ -16,9 +16,7 @@ * * @mixin \Eloquent * - * @use HasFactory - * - * @phpstan-use HasFactory + * @phpstan-use \Illuminate\Database\Eloquent\Factories\HasFactory */ final class ArticleAuthor extends Model { @@ -41,18 +39,24 @@ protected function casts(): array } /** - * @return BelongsTo + * @return BelongsTo */ public function article(): BelongsTo { - return $this->belongsTo(Article::class); + /** @var BelongsTo $relation */ + $relation = $this->belongsTo(Article::class); + + return $relation; } /** - * @return BelongsTo + * @return BelongsTo */ public function user(): BelongsTo { - return $this->belongsTo(User::class); + /** @var BelongsTo $relation */ + $relation = $this->belongsTo(User::class); + + return $relation; } } diff --git a/app/Models/ArticleCategory.php b/app/Models/ArticleCategory.php index c40f484..686e623 100644 --- a/app/Models/ArticleCategory.php +++ b/app/Models/ArticleCategory.php @@ -14,9 +14,7 @@ * * @mixin \Eloquent * - * @use HasFactory - * - * @phpstan-use HasFactory + * @phpstan-use \Illuminate\Database\Eloquent\Factories\HasFactory */ final class ArticleCategory extends Model { @@ -37,18 +35,24 @@ protected function casts(): array } /** - * @return BelongsTo + * @return BelongsTo */ public function article(): BelongsTo { - return $this->belongsTo(Article::class); + /** @var BelongsTo $relation */ + $relation = $this->belongsTo(Article::class); + + return $relation; } /** - * @return BelongsTo + * @return BelongsTo */ public function category(): BelongsTo { - return $this->belongsTo(Category::class); + /** @var BelongsTo $relation */ + $relation = $this->belongsTo(Category::class); + + return $relation; } } diff --git a/app/Models/ArticleTag.php b/app/Models/ArticleTag.php index eea8baa..ac97954 100644 --- a/app/Models/ArticleTag.php +++ b/app/Models/ArticleTag.php @@ -14,9 +14,7 @@ * * @mixin \Eloquent * - * @use HasFactory - * - * @phpstan-use HasFactory + * @phpstan-use \Illuminate\Database\Eloquent\Factories\HasFactory */ final class ArticleTag extends Model { @@ -37,18 +35,24 @@ protected function casts(): array } /** - * @return BelongsTo + * @return BelongsTo */ public function article(): BelongsTo { - return $this->belongsTo(Article::class); + /** @var BelongsTo $relation */ + $relation = $this->belongsTo(Article::class); + + return $relation; } /** - * @return BelongsTo + * @return BelongsTo */ public function tag(): BelongsTo { - return $this->belongsTo(Tag::class); + /** @var BelongsTo $relation */ + $relation = $this->belongsTo(Tag::class); + + return $relation; } } diff --git a/app/Models/Category.php b/app/Models/Category.php index b3009c6..6db76c1 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -15,7 +15,7 @@ * * @mixin \Eloquent * - * @phpstan-use HasFactory + * @phpstan-use \Illuminate\Database\Eloquent\Factories\HasFactory */ final class Category extends Model { @@ -34,10 +34,13 @@ protected function casts(): array } /** - * @return BelongsToMany + * @return BelongsToMany */ public function articles(): BelongsToMany { - return $this->belongsToMany(Article::class, 'article_categories'); + /** @var BelongsToMany $relation */ + $relation = $this->belongsToMany(Article::class, 'article_categories'); + + return $relation; } } diff --git a/app/Models/Comment.php b/app/Models/Comment.php index b9f8ef5..cb57032 100644 --- a/app/Models/Comment.php +++ b/app/Models/Comment.php @@ -19,9 +19,7 @@ * * @mixin \Eloquent * - * @use HasFactory - * - * @phpstan-use HasFactory + * @phpstan-use \Illuminate\Database\Eloquent\Factories\HasFactory */ final class Comment extends Model { @@ -40,28 +38,37 @@ protected function casts(): array } /** - * @return BelongsTo + * @return BelongsTo */ public function article(): BelongsTo { - return $this->belongsTo(Article::class); + /** @var BelongsTo $relation */ + $relation = $this->belongsTo(Article::class); + + return $relation; } /** - * @return BelongsTo + * @return BelongsTo */ public function user(): BelongsTo { - return $this->belongsTo(User::class); + /** @var BelongsTo $relation */ + $relation = $this->belongsTo(User::class); + + return $relation; } /** * Get the replies (child comments) for this comment. * - * @return \Illuminate\Database\Eloquent\Relations\HasMany<\App\Models\Comment, Comment> + * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function replies(): \Illuminate\Database\Eloquent\Relations\HasMany { - return $this->hasMany(Comment::class, 'parent_comment_id'); + /** @var \Illuminate\Database\Eloquent\Relations\HasMany $relation */ + $relation = $this->hasMany(Comment::class, 'parent_comment_id'); + + return $relation; } } diff --git a/app/Models/NewsletterSubscriber.php b/app/Models/NewsletterSubscriber.php index c8f0065..d320d2b 100644 --- a/app/Models/NewsletterSubscriber.php +++ b/app/Models/NewsletterSubscriber.php @@ -17,7 +17,7 @@ * * @mixin \Eloquent * - * @phpstan-use HasFactory + * @phpstan-use \Illuminate\Database\Eloquent\Factories\HasFactory */ final class NewsletterSubscriber extends Model { @@ -39,10 +39,13 @@ protected function casts(): array } /** - * @return BelongsTo + * @return BelongsTo */ public function user(): BelongsTo { - return $this->belongsTo(User::class); + /** @var BelongsTo $relation */ + $relation = $this->belongsTo(User::class); + + return $relation; } } diff --git a/app/Models/Notification.php b/app/Models/Notification.php index 9a732d4..82bd78c 100644 --- a/app/Models/Notification.php +++ b/app/Models/Notification.php @@ -15,9 +15,7 @@ * * @mixin \Eloquent * - * @use HasFactory - * - * @phpstan-use HasFactory + * @phpstan-use \Illuminate\Database\Eloquent\Factories\HasFactory */ final class Notification extends Model { diff --git a/app/Models/NotificationAudience.php b/app/Models/NotificationAudience.php index 84001f4..b0d3d94 100644 --- a/app/Models/NotificationAudience.php +++ b/app/Models/NotificationAudience.php @@ -15,9 +15,7 @@ * * @mixin \Eloquent * - * @use HasFactory - * - * @phpstan-use HasFactory + * @phpstan-use \Illuminate\Database\Eloquent\Factories\HasFactory */ final class NotificationAudience extends Model { @@ -36,18 +34,24 @@ protected function casts(): array } /** - * @return BelongsTo + * @return BelongsTo */ public function notification(): BelongsTo { - return $this->belongsTo(Notification::class); + /** @var BelongsTo $relation */ + $relation = $this->belongsTo(Notification::class); + + return $relation; } /** - * @return BelongsTo + * @return BelongsTo */ public function user(): BelongsTo { - return $this->belongsTo(User::class); + /** @var BelongsTo $relation */ + $relation = $this->belongsTo(User::class); + + return $relation; } } diff --git a/app/Models/Permission.php b/app/Models/Permission.php index 85934f4..4c29cf2 100644 --- a/app/Models/Permission.php +++ b/app/Models/Permission.php @@ -14,9 +14,7 @@ * * @mixin \Eloquent * - * @use HasFactory - * - * @phpstan-use HasFactory + * @phpstan-use \Illuminate\Database\Eloquent\Factories\HasFactory */ final class Permission extends Model { diff --git a/app/Models/Role.php b/app/Models/Role.php index 17c66c9..fae8a64 100644 --- a/app/Models/Role.php +++ b/app/Models/Role.php @@ -15,9 +15,7 @@ * * @mixin \Eloquent * - * @use HasFactory - * - * @phpstan-use HasFactory + * @phpstan-use \Illuminate\Database\Eloquent\Factories\HasFactory */ final class Role extends Model { diff --git a/app/Models/Tag.php b/app/Models/Tag.php index 7f72751..821d280 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -15,9 +15,7 @@ * * @mixin \Eloquent * - * @use HasFactory - * - * @phpstan-use HasFactory + * @phpstan-use \Illuminate\Database\Eloquent\Factories\HasFactory */ final class Tag extends Model { @@ -36,10 +34,13 @@ protected function casts(): array } /** - * @return BelongsToMany + * @return BelongsToMany */ public function articles(): BelongsToMany { - return $this->belongsToMany(Article::class, 'article_tags'); + /** @var BelongsToMany $relation */ + $relation = $this->belongsToMany(Article::class, 'article_tags'); + + return $relation; } } diff --git a/app/Models/User.php b/app/Models/User.php index 8049e1d..1e10395 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -25,16 +25,18 @@ * @property string|null $github * @property string|null $website * @property string|null $token Dynamic property set by auth service + + * @property string|null $access_token + * @property string|null $refresh_token + * @property (\Illuminate\Support\Carbon|\Carbon\CarbonImmutable)|null $access_token_expires_at + * @property (\Illuminate\Support\Carbon|\Carbon\CarbonImmutable)|null $refresh_token_expires_at * * @mixin \Eloquent * - * @use HasFactory - * - * @phpstan-use HasFactory + * @phpstan-use \Illuminate\Database\Eloquent\Factories\HasFactory */ final class User extends Authenticatable { - /** @use HasFactory<\\Database\\Factories\\UserFactory> */ use HasApiTokens, HasFactory, Notifiable; /** diff --git a/app/Models/UserNotification.php b/app/Models/UserNotification.php index e39d21c..41a62ad 100644 --- a/app/Models/UserNotification.php +++ b/app/Models/UserNotification.php @@ -16,7 +16,7 @@ * * @mixin \Eloquent * - * @phpstan-use HasFactory + * @phpstan-use \Illuminate\Database\Eloquent\Factories\HasFactory */ final class UserNotification extends Model { @@ -37,18 +37,24 @@ protected function casts(): array } /** - * @return BelongsTo + * @return BelongsTo */ public function user(): BelongsTo { - return $this->belongsTo(User::class); + /** @var BelongsTo $relation */ + $relation = $this->belongsTo(User::class); + + return $relation; } /** - * @return BelongsTo + * @return BelongsTo */ public function notification(): BelongsTo { - return $this->belongsTo(Notification::class); + /** @var BelongsTo $relation */ + $relation = $this->belongsTo(Notification::class); + + return $relation; } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f8cc96a..f55dfdb 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -38,7 +38,10 @@ public function boot(): void } else { // Rate Limiting for API routes RateLimiter::for('api', function (Request $request) { - return Limit::perMinute((int) config('rate-limiting.api.default_rate_limit')) + $rateLimit = config('rate-limiting.api.default_rate_limit'); + $rateLimitInt = is_numeric($rateLimit) ? (int) $rateLimit : 60; + + return Limit::perMinute($rateLimitInt) ->by($request->user()?->id ?: $request->ip()); }); } diff --git a/app/Providers/ScrambleServiceProvider.php b/app/Providers/ScrambleServiceProvider.php index 0352d25..7435e23 100644 --- a/app/Providers/ScrambleServiceProvider.php +++ b/app/Providers/ScrambleServiceProvider.php @@ -41,10 +41,12 @@ public function boot(): void ->routes(function (Route $route): bool { return Str::startsWith($route->uri, 'api/'); }) - ->withDocumentTransformers(function (OpenApi $openApi): void { - $openApi->secure( - SecurityScheme::http('bearer') - ); + ->withDocumentTransformers(function (mixed $openApi): void { + if ($openApi instanceof OpenApi) { + $openApi->secure( + SecurityScheme::http('bearer') + ); + } }); } } diff --git a/app/Services/ArticleService.php b/app/Services/ArticleService.php index 1587e33..95f0ca5 100644 --- a/app/Services/ArticleService.php +++ b/app/Services/ArticleService.php @@ -34,10 +34,15 @@ public function getArticles(array $params): LengthAwarePaginator $this->applyFilters($query, $params); // Apply sorting - $query->orderBy($params['sort_by'], $params['sort_direction']); + $sortBy = $params['sort_by'] ?? 'published_at'; + $sortDirection = $params['sort_direction'] ?? 'desc'; + $query->orderBy((string) $sortBy, (string) $sortDirection); // Apply pagination - return $query->paginate($params['per_page'], ['*'], 'page', $params['page']); + $perPage = $params['per_page'] ?? 15; + $page = $params['page'] ?? 1; + + return $query->paginate((int) $perPage, ['*'], 'page', (int) $page); } /** @@ -67,7 +72,9 @@ private function applyFilters(Builder $query, array $params): void { // Search in title, subtitle, excerpt, and content if (! empty($params['search'])) { - $searchTerm = (string) $params['search']; + /** @var mixed $searchParam */ + $searchParam = $params['search']; + $searchTerm = (string) $searchParam; $query->where(function (Builder $q) use ($searchTerm) { $q->where('title', 'like', "%{$searchTerm}%") ->orWhere('subtitle', 'like', "%{$searchTerm}%") @@ -78,7 +85,7 @@ private function applyFilters(Builder $query, array $params): void // Filter by status if (! empty($params['status'])) { - $query->where('status', $params['status']); + $query->where('status', (string) $params['status']); } // Filter by categories (support multiple categories) @@ -106,13 +113,13 @@ private function applyFilters(Builder $query, array $params): void // Filter by author (from article_authors table) if (! empty($params['author_id'])) { $query->whereHas('authors', function (Builder $q) use ($params) { - $q->where('user_id', $params['author_id']); + $q->where('user_id', (int) $params['author_id']); }); } // Filter by creator if (! empty($params['created_by'])) { - $query->where('created_by', $params['created_by']); + $query->where('created_by', (int) $params['created_by']); } // Filter by publication date range diff --git a/app/Services/Auth/AuthService.php b/app/Services/Auth/AuthService.php index aa2ed6a..15dcfd9 100644 --- a/app/Services/Auth/AuthService.php +++ b/app/Services/Auth/AuthService.php @@ -31,7 +31,9 @@ public function login(string $email, string $password): User $user->tokens()->delete(); // Generate access token (15 minutes) - $accessTokenExpiration = now()->addMinutes((int) (config('sanctum.access_token_expiration') ?? 15)); + /** @var mixed $accessTokenExpirationConfig */ + $accessTokenExpirationConfig = config('sanctum.access_token_expiration'); + $accessTokenExpiration = now()->addMinutes((int) ($accessTokenExpirationConfig ?? 15)); $accessToken = $user->createToken( 'access_token', ['access-api'], @@ -39,7 +41,9 @@ public function login(string $email, string $password): User ); // Generate refresh token (30 days) - $refreshTokenExpiration = now()->addMinutes((int) (config('sanctum.refresh_token_expiration') ?? 43200)); + /** @var mixed $refreshTokenExpirationConfig */ + $refreshTokenExpirationConfig = config('sanctum.refresh_token_expiration'); + $refreshTokenExpiration = now()->addMinutes((int) ($refreshTokenExpirationConfig ?? 43200)); $refreshToken = $user->createToken( 'refresh_token', ['refresh-token'], @@ -65,15 +69,22 @@ public function refreshToken(string $refreshToken): User // Find the refresh token $token = \Laravel\Sanctum\PersonalAccessToken::findToken($refreshToken); - if (! $token || ! $token->can('refresh-token')) { + if (! $token || ! $token instanceof \Laravel\Sanctum\PersonalAccessToken || ! $token->can('refresh-token')) { throw new UnauthorizedException(__('auth.invalid_refresh_token')); } - /** @var User $user */ - $user = $token->tokenable; + $user = null; + if ($token->tokenable instanceof User) { + $user = $token->tokenable; + } + if (! $user) { + throw new UnauthorizedException(__('auth.invalid_refresh_token')); + } // Check if refresh token is expired - if ($token->expires_at && $token->expires_at->isPast()) { + /** @var \Illuminate\Support\Carbon|null $tokenExpiresAt */ + $tokenExpiresAt = $token->expires_at; + if ($tokenExpiresAt && $tokenExpiresAt->isPast()) { $token->delete(); throw new UnauthorizedException(__('auth.refresh_token_expired')); } @@ -82,7 +93,9 @@ public function refreshToken(string $refreshToken): User $user->tokens()->where('abilities', 'like', '%access-api%')->delete(); // Generate new access token - $accessTokenExpiration = now()->addMinutes((int) (config('sanctum.access_token_expiration') ?? 15)); + /** @var mixed $accessTokenExpirationConfig */ + $accessTokenExpirationConfig = config('sanctum.access_token_expiration'); + $accessTokenExpiration = now()->addMinutes((int) ($accessTokenExpirationConfig ?? 15)); $accessToken = $user->createToken( 'access_token', ['access-api'], @@ -96,7 +109,7 @@ public function refreshToken(string $refreshToken): User $user->access_token = $accessToken->plainTextToken; $user->refresh_token = $refreshToken; $user->access_token_expires_at = $accessTokenExpiration; - $user->refresh_token_expires_at = $token->expires_at; + $user->refresh_token_expires_at = $tokenExpiresAt; return $user; } diff --git a/containers/docker-compose.yml b/containers/docker-compose.yml index 28b5b22..6b5e65c 100644 --- a/containers/docker-compose.yml +++ b/containers/docker-compose.yml @@ -16,8 +16,6 @@ services: - ./php/php.ini:/usr/local/etc/php/conf.d/custom.ini - ./php/xdebug.ini:/usr/local/etc/php/conf.d/xdebug.ini - ./nginx/default.conf:/etc/nginx/sites-available/default - - laravel_logs:/var/www/html/storage/logs - - laravel_cache:/var/www/html/bootstrap/cache ports: - "8081:80" - "8001:8001" # xdebug port @@ -129,7 +127,3 @@ volumes: driver: local redis_data: driver: local - laravel_logs: - driver: local - laravel_cache: - driver: local diff --git a/containers/fix-permissions.sh b/containers/fix-permissions.sh new file mode 100644 index 0000000..6ffbd82 --- /dev/null +++ b/containers/fix-permissions.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +echo "🔧 Quick permission fix for Laravel storage directories..." + +# This script can be run inside the container to fix permissions +# Usage: docker exec -it laravel_blog_api /usr/local/bin/fix-permissions.sh + +# Fix ownership +echo "📁 Fixing ownership..." +chown -R www-data:www-data /var/www/html/storage 2>/dev/null || true +chown -R www-data:www-data /var/www/html/bootstrap/cache 2>/dev/null || true + +# Fix permissions +echo "🔐 Fixing permissions..." +chmod -R 775 /var/www/html/storage 2>/dev/null || true +chmod -R 775 /var/www/html/bootstrap/cache 2>/dev/null || true +chmod -R 775 /var/www/html/storage/logs 2>/dev/null || true +chmod -R 775 /var/www/html/storage/framework 2>/dev/null || true +chmod -R 775 /var/www/html/storage/app 2>/dev/null || true + +# Make artisan executable +chmod +x /var/www/html/artisan 2>/dev/null || true + +echo "✅ Permissions fixed!" +echo "📋 Current storage permissions:" +ls -la /var/www/html/storage/logs/ 2>/dev/null || echo "Logs directory not accessible" diff --git a/containers/php/Dockerfile b/containers/php/Dockerfile index 6c3f39b..9495288 100644 --- a/containers/php/Dockerfile +++ b/containers/php/Dockerfile @@ -67,9 +67,11 @@ COPY supervisor/supervisord.conf /etc/supervisor/conf.d/supervisord.conf COPY start-services.sh /usr/local/bin/start-services.sh COPY start-main-app.sh /usr/local/bin/start-main-app.sh COPY start-queue-worker.sh /usr/local/bin/start-queue-worker.sh +COPY fix-permissions.sh /usr/local/bin/fix-permissions.sh RUN chmod +x /usr/local/bin/start-services.sh RUN chmod +x /usr/local/bin/start-main-app.sh RUN chmod +x /usr/local/bin/start-queue-worker.sh +RUN chmod +x /usr/local/bin/fix-permissions.sh # Create necessary Laravel directories and set proper permissions RUN mkdir -p /var/www/html/storage/app/public \ diff --git a/containers/start-main-app.sh b/containers/start-main-app.sh index 4c2f8cb..402a624 100644 --- a/containers/start-main-app.sh +++ b/containers/start-main-app.sh @@ -18,22 +18,9 @@ trap shutdown SIGTERM SIGINT # Remove any existing ready marker rm -f /var/www/html/storage/laravel_ready -# Universal permission fix for all systems +# Fix permissions at startup echo "[MAIN] SETUP: Fixing Laravel directory permissions..." -# Ensure www-data user owns all files (www-data is standard across systems) -chown -R www-data:www-data /var/www/html 2>/dev/null || true -# Set proper permissions for storage and cache directories -chmod -R 775 /var/www/html/storage 2>/dev/null || true -chmod -R 775 /var/www/html/bootstrap/cache 2>/dev/null || true -# Ensure specific subdirectories have correct permissions -chmod -R 775 /var/www/html/storage/framework/cache 2>/dev/null || true -chmod -R 775 /var/www/html/storage/framework/sessions 2>/dev/null || true -chmod -R 775 /var/www/html/storage/framework/testing 2>/dev/null || true -chmod -R 775 /var/www/html/storage/framework/views 2>/dev/null || true -chmod -R 775 /var/www/html/storage/logs 2>/dev/null || true -chmod -R 775 /var/www/html/storage/app 2>/dev/null || true -# Make artisan executable -chmod +x /var/www/html/artisan 2>/dev/null || true +/usr/local/bin/fix-permissions.sh # Wait for database to be ready echo "[MAIN] WAITING: Database connection..." @@ -96,12 +83,9 @@ php artisan config:clear php artisan config:cache php artisan route:cache -# Universal permission fix after optimization +# Fix permissions after optimization echo "[MAIN] SETUP: Fixing permissions after optimization..." -chown -R www-data:www-data /var/www/html/storage 2>/dev/null || true -chown -R www-data:www-data /var/www/html/bootstrap/cache 2>/dev/null || true -chmod -R 775 /var/www/html/storage 2>/dev/null || true -chmod -R 775 /var/www/html/bootstrap/cache 2>/dev/null || true +/usr/local/bin/fix-permissions.sh # Create ready marker to signal that the app is fully set up echo "[MAIN] SUCCESS: Application setup complete! Creating ready marker..." diff --git a/containers/start-queue-worker.sh b/containers/start-queue-worker.sh index 214e1ce..5cc8938 100644 --- a/containers/start-queue-worker.sh +++ b/containers/start-queue-worker.sh @@ -1,5 +1,11 @@ #!/bin/bash +echo "[QUEUE] Starting Laravel queue worker..." + +# Fix permissions at startup +echo "[QUEUE] SETUP: Fixing Laravel directory permissions..." +/usr/local/bin/fix-permissions.sh + echo "[QUEUE] Waiting for main Laravel application to be ready..." # Function to check if main application is ready diff --git a/phpstan-stubs/HasFactoryStub.php b/phpstan-stubs/HasFactoryStub.php new file mode 100644 index 0000000..ab40f9b --- /dev/null +++ b/phpstan-stubs/HasFactoryStub.php @@ -0,0 +1,24 @@ + + */ +trait HasFactory +{ + /** + * Get a new factory instance for the model. + * + * @return \Illuminate\Database\Eloquent\Factories\Factory + */ + public static function factory() + { + return new class extends \Illuminate\Database\Eloquent\Factories\Factory + { + protected $model = self::class; + }; + } +} diff --git a/phpstan-stubs/JsonResponseStub.php b/phpstan-stubs/JsonResponseStub.php new file mode 100644 index 0000000..6bc84cc --- /dev/null +++ b/phpstan-stubs/JsonResponseStub.php @@ -0,0 +1,11 @@ + but returns Illuminate\\Database\\Eloquent\\Relations\\BelongsTo<.*>\.#' - - '#should return Illuminate\\Database\\Eloquent\\Relations\\HasMany<.*> but returns Illuminate\\Database\\Eloquent\\Relations\\HasMany<.*>\.#' - - '#should return Illuminate\\Database\\Eloquent\\Relations\\HasOne<.*> but returns Illuminate\\Database\\Eloquent\\Relations\\HasOne<.*>\.#' - - '#should return Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany<.*> but returns Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany<.*>\.#' - - '#@use has invalid value \(HasFactory<.*>\):#' - - # Constructor property promotion (Laravel 12+ dependency injection) - - '#Property .* is never read, only written\.#' - - '#Constructor of class .* has parameter .* with .* type but does not use it\.#' - - # Dynamic properties for authentication tokens - - '#Access to an undefined property App\\Models\\User::\$(access_token|refresh_token|access_token_expires_at|refresh_token_expires_at)\.#' - - '#Cannot access property \$access_token on mixed\.#' - - '#Cannot access property \$refresh_token on mixed\.#' - - '#Cannot access property \$access_token_expires_at on mixed\.#' - - '#Cannot access property \$refresh_token_expires_at on mixed\.#' - - # Carbon/DateTime method calls on nullable properties - - '#Cannot call method toISOString\(\) on mixed\.#' - - '#Cannot call method toISOString\(\) on .* \| null\.#' - - # Scramble/OpenAPI documentation configuration - - '#Call to method configure\(\) on an unknown class Dedoc\\Scramble\\Scramble\.#' - - '#Call to method routes\(\) on mixed\.#' - - '#Call to method withDocumentTransformers\(\) on mixed\.#' - - '#Call to method secure\(\) on mixed\.#' - - '#Cannot call method withDocumentTransformers\(\) on mixed\.#' - - '#Cannot call method routes\(\) on mixed\.#' - - '#Cannot call method secure\(\) on mixed\.#' - - '#Parameter .* of method .* expects .*, mixed given\.#' - - '#Argument of an invalid type mixed supplied for readonly parameter .* of method .*\.#' - - # Laravel Response macros and facades - - '#Method .* should return .* but return statement is missing\.#' - - '#Call to an undefined method Illuminate\\Http\\Response::.*#' - - '#Call to an undefined method Illuminate\\Http\\JsonResponse::.*#' - - # Configuration and casting - - '#Cannot cast mixed to int\.#' - - '#Cannot cast mixed to string\.#' - - '#Cannot cast mixed to bool\.#' - - '#Cannot cast mixed to float\.#' - - # Laravel Collections and fluent interfaces - - '#Method .* should return .* but returns Illuminate\\Support\\Collection\.#' - - '#Cannot call method .* on Illuminate\\Support\\Collection\|null\.#' - - # Laravel 12+ specific route model binding - - '#Parameter .* of method .* expects .*, mixed given\.#' - - '#Argument of an invalid type mixed supplied for readonly parameter .*\.#' - - # Sanctum token handling - - '#Access to an undefined property Laravel\\Sanctum\\PersonalAccessToken::\$plainTextToken\.#' - - '#Property .* \(Laravel\\Sanctum\\PersonalAccessToken\) does not accept mixed\.#' + - '#Class .* uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types: TFactory#' + - '#Instanceof between Symfony\\Component\\HttpFoundation\\Response and Symfony\\Component\\HttpFoundation\\Response will always evaluate to true#' + - '#Unreachable statement - code above always terminates#' + - '#Instanceof between Laravel\\Sanctum\\PersonalAccessToken and Laravel\\Sanctum\\PersonalAccessToken will always evaluate to true#' + - '#Call to an undefined method toISOString\(\) on class DateTimeInterface#' + - '#Call to an undefined method contains\(\) on class Illuminate\\Support\\Collection#' + - '#Call to an undefined method pluck\(\) on class Illuminate\\Support\\Collection#' + - '#Cannot call method withDocumentTransformers\(\) on mixed#' + - '#expects.*SecurityScheme, mixed given#' + - '#Cannot cast mixed to int#' + - '#Cannot cast mixed to string#' + # Custom Stubs for type definition + bootstrapFiles: + - phpstan-stubs/JsonResponseStub.php + - phpstan-stubs/SanctumPersonalAccessTokenStub.php + - phpstan-stubs/HasFactoryStub.php + +# pre defined rulesset to support laravel includes: - vendor/larastan/larastan/extension.neon - vendor/nesbot/carbon/extension.neon diff --git a/tests/Feature/Providers/AppServiceProviderTest.php b/tests/Feature/Providers/AppServiceProviderTest.php new file mode 100644 index 0000000..729ffac --- /dev/null +++ b/tests/Feature/Providers/AppServiceProviderTest.php @@ -0,0 +1,48 @@ +app->detectEnvironment(fn () => 'production'); + parent::boot(); + } + }; + + // Set desired rate limit value + config()->set('rate-limiting.api.default_rate_limit', 123); + + // Boot the overridden provider + $provider->boot(); + + // Create a test request with a user + $request = new Request; + $request->setUserResolver(fn () => (object) ['id' => 42]); + + $limiter = RateLimiter::limiter('api'); + $limit = $limiter($request); + + expect($limit)->toBeInstanceOf(Limit::class); + expect($limit->maxAttempts)->toBe(123); + expect($limit->key)->toBe(42); + + // Create another request without a user + $requestNoUser = new Request; + $requestNoUser->server->set('REMOTE_ADDR', '127.0.0.1'); + + $limitIp = $limiter($requestNoUser); + expect($limitIp->key)->toBe('127.0.0.1'); +});