Skip to content

Commit 05730b4

Browse files
committed
Merge branch 'main' into laravel-12
* main: Fix code styling Show latest news in dashboard Add de:doc to sponsors list Allow showing 10 years of incidents. Closes #248 Fix code styling [3.x] Add email verification route (#247)
2 parents b938527 + 2a3d835 commit 05730b4

File tree

12 files changed

+226
-2
lines changed

12 files changed

+226
-2
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ Login to the account at `/dashboard` and use credentials:
7575
<a href="https://dreamtilt.com.au"><img width="100px" src="https://github.com/dreamtilt.png" alt="Dreamtilt"></a>
7676
<a href="https://xyphen-it.nl"><img width="100px" src="https://github.com/xyphen-it.png" alt="Xyphen-IT"></a>
7777
<a href="https://coderabbit.ai/"><img width="100px" src="https://github.com/coderabbitai.png" alt="Code Rabbit"></a>
78+
<a href="https://scramble.dedoc.co/"><img width="100px" src="https://github.com/dedoc.png" alt="de:doc"></a>
7879
</p>
7980

8081
## Security Vulnerabilities

composer.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@
2020
},
2121
"require": {
2222
"php": "^8.2",
23+
"ext-simplexml": "*",
2324
"doctrine/dbal": "^3.6",
2425
"filament/filament": "^3.2.57",
2526
"filament/spatie-laravel-settings-plugin": "^3.2",
2627
"guzzlehttp/guzzle": "^7.8",
28+
"illuminate/cache": "^11.23.0|^12.0",
2729
"illuminate/console": "^11.23.0|^12.0",
2830
"illuminate/database": "^11.23.0|^12.0",
2931
"illuminate/events": "^11.23.0|^12.0",

config/cachet.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,4 +160,9 @@
160160
*/
161161
'demo_mode' => env('CACHET_DEMO_MODE', false),
162162

163+
'feed' => [
164+
'uri' => env('CACHET_FEED_URI', 'https://blog.cachethq.io/rss'),
165+
'cache' => env('CACHET_FEED_CACHE', 3600),
166+
],
167+
163168
];

resources/lang/en/cachet.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
'keep_up_to_date' => 'Keep up to date with the latest news and releases by following the *Cachet blog*.',
88
'work_in_progress_text' => 'Cachet is under active development. Things are still subject to change.',
99
],
10+
'feed' => [
11+
'section_heading' => 'Latest Blog Posts',
12+
'empty' => 'No blog posts were found. Check *the blog* for further information.',
13+
'posted_at' => 'Posted :date',
14+
],
1015
'powered_by' => 'Powered by',
1116
'open_source_status_page' => 'The open-source status page.',
1217
'all_times_shown_in' => 'All times are shown in *:timezone*.',
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<x-filament::widget>
2+
<x-filament::section :heading="__('cachet::cachet.feed.section_heading')">
3+
<div class="relative">
4+
<ul role="list" class="gap-4 flex flex-col">
5+
@forelse ($items as $post)
6+
<li>
7+
<a class="flex items-center justify-between text-sm" href="{{ $post['link'] }}" target="_blank">
8+
<div class="overflow-hidden text-sm leading-6 text-gray-500 dark:text-gray-400">
9+
<h3 class="text-base font-medium text-gray-950 dark:text-white">{{ $post['title'] }}</h3>
10+
<time class="text-muted text-xs" datetime="{{ $post['date']->toW3cString() }}" title="{{ $post['date']->toDateTimeString() }}">
11+
{{ __('cachet::cachet.feed.posted_at', ['date' => $post['date']->diffForHumans()]) }}
12+
</time>
13+
<p class="break-words truncate">{{ $post['description'] }}</p>
14+
</div>
15+
<div class="">
16+
<x-heroicon-o-chevron-right class="w-5 h-5 text-gray-400" />
17+
</div>
18+
</a>
19+
</li>
20+
@empty
21+
<li class="text-center filament-tables-text-column">
22+
<p class="text-sm text-gray-500">{!! $noItems !!}</p>
23+
</li>
24+
@endforelse
25+
</ul>
26+
</div>
27+
</x-filament::section>
28+
</x-filament::widget>
29+

