diff --git a/app/Console/Commands/SyncArticleImages.php b/app/Console/Commands/SyncArticleImages.php deleted file mode 100644 index 740ab53aa..000000000 --- a/app/Console/Commands/SyncArticleImages.php +++ /dev/null @@ -1,67 +0,0 @@ -error('Unsplash access key must be configured'); - - return; - } - - Article::unsyncedImages()->chunk(100, function ($articles) { - $articles->each(function ($article) { - $imageData = $this->fetchUnsplashImageDataFromId($article); - - if (! is_null($imageData)) { - $article->hero_image_url = $imageData['image_url']; - $article->hero_image_author_name = $imageData['author_name']; - $article->hero_image_author_url = $imageData['author_url']; - $article->save(); - } else { - $this->warn("Failed to fetch image data for image {$article->hero_image_id}"); - } - }); - }); - } - - protected function fetchUnsplashImageDataFromId(Article $article): ?array - { - $response = Http::retry(3, 100, throw: false) - ->withToken(config('services.unsplash.access_key'), 'Client-ID') - ->get("https://api.unsplash.com/photos/{$article->hero_image_id}"); - - if ($response->failed()) { - $article->hero_image_id = null; - $article->save(); - - $this->warn("Failed to fetch image data for image {$article->hero_image_id}"); - - return null; - } - - $response = $response->json(); - - // Trigger as Unsplash download... - Http::retry(3, 100, throw: false) - ->withToken(config('services.unsplash.access_key'), 'Client-ID') - ->get($response['links']['download_location']); - - return [ - 'image_url' => $response['urls']['raw'], - 'author_name' => $response['user']['name'], - 'author_url' => $response['user']['links']['html'], - ]; - } -} diff --git a/app/Http/Controllers/Admin/UsersController.php b/app/Http/Controllers/Admin/UsersController.php index 9474ccb0b..130d44e93 100644 --- a/app/Http/Controllers/Admin/UsersController.php +++ b/app/Http/Controllers/Admin/UsersController.php @@ -9,6 +9,8 @@ use App\Jobs\DeleteUser; use App\Jobs\DeleteUserThreads; use App\Jobs\UnbanUser; +use App\Jobs\UnVerifyAuthor; +use App\Jobs\VerifyAuthor; use App\Models\User; use App\Policies\UserPolicy; use App\Queries\SearchUsers; @@ -60,6 +62,28 @@ public function unban(User $user): RedirectResponse return redirect()->route('profile', $user->username()); } + public function verifyAuthor(User $user) + { + $this->authorize(UserPolicy::ADMIN, $user); + + $this->dispatchSync(new VerifyAuthor($user)); + + $this->success($user->name() . ' was verified!'); + + return redirect()->route('admin.users'); + } + + public function unverifyAuthor(User $user) + { + $this->authorize(UserPolicy::ADMIN, $user); + + $this->dispatchSync(new UnverifyAuthor($user)); + + $this->success($user->name() . ' was unverified!'); + + return redirect()->route('admin.users'); + } + public function delete(User $user): RedirectResponse { $this->authorize(UserPolicy::DELETE, $user); diff --git a/app/Http/Controllers/Articles/ArticlesController.php b/app/Http/Controllers/Articles/ArticlesController.php index b876e4461..f106566d0 100644 --- a/app/Http/Controllers/Articles/ArticlesController.php +++ b/app/Http/Controllers/Articles/ArticlesController.php @@ -115,11 +115,13 @@ public function store(ArticleRequest $request) $article = Article::findByUuidOrFail($uuid); - $this->success( - $request->shouldBeSubmitted() - ? 'Thank you for submitting, unfortunately we can\'t accept every submission. You\'ll only hear back from us when we accept your article.' - : 'Article successfully created!' - ); + if ($article->isNotApproved()) { + $this->success( + $request->shouldBeSubmitted() + ? 'Thank you for submitting, unfortunately we can\'t accept every submission. You\'ll only hear back from us when we accept your article.' + : 'Article successfully created!' + ); + } return $request->wantsJson() ? ArticleResource::make($article) diff --git a/app/Http/Requests/ArticleRequest.php b/app/Http/Requests/ArticleRequest.php index db953e8c9..0c2e5d470 100644 --- a/app/Http/Requests/ArticleRequest.php +++ b/app/Http/Requests/ArticleRequest.php @@ -5,6 +5,7 @@ use App\Models\User; use App\Rules\HttpImageRule; use Illuminate\Http\Concerns\InteractsWithInput; +use Illuminate\Validation\Rules\RequiredIf; class ArticleRequest extends Request { @@ -14,6 +15,7 @@ public function rules(): array { return [ 'title' => ['required', 'max:100'], + 'hero_image_id' => ['nullable', new RequiredIf(auth()->user()->isVerifiedAuthor())], 'body' => ['required', new HttpImageRule], 'tags' => 'array|nullable', 'tags.*' => 'exists:tags,id', @@ -58,4 +60,9 @@ public function shouldBeSubmitted(): bool { return $this->boolean('submitted'); } + + public function heroImageId(): ?string + { + return $this->get('hero_image_id'); + } } diff --git a/app/Jobs/CreateArticle.php b/app/Jobs/CreateArticle.php index 840e7d0df..601fb58d2 100644 --- a/app/Jobs/CreateArticle.php +++ b/app/Jobs/CreateArticle.php @@ -20,6 +20,7 @@ public function __construct( private string $body, private User $author, private bool $shouldBeSubmitted, + private ?string $heroImageId = null, array $options = [] ) { $this->originalUrl = $options['original_url'] ?? null; @@ -34,6 +35,7 @@ public static function fromRequest(ArticleRequest $request, UuidInterface $uuid) $request->body(), $request->author(), $request->shouldBeSubmitted(), + $request->heroImageId(), [ 'original_url' => $request->originalUrl(), 'tags' => $request->tags(), @@ -46,16 +48,27 @@ public function handle(): void $article = new Article([ 'uuid' => $this->uuid->toString(), 'title' => $this->title, + 'hero_image_id' => $this->heroImageId, 'body' => $this->body, 'original_url' => $this->originalUrl, 'slug' => $this->title, 'submitted_at' => $this->shouldBeSubmitted ? now() : null, + 'approved_at' => $this->canBeAutoApproved() ? now() : null, ]); $article->authoredBy($this->author); $article->syncTags($this->tags); + if ($article->hero_image_id) { + SyncArticleImage::dispatch($article); + } + if ($article->isAwaitingApproval()) { event(new ArticleWasSubmittedForApproval($article)); } } + + private function canBeAutoApproved(): bool + { + return $this->shouldBeSubmitted && $this->author->canVerifiedAuthorPublishMoreArticleToday(); + } } diff --git a/app/Jobs/SyncArticleImage.php b/app/Jobs/SyncArticleImage.php new file mode 100644 index 000000000..f41d9f7b5 --- /dev/null +++ b/app/Jobs/SyncArticleImage.php @@ -0,0 +1,60 @@ +fetchUnsplashImageDataFromId($this->article); + + if (! is_null($imageData)) { + $this->article->hero_image_url = $imageData['image_url']; + $this->article->hero_image_author_name = $imageData['author_name']; + $this->article->hero_image_author_url = $imageData['author_url']; + $this->article->save(); + } + } + + protected function fetchUnsplashImageDataFromId(Article $article): ?array + { + $response = Http::retry(3, 100, throw: false) + ->withToken(config('services.unsplash.access_key'), 'Client-ID') + ->get("https://api.unsplash.com/photos/{$article->hero_image_id}"); + + if ($response->failed()) { + $article->hero_image_id = null; + $article->save(); + + return null; + } + + $response = $response->json(); + + // Trigger as Unsplash download... + Http::retry(3, 100, throw: false) + ->withToken(config('services.unsplash.access_key'), 'Client-ID') + ->get($response['links']['download_location']); + + return [ + 'image_url' => $response['urls']['raw'], + 'author_name' => $response['user']['name'], + 'author_url' => $response['user']['links']['html'], + ]; + } +} \ No newline at end of file diff --git a/app/Jobs/UnVerifyAuthor.php b/app/Jobs/UnVerifyAuthor.php new file mode 100644 index 000000000..ec742a952 --- /dev/null +++ b/app/Jobs/UnVerifyAuthor.php @@ -0,0 +1,29 @@ +user->author_verified_at = null; + $this->user->save(); + } +} diff --git a/app/Jobs/UpdateArticle.php b/app/Jobs/UpdateArticle.php index 7a62c245d..17b12bc44 100644 --- a/app/Jobs/UpdateArticle.php +++ b/app/Jobs/UpdateArticle.php @@ -17,6 +17,7 @@ public function __construct( private string $title, private string $body, private bool $shouldBeSubmitted, + private ?string $heroImageId = null, array $options = [] ) { $this->originalUrl = $options['original_url'] ?? null; @@ -30,6 +31,7 @@ public static function fromRequest(Article $article, ArticleRequest $request): s $request->title(), $request->body(), $request->shouldBeSubmitted(), + $request->heroImageId(), [ 'original_url' => $request->originalUrl(), 'tags' => $request->tags(), @@ -39,9 +41,12 @@ public static function fromRequest(Article $article, ArticleRequest $request): s public function handle(): void { + $originalImage = $this->article->hero_image_id; + $this->article->update([ 'title' => $this->title, 'body' => $this->body, + 'hero_image_id' => $this->heroImageId, 'original_url' => $this->originalUrl, 'slug' => $this->title, ]); @@ -54,6 +59,10 @@ public function handle(): void } $this->article->syncTags($this->tags); + + if ($this->article->hero_image_id !== $originalImage) { + SyncArticleImage::dispatch($this->article); + } } private function shouldUpdateSubmittedAt(): bool diff --git a/app/Jobs/VerifyAuthor.php b/app/Jobs/VerifyAuthor.php new file mode 100644 index 000000000..89dbf332a --- /dev/null +++ b/app/Jobs/VerifyAuthor.php @@ -0,0 +1,29 @@ +user->author_verified_at = now(); + $this->user->save(); + } +} diff --git a/app/Models/Article.php b/app/Models/Article.php index ad6aad1da..b6a3f0cc0 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -196,7 +196,7 @@ public function isShared(): bool public function isAwaitingApproval(): bool { - return $this->isSubmitted() && $this->isNotApproved() && $this->isNotDeclined(); + return $this->isSubmitted() && $this->isNotApproved() && $this->isNotDeclined() && ! $this->author()->canVerifiedAuthorPublishMoreArticleToday(); } public function isNotAwaitingApproval(): bool diff --git a/app/Models/User.php b/app/Models/User.php index cda596775..7d429c736 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -73,6 +73,7 @@ protected function casts(): array { return [ 'allowed_notifications' => 'array', + 'author_verified_at' => 'datetime', ]; } @@ -151,6 +152,11 @@ public function type(): int return (int) $this->type; } + public function isRegularUser(): bool + { + return $this->type() === self::DEFAULT; + } + public function isModerator(): bool { return $this->type() === self::MODERATOR; @@ -300,6 +306,28 @@ public function delete() parent::delete(); } + public function isVerifiedAuthor(): bool + { + return ! is_null($this->author_verified_at) || $this->isAdmin(); + } + + public function canVerifiedAuthorPublishMoreArticleToday(): bool + { + if ($this->isAdmin()) { + return true; + } + + if (! $this->isVerifiedAuthor()) { + return false; + } + + $publishedTodayCount = $this->articles() + ->whereDate('submitted_at', today()) + ->count(); + + return $publishedTodayCount < 2; + } + public function countSolutions(): int { return $this->replyAble()->isSolution()->count(); diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php index 519a218ec..d01f52908 100644 --- a/app/Policies/UserPolicy.php +++ b/app/Policies/UserPolicy.php @@ -21,13 +21,16 @@ public function admin(User $user): bool public function ban(User $user, User $subject): bool { - return ($user->isAdmin() && ! $subject->isAdmin()) || - ($user->isModerator() && ! $subject->isAdmin() && ! $subject->isModerator()); + if ($subject->isAdmin()) { + return false; + } + + return $user->isAdmin() || ($user->isModerator() && ! $subject->isModerator()); } public function block(User $user, User $subject): bool { - return ! $user->is($subject) && ! $subject->isModerator() && ! $subject->isAdmin(); + return ! $user->is($subject) && $subject->isRegularUser(); } public function delete(User $user, User $subject): bool diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index f434385cc..8f5c3609b 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -51,4 +51,11 @@ public function moderator(): self return ['type' => User::MODERATOR]; }); } + + public function verifiedAuthor(): self + { + return $this->state(function () { + return ['author_verified_at' => now()]; + }); + } } diff --git a/database/migrations/2025_06_14_222049_add_verified_author_at_to_users_table.php b/database/migrations/2025_06_14_222049_add_verified_author_at_to_users_table.php new file mode 100644 index 000000000..f36d1c48d --- /dev/null +++ b/database/migrations/2025_06_14_222049_add_verified_author_at_to_users_table.php @@ -0,0 +1,17 @@ +timestamp('author_verified_at') + ->nullable() + ->after('email_verified_at'); + }); + } +}; diff --git a/resources/views/admin/users.blade.php b/resources/views/admin/users.blade.php index 204d1f018..fa8a6c21c 100644 --- a/resources/views/admin/users.blade.php +++ b/resources/views/admin/users.blade.php @@ -90,6 +90,37 @@

All the threads from this user will be deleted. This cannot be undone.

@endcan + + {{-- Toggle Verified Author --}} + @can(App\Policies\UserPolicy::ADMIN, $user) + @if ($user->isVerifiedAuthor()) + + +

This will remove the verified author status from this user.

+
+ @else + + +

This will mark this user as a verified author.

+
+ @endif + @endcan @endforeach diff --git a/resources/views/articles/overview.blade.php b/resources/views/articles/overview.blade.php index 352116ed2..72b179e00 100644 --- a/resources/views/articles/overview.blade.php +++ b/resources/views/articles/overview.blade.php @@ -153,11 +153,19 @@ - - - {{ $author->username() }} - - + + + + {{ $author->username() }} + + + + @if ($author->isVerifiedAuthor()) + + @svg('heroicon-s-check-badge', 'w-5 h-5 text-lio-500') + + @endif + {{ $author->articles_count }} {{ Str::plural('Article', $author->articles_count) }} diff --git a/resources/views/articles/show.blade.php b/resources/views/articles/show.blade.php index 3a079e819..435b22bbc 100644 --- a/resources/views/articles/show.blade.php +++ b/resources/views/articles/show.blade.php @@ -149,9 +149,17 @@ class="prose prose-lg text-gray-800 prose-lio"
- - {{ $article->author()->username() }} ({{ $article->author()->name() }}) - + + + {{ $article->author()->username() }} ({{ $article->author()->name() }}) + + + @if ($article->author()->isVerifiedAuthor()) + + @svg('heroicon-s-check-badge', 'w-6 h-6 text-lio-500') + + @endif + {{ $article->author()->bio() }} diff --git a/resources/views/components/a.blade.php b/resources/views/components/a.blade.php new file mode 100644 index 000000000..a987a2896 --- /dev/null +++ b/resources/views/components/a.blade.php @@ -0,0 +1 @@ +{{ $slot }} \ No newline at end of file diff --git a/resources/views/components/articles/form.blade.php b/resources/views/components/articles/form.blade.php index 76ad1677d..aac694789 100644 --- a/resources/views/components/articles/form.blade.php +++ b/resources/views/components/articles/form.blade.php @@ -25,7 +25,7 @@ - Every article that gets approved will be shared with our 50.000 users and wil be tweeted out on our X (Twitter) account which has over 50,000 followers. Feel free to submit as many articles as you like. You can even cross-reference an article on your blog with the original url. + Every article that gets approved will be shared with our 50.000 users and wil be tweeted out on our X (Twitter) account which has over 50,000 followers as well as our Bluesky account. Feel free to submit as many articles as you like. You can even cross-reference an article on your blog with the original url. @@ -40,7 +40,7 @@
- + Maximum 100 characters. @@ -48,6 +48,28 @@
+
+
+ Hero Image + + + + @if (($article?->author() ?? auth()->user())->isVerifiedAuthor()) +

+ Because you're a verified author, you're required to choose an Unsplash image for your article. +

+ @else +

+ Optionally, add an Unsplash image. +

+ @endif + +

+ Please enter the Unsplash ID of the image you want to use. You can find the ID in the URL of the image on Unsplash. Please make sure to only use landscape images. For example, if the URL is https://unsplash.com/photos/...-NoiJZhDF4Es, then the ID is NoiJZhDF4Es. After saving your article, the image will be automatically fetched and displayed in the article. This might take a few minutes. If you want to change the image later, you can do so by editing the article before submitting it for approval. +

+
+
+
diff --git a/resources/views/components/rules-banner.blade.php b/resources/views/components/rules-banner.blade.php index 89ce60490..f7398d1b9 100644 --- a/resources/views/components/rules-banner.blade.php +++ b/resources/views/components/rules-banner.blade.php @@ -1,3 +1,3 @@ -

- Make sure you've read our rules before proceeding. +

+ Make sure you've read our rules before proceeding.

diff --git a/resources/views/components/threads/form.blade.php b/resources/views/components/threads/form.blade.php index 0185d412c..e8a29daff 100644 --- a/resources/views/components/threads/form.blade.php +++ b/resources/views/components/threads/form.blade.php @@ -21,11 +21,12 @@ Create a new thread @endif + Please search for your question before posting your thread by using the search box in the navigation bar.
- Want to share large code snippets? Share them through our pastebin. + Want to share large code snippets? Share them through our pastebin.
diff --git a/routes/console.php b/routes/console.php index 9c925fe1a..b45ac35de 100644 --- a/routes/console.php +++ b/routes/console.php @@ -8,5 +8,4 @@ Schedule::command('horizon:snapshot')->everyFiveMinutes(); Schedule::command('lio:post-article-to-social-media')->twiceDaily(14, 18); Schedule::command('lio:generate-sitemap')->daily()->graceTimeInMinutes(25); -Schedule::command('lio:sync-article-images')->cron('*/5 7-23 * * *'); Schedule::command('lio:update-article-view-counts')->twiceDaily(); diff --git a/routes/web.php b/routes/web.php index 3e141b0bb..3e01420bd 100644 --- a/routes/web.php +++ b/routes/web.php @@ -136,6 +136,8 @@ Route::get('users', [UsersController::class, 'index'])->name('.users'); Route::put('users/{username}/ban', [UsersController::class, 'ban'])->name('.users.ban'); Route::put('users/{username}/unban', [UsersController::class, 'unban'])->name('.users.unban'); + Route::put('users/{username}/verify-author', [UsersController::class, 'verifyAuthor'])->name('.users.verify-author'); + Route::put('users/{username}/unverify-author', [UsersController::class, 'unverifyAuthor'])->name('.users.unverify-author'); Route::delete('users/{username}', [UsersController::class, 'delete'])->name('.users.delete'); Route::delete('users/{username}/threads', [UsersController::class, 'deleteThreads'])->name('.users.threads.delete'); diff --git a/tests/CreatesUsers.php b/tests/CreatesUsers.php index cde560304..d5300256a 100644 --- a/tests/CreatesUsers.php +++ b/tests/CreatesUsers.php @@ -3,6 +3,7 @@ namespace Tests; use App\Models\User; +use Database\Factories\UserFactory; trait CreatesUsers { @@ -30,9 +31,9 @@ protected function loginAsAdmin(array $attributes = []): User return $this->login(array_merge($attributes, ['type' => User::ADMIN])); } - protected function createUser(array $attributes = []): User + protected function createUser(array $attributes = [], ?UserFactory $userFactory = null): User { - return User::factory()->create(array_merge([ + return ($userFactory ?? User::factory())->create(array_merge([ 'name' => 'John Doe', 'username' => 'johndoe', 'email' => 'john@example.com', diff --git a/tests/Feature/ArticleTest.php b/tests/Feature/ArticleTest.php index 78b331d37..a3dd06428 100644 --- a/tests/Feature/ArticleTest.php +++ b/tests/Feature/ArticleTest.php @@ -1,11 +1,14 @@ create(['title' => 'My First Article', 'slug' => 'my-first-article', 'submitted_at' => now(), 'approved_at' => now(), 'view_count' => 9]); + $article = Article::factory()->create([ + 'title' => 'My First Article', + 'slug' => 'my-first-article', + 'submitted_at' => now(), + 'approved_at' => now(), + 'view_count' => 9, + ]); $this->get("/articles/{$article->slug()}") ->assertSee('My First Article') @@ -576,3 +585,37 @@ ->assertSee('My First Article') ->assertSee('10 views'); }); + +test('verified authors can publish two articles per day with no approval needed', function () { + $author = $this->createUser(userFactory: User::factory()->verifiedAuthor()); + + Article::factory()->count(2)->create([ + 'author_id' => $author->id, + 'submitted_at' => now()->addMinutes(1), // after verification + ]); + + expect($author->canVerifiedAuthorPublishMoreArticleToday())->toBeFalse(); +}); + +test('verified authors skip the approval message when submitting new article', function () { + Bus::fake(SyncArticleImage::class); + + $author = $this->createUser(userFactory: User::factory()->verifiedAuthor()); + $this->loginAs($author); + + $response = $this->post('/articles', [ + 'title' => 'Using database migrations', + 'hero_image_id' => 'NoiJZhDF4Es', + 'body' => 'This article will go into depth on working with database migrations.', + 'tags' => [], + 'submitted' => '1', + ]); + + $response + ->assertRedirect('/articles/using-database-migrations') + ->assertSessionMissing('success'); + + Bus::assertDispatched(SyncArticleImage::class, function (SyncArticleImage $job) { + return $job->article->hero_image_id === 'NoiJZhDF4Es'; + }); +}); diff --git a/tests/Integration/Jobs/CreateArticleTest.php b/tests/Integration/Jobs/CreateArticleTest.php index 01e483a1a..8ff6ee29b 100644 --- a/tests/Integration/Jobs/CreateArticleTest.php +++ b/tests/Integration/Jobs/CreateArticleTest.php @@ -15,7 +15,7 @@ $uuid = Str::uuid(); - $this->dispatch(new CreateArticle($uuid, 'Title', 'Body', $user, false, [ + $this->dispatch(new CreateArticle($uuid, 'Title', 'Body', $user, false, null, [ 'original_url' => 'https://laravel.io', ])); @@ -35,7 +35,7 @@ $uuid = Str::uuid(); - $this->dispatch(new CreateArticle($uuid, 'Title', 'Body', $user, true, [ + $this->dispatch(new CreateArticle($uuid, 'Title', 'Body', $user, true, null, [ 'original_url' => 'https://laravel.io', ])); diff --git a/tests/Integration/Commands/SyncArticleImagesTest.php b/tests/Integration/Jobs/SyncArticleImageTest.php similarity index 93% rename from tests/Integration/Commands/SyncArticleImagesTest.php rename to tests/Integration/Jobs/SyncArticleImageTest.php index 422994b7d..ba0e92c9b 100644 --- a/tests/Integration/Commands/SyncArticleImagesTest.php +++ b/tests/Integration/Jobs/SyncArticleImageTest.php @@ -1,6 +1,6 @@ now(), ]); - (new SyncArticleImages)->handle(); + SyncArticleImage::dispatchSync($article); $article->refresh(); @@ -53,7 +53,7 @@ 'approved_at' => now(), ]); - (new SyncArticleImages)->handle(); + SyncArticleImage::dispatchSync($article); $article->refresh();