Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ GITHUB_REDIRECT_URI="${APP_URL}/auth/github/callback"
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=

LASTFM_API_KEY=

# New GitHub accounts must be at least this many days old, and have a verified primary email,
# before they can sign up. Both apply to the first sign-in only.
SIGNUP_MIN_ACCOUNT_AGE_DAYS=7
Expand Down
1 change: 1 addition & 0 deletions app/Actions/CreateExport.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ public function handle(User $user): array
'live_platform' => $user->live_platform?->value,
'live_url' => $user->live_url,
'live_ends_at' => $user->live_ends_at?->toIso8601String(),
'lastfm_username' => $user->lastfm_username,
'last_seen_at' => $user->last_seen_at?->toDateString(),
'commit_rules_accepted_at' => $user->commit_rules_accepted_at?->toIso8601String(),
'emails_unsubscribed_at' => $user->emails_unsubscribed_at?->toIso8601String(),
Expand Down
15 changes: 15 additions & 0 deletions app/Actions/DeleteUserNowPlaying.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Models\User;

final readonly class DeleteUserNowPlaying
{
public function handle(User $user): void
{
$user->update(['lastfm_username' => null]);
}
}
15 changes: 15 additions & 0 deletions app/Actions/UpdateUserNowPlaying.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Models\User;

final readonly class UpdateUserNowPlaying
{
public function handle(User $user, string $lastFmUsername): void
{
$user->update(['lastfm_username' => $lastFmUsername]);
}
}
21 changes: 21 additions & 0 deletions app/Contracts/Services/LastFmProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace App\Contracts\Services;

use App\Dto\Track;
use Illuminate\Http\Client\HttpClientException;

interface LastFmProvider
{
/**
* @throws HttpClientException
*/
public function exists(string $username): bool;

/**
* @throws HttpClientException
*/
public function nowPlaying(string $username): ?Track;
}
35 changes: 35 additions & 0 deletions app/Dto/Track.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace App\Dto;

use Illuminate\Contracts\Support\Arrayable;

/**
* @implements Arrayable<string, string|null>
*/
final readonly class Track implements Arrayable
{
public function __construct(
public string $artist,
public string $title,
public ?string $album,
public ?string $url,
) {
//
}

/**
* @return array{artist: string, title: string, album: string|null, url: string|null}
*/
public function toArray(): array
{
return [
'artist' => $this->artist,
'title' => $this->title,
'album' => $this->album,
'url' => $this->url,
];
}
}
3 changes: 2 additions & 1 deletion app/Enums/Feature.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ enum Feature: string
{
case Translations = 'translations';
case OrganizationCommits = 'organization_commits';
case NowPlaying = 'now_playing';

/**
* Features are on until switched off, except one still being built, which stays off until switched on.
*/
public function isEnabledByDefault(): bool
{
return $this !== self::OrganizationCommits;
return ! in_array($this, [self::OrganizationCommits, self::NowPlaying], true);
}
}
38 changes: 38 additions & 0 deletions app/Http/Controllers/UserNowPlayingController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Actions\DeleteUserNowPlaying;
use App\Actions\UpdateUserNowPlaying;
use App\Http\Requests\UpdateUserNowPlayingRequest;
use App\Models\User;
use App\Queries\NowPlayingQuery;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;

