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.
+This will mark this user as a verified author.
+
+ Because you're a verified author, you're required to choose an
+ Optionally, add an
+ 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.
+
- Make sure you've read our rules before proceeding. +
+ Make sure you've read our