src/Filament/Pages/Settings/ManageCachet.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ public function form(Form $form): Form
5252
->numeric()
5353
->label(__('cachet::settings.manage_cachet.incident_days_label'))
5454
->minValue(1)
55-
->maxValue(365)
55+
->maxValue(3650)
5656
->step(1),
5757

5858
Forms\Components\TextInput::make('major_outage_threshold')

src/Filament/Widgets/Feed.php

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
namespace Cachet\Filament\Widgets;
4+
5+
use Filament\Widgets\Concerns\CanPoll;
6+
use Filament\Widgets\Widget;
7+
use Illuminate\Support\Carbon;
8+
use Illuminate\Support\Facades\Blade;
9+
use Illuminate\Support\Facades\Cache;
10+
use Illuminate\Support\Str;
11+
use Illuminate\Support\Uri;
12+
use Throwable;
13+
14+
class Feed extends Widget
15+
{
16+
use CanPoll;
17+
18+
protected int|string|array $columnSpan = 'full';
19+
20+
protected static string $view = 'cachet::filament.widgets.feed';
21+
22+
protected static ?int $sort = 10;
23+
24+
protected function getViewData(): array
25+
{
26+
return [
27+
'items' => $this->getFeed(),
28+
'noItems' => Blade::render($this->getEmptyBlock()),
29+
];
30+
}
31+
32+
/**
33+
* Get the generated empty block text.
34+
*/
35+
public function getEmptyBlock(): string
36+
{
37+
return preg_replace(
38+
'/\*(.*?)\*/',
39+
'<x-filament::link href="'.config('cachet.feed.uri').'" target="_blank" rel="nofollow noopener">$1</x-filament::link>',
40+
__('cachet::cachet.feed.empty')
41+
);
42+
}
43+
44+
/**
45+
* Get the feed from the cache or fetch it fresh.
46+
*/
47+
protected function getFeed(): array
48+
{
49+
return Cache::flexible('cachet-feed', [
50+
60 * 15,
51+
60 * 60,
52+
], fn () => $this->fetchFeed(
53+
config('cachet.feed.uri')
54+
));
55+
}
56+
57+
/**
58+
* Fetch the data from the given RSS feed.
59+
*/
60+
protected function fetchFeed(string $uri, int $maxPosts = 5): array
61+
{
62+
try {
63+
$xml = simplexml_load_string(file_get_contents($uri));
64+
65+
$posts = [];
66+
67+
$feedItems = $xml->channel->item ?? $xml->entry ?? [];
68+
$feedIndex = 0;
69+
70+
foreach ($feedItems as $item) {
71+
if ($feedIndex >= $maxPosts) {
72+
break;
73+
}
74+
75+
$posts[] = [
76+
'title' => (string) ($item->title ?? ''),
77+
'link' => Uri::of((string) ($item->link ?? ''))->withQuery([
78+
'ref' => 'cachet-dashboard',
79+
]),
80+
'description' => Str::of($item->description ?? $item->summary ?? '')->limit(preserveWords: true),
81+
'date' => Carbon::parse((string) ($item->pubDate ?? $item->updated ?? '')),
82+
];
83+
84+
$feedIndex++;
85+
}
86+
87+
return $posts;
88+
} catch (Throwable $e) {
89+
return [];
90+
}
91+
}
92+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Cachet\Http\Controllers\Auth;
6+
7+
use Illuminate\Foundation\Auth\EmailVerificationRequest;
8+
use Illuminate\Http\RedirectResponse;
9+
10+
final class VerifyEmailController
11+
{
12+
/**
13+
* Mark the authenticated user's email address as verified.
14+
*/
15+
public function __invoke(EmailVerificationRequest $request): RedirectResponse
16+
{
17+
$request->fulfill();
18+
19+
return redirect()->intended(route('cachet.status-page', absolute: false).'?verified=1');
20+
}
21+
}

src/Models/User.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Cachet\Concerns\CachetUser;
66
use Cachet\Database\Factories\UserFactory;
7+
use Illuminate\Auth\MustVerifyEmail as MustVerifyEmailTrait;
78
use Illuminate\Contracts\Auth\MustVerifyEmail;
89
use Illuminate\Contracts\Translation\HasLocalePreference;
910
use Illuminate\Database\Eloquent\Factories\Factory;
@@ -23,7 +24,7 @@
2324
class User extends Authenticatable implements CachetUser, HasLocalePreference, MustVerifyEmail
2425
{
2526
/** @use HasFactory<\Cachet\Database\Factories\UserFactory> */
26-
use HasApiTokens, HasFactory, Notifiable;
27+
use HasApiTokens, HasFactory, MustVerifyEmailTrait, Notifiable;
2728

2829
/**
2930
* The attributes that are mass assignable.

src/PendingRouteRegistration.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Cachet;
44

5+
use Cachet\Http\Controllers\Auth\VerifyEmailController;
56
use Cachet\Http\Controllers\HealthController;
67
use Cachet\Http\Controllers\RssController;
78
use Cachet\Http\Controllers\Setup\SetupController;
@@ -40,7 +41,19 @@ public function register(): void
4041
$router->get('/health', HealthController::class)->name('health');
4142

4243
$router->get('/rss', RssController::class)->name('rss');
44+
4345
});
46+
47+
$this->registerEmailVerificationRoutes();
48+
}
49+
50+
private function registerEmailVerificationRoutes(): void
51+
{
52+
Route::middleware('auth')->group(function () {
53+
Route::get('verify-email/{id}/{hash}', VerifyEmailController::class)
54+
->middleware(['signed', 'throttle:6,1'])
55+
->name('verification.verify');
56+
});
4457
}
4558

4659
/**
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Cachet\Tests\Feature\Auth;
6+
7+
use Illuminate\Auth\Events\Verified;
8+
use Illuminate\Support\Facades\Event;
9+
use Illuminate\Support\Facades\URL;
10+
use Workbench\App\User;
11+
12+
use function Pest\Laravel\actingAs;
13+
use function PHPUnit\Framework\assertFalse;
14+
use function PHPUnit\Framework\assertTrue;
15+
16+
test('email can be verified', function () {
17+
$user = User::factory()->unverified()->create();
18+
19+
Event::fake();
20+
21+
$verificationUrl = URL::temporarySignedRoute(
22+
'verification.verify',
23+
now()->addMinutes(60),
24+
['id' => $user->id, 'hash' => sha1($user->email)]
25+
);
26+
27+
$response = actingAs($user)->get($verificationUrl);
28+
29+
Event::assertDispatched(Verified::class);
30+
assertTrue($user->fresh()->hasVerifiedEmail());
31+
$response->assertRedirect(route('cachet.status-page', absolute: false).'?verified=1');
32+
});
33+
34+
test('email is not verified with invalid hash', function () {
35+
$user = User::factory()->unverified()->create();
36+
37+
$verificationUrl = URL::temporarySignedRoute(
38+
'verification.verify',
39+
now()->addMinutes(60),
40+
['id' => $user->id, 'hash' => sha1('wrong-email')]
41+
);
42+
43+
actingAs($user)->get($verificationUrl);
44+
assertFalse($user->fresh()->hasVerifiedEmail());
45+
});

workbench/database/factories/UserFactory.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,14 @@ public function active()
4444
];
4545
});
4646
}
47+
48+
/**
49+
* Indicate that the model's email address should be unverified.
50+
*/
51+
public function unverified(): static
52+
{
53+
return $this->state(fn (array $attributes) => [
54+
'email_verified_at' => null,
55+
]);
56+
}
4757
}

0 commit comments

Comments
 (0)