final readonly class UserNowPlayingController
{
public function show(#[CurrentUser] ?User $currentUser, User $user, NowPlayingQuery $nowPlaying): JsonResponse
{
return response()->json([
'track' => $currentUser?->blockerIdWith($user) === null ? $nowPlaying->get($user) : null,
])->header('Cache-Control', 'no-store, private');
}

public function update(UpdateUserNowPlayingRequest $request, #[CurrentUser] User $user, UpdateUserNowPlaying $action): RedirectResponse
{
$action->handle($user, $request->lastFmUsername());

return back();
}

public function destroy(#[CurrentUser] User $user, DeleteUserNowPlaying $action): RedirectResponse
{
$action->handle($user);

return back();
}
}
32 changes: 32 additions & 0 deletions app/Http/Requests/UpdateUserNowPlayingRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Rules\LastFmUsername;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;

final class UpdateUserNowPlayingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

/**
* @return array<string, list<string|ValidationRule>>
*/
public function rules(): array
{
return [
'lastfm_username' => ['bail', 'required', 'string', new LastFmUsername()],
];
}

public function lastFmUsername(): string
{
return $this->string('lastfm_username')->value();
}
}
4 changes: 4 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
* @property-read LivePlatform|null $live_platform
* @property-read string|null $live_url
* @property-read CarbonInterface|null $live_ends_at
* @property-read string|null $lastfm_username
* @property-read Log $log
* @property-read CarbonInterface|null $onboarded_at
* @property-read CarbonInterface|null $github_follows_imported_at
Expand Down Expand Up @@ -115,6 +116,7 @@
'live_platform',
'live_url',
'live_ends_at',
'lastfm_username',
'last_seen_at',
'banned_at',
'commit_rules_accepted_at',
Expand Down Expand Up @@ -164,6 +166,7 @@ final class User extends Authenticatable
'live_platform' => null,
'live_url' => null,
'live_ends_at' => null,
'lastfm_username' => null,
'last_seen_at' => null,
'banned_at' => null,
'commit_rules_accepted_at' => null,
Expand Down Expand Up @@ -212,6 +215,7 @@ public function casts(): array
'live_platform' => LivePlatform::class,
'live_url' => 'string',
'live_ends_at' => 'datetime',
'lastfm_username' => 'string',
'last_seen_at' => 'date',
'banned_at' => 'datetime',
'commit_rules_accepted_at' => 'datetime',
Expand Down
10 changes: 10 additions & 0 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use App\Contracts\Services\EmbeddingProvider;
use App\Contracts\Services\GitHubProvider;
use App\Contracts\Services\HeadlineProvider;
use App\Contracts\Services\LastFmProvider;
use App\Contracts\Services\LinkPreviewProvider;
use App\Contracts\Services\ModerationProvider;
use App\Contracts\Services\PushNotificationProvider;
Expand All @@ -30,6 +31,8 @@
use App\Services\GitHubProviders\NullGitHubProvider;
use App\Services\HeadlineProviders\NullHeadlineProvider;
use App\Services\HeadlineProviders\OpenAIHeadlineProvider;
use App\Services\LastFmProviders\HttpLastFmProvider;
use App\Services\LastFmProviders\NullLastFmProvider;
use App\Services\LinkPreviewProviders\HttpLinkPreviewProvider;
use App\Services\LinkPreviewProviders\NullLinkPreviewProvider;
use App\Services\ModerationProviders\NullModerationProvider;
Expand Down Expand Up @@ -68,6 +71,7 @@ final class AppServiceProvider extends ServiceProvider
EmbeddingProvider::class => OpenAIEmbeddingProvider::class,
GitHubProvider::class => HttpGitHubProvider::class,
HeadlineProvider::class => OpenAIHeadlineProvider::class,
LastFmProvider::class => HttpLastFmProvider::class,
LinkPreviewProvider::class => HttpLinkPreviewProvider::class,
ModerationProvider::class => OpenAIModerationProvider::class,
PushNotificationProvider::class => WebPushNotificationProvider::class,
Expand All @@ -88,6 +92,7 @@ final class AppServiceProvider extends ServiceProvider
EmbeddingProvider::class => NullEmbeddingProvider::class,
GitHubProvider::class => NullGitHubProvider::class,
HeadlineProvider::class => NullHeadlineProvider::class,
LastFmProvider::class => NullLastFmProvider::class,
LinkPreviewProvider::class => NullLinkPreviewProvider::class,
ModerationProvider::class => NullModerationProvider::class,
PushNotificationProvider::class => NullPushNotificationProvider::class,
Expand Down Expand Up @@ -240,6 +245,11 @@ private function bootRateLimitingDefaults(): void
$request->user() instanceof User ? $this->userId($request) : 'ip:'.$request->ip(),
));

RateLimiter::for('now-playing', fn (Request $request): Limit => Limit::perMinute(60)->by(
$request->user() instanceof User ? $this->userId($request) : 'ip:'.$request->ip(),
));
RateLimiter::for('lastfm-links', fn (Request $request): Limit => Limit::perMinute(10)->by($this->userId($request)));

// Every issue lookup GitHub hasn't answered in the last five minutes costs a request out of the shared hourly budget,
// which also keeps SSH keys in sync for the CLI, so it gets a budget of its own...
RateLimiter::for('issue-suggestions', fn (Request $request): array => [
Expand Down
53 changes: 53 additions & 0 deletions app/Queries/NowPlayingQuery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace App\Queries;

use App\Contracts\Services\LastFmProvider;
use App\Enums\Feature;
use App\Models\User;
use App\Services\Features;
use Illuminate\Http\Client\HttpClientException;
use Illuminate\Support\Facades\Cache;

final readonly class NowPlayingQuery
{
private const int TTL = 30;

private const int FAILURE_TTL = 300;

public function __construct(private Features $features, private LastFmProvider $lastFm)
{
//
}

/**
* @return array{artist: string, title: string, album: string|null, url: string|null}|null
*/
public function get(User $user): ?array
{
$username = $user->lastfm_username;

if ($username === null || ! $this->features->enabled(Feature::NowPlaying)) {
return null;
}

$key = 'lastfm-now-playing:'.mb_strtolower($username);

/** @var array{artist: string, title: string, album: string|null, url: string|null}|false|null $track */
$track = Cache::get($key);

if ($track === null) {
try {
$track = $this->lastFm->nowPlaying($username)?->toArray() ?? false;
Cache::put($key, $track, self::TTL);
} catch (HttpClientException) {
$track = false;
Cache::put($key, $track, self::FAILURE_TTL);
}
}

return $track === false ? null : $track;
}
}
42 changes: 42 additions & 0 deletions app/Rules/LastFmUsername.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace App\Rules;

use App\Contracts\Services\LastFmProvider;
use App\Enums\Feature;
use App\Services\Features;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Http\Client\HttpClientException;

final readonly class LastFmUsername implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! resolve(Features::class)->enabled(Feature::NowPlaying)) {
$fail("Sharing what you're listening to isn't available yet.");

return;
}

if (! is_string($value) || preg_match('/^[A-Za-z][A-Za-z0-9_-]{1,14}$/', $value) !== 1) {
$fail('Use your Last.fm username: 2 to 15 letters, numbers, dashes or underscores, starting with a letter.');

return;
}

try {
$exists = resolve(LastFmProvider::class)->exists($value);
} catch (HttpClientException) {
$fail("Last.fm isn't answering. Try again in a moment.");

return;
}

if (! $exists) {
$fail('There is no Last.fm account with that username.');
}
}
}
Loading
Loading