diff --git a/.editorconfig b/.editorconfig index f1938bbd..1f3fa2d8 100644 --- a/.editorconfig +++ b/.editorconfig @@ -4,5 +4,5 @@ trim_trailing_whitespace = true indent_style = space indent_size = 2 -[{*.php, *.xml, *.xml.dist}] +[{*.php,*.xml,*.xml.dist}] indent_size = 4 diff --git a/app/Helpers.php b/app/Helpers.php index bc5fc4ab..074b46eb 100644 --- a/app/Helpers.php +++ b/app/Helpers.php @@ -2,6 +2,7 @@ use Illuminate\Support\Facades\File as FileFacade; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; /** * Get a URL for static file requests. @@ -47,6 +48,16 @@ function playlist_cover_url(?string $fileName): ?string return $fileName ? static_url(config('koel.playlist_cover_dir') . $fileName) : null; } +function user_avatar_path(?string $fileName): ?string +{ + return $fileName ? public_path(config('koel.user_avatar_dir') . $fileName) : null; +} + +function user_avatar_url(?string $fileName): ?string +{ + return $fileName ? static_url(config('koel.user_avatar_dir') . $fileName) : null; +} + function koel_version(): string { return trim(FileFacade::get(base_path('.version'))); @@ -84,7 +95,7 @@ function attempt_unless($condition, callable $callback, bool $log = true): mixed function gravatar(string $email, int $size = 192): string { - return sprintf("https://www.gravatar.com/avatar/%s?s=$size&d=robohash", md5($email)); + return sprintf("https://www.gravatar.com/avatar/%s?s=$size&d=robohash", md5(Str::lower($email))); } /** diff --git a/app/Http/Controllers/API/ProfileController.php b/app/Http/Controllers/API/ProfileController.php index 1cdde1e5..b39a44f5 100644 --- a/app/Http/Controllers/API/ProfileController.php +++ b/app/Http/Controllers/API/ProfileController.php @@ -7,9 +7,11 @@ use App\Http\Requests\API\ProfileUpdateRequest; use App\Http\Resources\UserResource; use App\Models\User; use App\Services\TokenManager; +use App\Services\UserService; use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Hashing\Hasher; use Illuminate\Http\Response; +use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; class ProfileController extends Controller @@ -17,6 +19,7 @@ class ProfileController extends Controller /** @param User $user */ public function __construct( private Hasher $hash, + private UserService $userService, private TokenManager $tokenManager, private ?Authenticatable $user ) { @@ -36,15 +39,15 @@ class ProfileController extends Controller ValidationException::withMessages(['current_password' => 'Invalid current password']) ); - $data = $request->only('name', 'email'); + $user = $this->userService->updateUser( + user: $this->user, + name: $request->name, + email: $request->email, + password: $request->new_password, + avatar: Str::startsWith($request->avatar, 'data:') ? $request->avatar : null + ); - if ($request->new_password) { - $data['password'] = $this->hash->make($request->new_password); - } - - $this->user->update($data); - - $response = UserResource::make($this->user)->response(); + $response = UserResource::make($user)->response(); if ($request->new_password) { $response->header( diff --git a/app/Http/Controllers/API/UploadAlbumCoverController.php b/app/Http/Controllers/API/UploadAlbumCoverController.php index 72f25257..367a73cd 100644 --- a/app/Http/Controllers/API/UploadAlbumCoverController.php +++ b/app/Http/Controllers/API/UploadAlbumCoverController.php @@ -9,15 +9,10 @@ use App\Services\MediaMetadataService; class UploadAlbumCoverController extends Controller { - public function __invoke(UploadAlbumCoverRequest $request, Album $album, MediaMetadataService $mediaMetadataService) + public function __invoke(UploadAlbumCoverRequest $request, Album $album, MediaMetadataService $metadataService) { $this->authorize('update', $album); - - $mediaMetadataService->writeAlbumCover( - $album, - $request->getFileContentAsBinaryString(), - $request->getFileExtension() - ); + $metadataService->writeAlbumCover($album, $request->getFileContent()); return response()->json(['cover_url' => $album->cover]); } diff --git a/app/Http/Controllers/API/UploadArtistImageController.php b/app/Http/Controllers/API/UploadArtistImageController.php index 4246957b..f47d6565 100644 --- a/app/Http/Controllers/API/UploadArtistImageController.php +++ b/app/Http/Controllers/API/UploadArtistImageController.php @@ -9,18 +9,10 @@ use App\Services\MediaMetadataService; class UploadArtistImageController extends Controller { - public function __invoke( - UploadArtistImageRequest $request, - Artist $artist, - MediaMetadataService $mediaMetadataService - ) { + public function __invoke(UploadArtistImageRequest $request, Artist $artist, MediaMetadataService $metadataService) + { $this->authorize('update', $artist); - - $mediaMetadataService->writeArtistImage( - $artist, - $request->getFileContentAsBinaryString(), - $request->getFileExtension() - ); + $metadataService->writeArtistImage($artist, $request->getFileContent()); return response()->json(['image_url' => $artist->image]); } diff --git a/app/Http/Controllers/API/UploadPlaylistCoverController.php b/app/Http/Controllers/API/UploadPlaylistCoverController.php index 8147ec27..5d53d526 100644 --- a/app/Http/Controllers/API/UploadPlaylistCoverController.php +++ b/app/Http/Controllers/API/UploadPlaylistCoverController.php @@ -15,12 +15,7 @@ class UploadPlaylistCoverController extends Controller MediaMetadataService $mediaMetadataService ) { $this->authorize('collaborate', $playlist); - - $mediaMetadataService->writePlaylistCover( - $playlist, - $request->getFileContentAsBinaryString(), - $request->getFileExtension() - ); + $mediaMetadataService->writePlaylistCover($playlist, $request->getFileContent()); return response()->json(['cover_url' => $playlist->cover]); } diff --git a/app/Http/Controllers/API/UserController.php b/app/Http/Controllers/API/UserController.php index c42b7fb0..43b63639 100644 --- a/app/Http/Controllers/API/UserController.php +++ b/app/Http/Controllers/API/UserController.php @@ -43,11 +43,11 @@ class UserController extends Controller try { return UserResource::make($this->userService->updateUser( - $user, - $request->name, - $request->email, - $request->password, - $request->get('is_admin') ?: false + user: $user, + name: $request->name, + email: $request->email, + password: $request->password, + isAdmin: $request->get('is_admin') ?: false )); } catch (UserProspectUpdateDeniedException) { abort(Response::HTTP_FORBIDDEN, 'Cannot update a user prospect.'); diff --git a/app/Http/Requests/API/MediaImageUpdateRequest.php b/app/Http/Requests/API/MediaImageUpdateRequest.php index 2d587f91..a01f0269 100644 --- a/app/Http/Requests/API/MediaImageUpdateRequest.php +++ b/app/Http/Requests/API/MediaImageUpdateRequest.php @@ -3,7 +3,6 @@ namespace App\Http\Requests\API; use App\Rules\ImageData; -use Illuminate\Support\Str; abstract class MediaImageUpdateRequest extends Request { @@ -15,14 +14,9 @@ abstract class MediaImageUpdateRequest extends Request ]; } - public function getFileContentAsBinaryString(): string + public function getFileContent(): string { - return base64_decode(Str::after($this->{$this->getImageFieldName()}, ','), true); - } - - public function getFileExtension(): string - { - return Str::after(Str::before($this->{$this->getImageFieldName()}, ';'), '/'); + return $this->{$this->getImageFieldName()}; } abstract protected function getImageFieldName(): string; diff --git a/app/Http/Requests/API/ProfileUpdateRequest.php b/app/Http/Requests/API/ProfileUpdateRequest.php index 0b78a533..95e78024 100644 --- a/app/Http/Requests/API/ProfileUpdateRequest.php +++ b/app/Http/Requests/API/ProfileUpdateRequest.php @@ -7,6 +7,9 @@ use Illuminate\Validation\Rules\Password; /** * @property-read string|null $current_password * @property-read string|null $new_password + * @property-read string $name + * @property-read string $email + * @property-read string|null $avatar */ class ProfileUpdateRequest extends Request { diff --git a/app/Models/User.php b/app/Models/User.php index 5c9ca1f1..e39e9d9d 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -76,7 +76,13 @@ class User extends Authenticatable protected function avatar(): Attribute { - return Attribute::get(fn (): string => gravatar($this->email)); + return Attribute::get(function (): string { + if ($this->attributes['avatar']) { + return user_avatar_url($this->attributes['avatar']); + } + + return gravatar($this->email); + }); } protected function isProspect(): Attribute diff --git a/app/Services/FileScanner.php b/app/Services/FileScanner.php index a7c8ed3b..a5686e9c 100644 --- a/app/Services/FileScanner.php +++ b/app/Services/FileScanner.php @@ -115,10 +115,7 @@ class FileScanner attempt(function () use ($album, $coverData): void { // If the album has no cover, we try to get the cover image from existing tag data if ($coverData) { - $extension = explode('/', $coverData['image_mime']); - $extension = $extension[1] ?? 'png'; - - $this->mediaMetadataService->writeAlbumCover($album, $coverData['data'], $extension); + $this->mediaMetadataService->writeAlbumCover($album, $coverData['data']); return; } @@ -127,8 +124,7 @@ class FileScanner $cover = $this->getCoverFileUnderSameDirectory(); if ($cover) { - $extension = pathinfo($cover, PATHINFO_EXTENSION); - $this->mediaMetadataService->writeAlbumCover($album, $cover, $extension); + $this->mediaMetadataService->writeAlbumCover($album, $cover); } }, false); } diff --git a/app/Services/MediaMetadataService.php b/app/Services/MediaMetadataService.php index c0d49106..2893ea38 100644 --- a/app/Services/MediaMetadataService.php +++ b/app/Services/MediaMetadataService.php @@ -27,16 +27,10 @@ class MediaMetadataService * @param string $source Path, URL, or even binary data. See https://image.intervention.io/v2/api/make. * @param string|null $destination The destination path. Automatically generated if empty. */ - public function writeAlbumCover( - Album $album, - string $source, - string $extension = 'png', - ?string $destination = '', - bool $cleanUp = true - ): void { - attempt(function () use ($album, $source, $extension, $destination, $cleanUp): void { - $extension = trim(strtolower($extension), '. '); - $destination = $destination ?: $this->generateAlbumCoverPath($extension); + public function writeAlbumCover(Album $album, string $source, ?string $destination = '', bool $cleanUp = true): void + { + attempt(function () use ($album, $source, $destination, $cleanUp): void { + $destination = $destination ?: $this->generateAlbumCoverPath(); $this->imageWriter->write($destination, $source); if ($cleanUp) { @@ -64,13 +58,11 @@ class MediaMetadataService public function writeArtistImage( Artist $artist, string $source, - string $extension = 'png', ?string $destination = '', bool $cleanUp = true ): void { - attempt(function () use ($artist, $source, $extension, $destination, $cleanUp): void { - $extension = trim(strtolower($extension), '. '); - $destination = $destination ?: $this->generateArtistImagePath($extension); + attempt(function () use ($artist, $source, $destination, $cleanUp): void { + $destination = $destination ?: $this->generateArtistImagePath(); $this->imageWriter->write($destination, $source); if ($cleanUp && $artist->has_image) { @@ -81,11 +73,10 @@ class MediaMetadataService }); } - public function writePlaylistCover(Playlist $playlist, string $source, string $extension = 'png'): void + public function writePlaylistCover(Playlist $playlist, string $source): void { - attempt(function () use ($playlist, $source, $extension): void { - $extension = trim(strtolower($extension), '. '); - $destination = $this->generatePlaylistCoverPath($extension); + attempt(function () use ($playlist, $source): void { + $destination = $this->generatePlaylistCoverPath(); $this->imageWriter->write($destination, $source); if ($playlist->cover_path) { @@ -96,19 +87,19 @@ class MediaMetadataService }); } - private function generateAlbumCoverPath(string $extension): string + private function generateAlbumCoverPath(): string { - return album_cover_path(sprintf('%s.%s', sha1(Str::uuid()), trim($extension, '.'))); + return album_cover_path(sprintf('%s.webp', sha1(Str::uuid()))); } - private function generateArtistImagePath(string $extension): string + private function generateArtistImagePath(): string { - return artist_image_path(sprintf('%s.%s', sha1(Str::uuid()), trim($extension, '.'))); + return artist_image_path(sprintf('%s.webp', sha1(Str::uuid()))); } - private function generatePlaylistCoverPath(string $extension): string + private function generatePlaylistCoverPath(): string { - return playlist_cover_path(sprintf('%s.%s', sha1(Str::uuid()), trim($extension, '.'))); + return playlist_cover_path(sprintf('%s.webp', sha1(Str::uuid()))); } /** diff --git a/app/Services/SongStorages/S3LambdaStorage.php b/app/Services/SongStorages/S3LambdaStorage.php index 7d6ffdf8..a6714daf 100644 --- a/app/Services/SongStorages/S3LambdaStorage.php +++ b/app/Services/SongStorages/S3LambdaStorage.php @@ -55,11 +55,7 @@ final class S3LambdaStorage extends S3CompatibleStorage $album = Album::getOrCreate($albumArtist, $albumName); if ($cover) { - $this->mediaMetadataService->writeAlbumCover( - $album, - base64_decode($cover['data'], true), - $cover['extension'] - ); + $this->mediaMetadataService->writeAlbumCover($album, base64_decode($cover['data'], true)); } /** @var Song $song */ diff --git a/app/Services/UserService.php b/app/Services/UserService.php index dfee7303..483a3a51 100644 --- a/app/Services/UserService.php +++ b/app/Services/UserService.php @@ -5,10 +5,12 @@ namespace App\Services; use App\Exceptions\UserProspectUpdateDeniedException; use App\Models\User; use Illuminate\Contracts\Hashing\Hasher; +use Illuminate\Support\Facades\File; +use Illuminate\Support\Str; class UserService { - public function __construct(private Hasher $hash) + public function __construct(private Hasher $hash, private ImageWriter $imageWriter) { } @@ -22,20 +24,43 @@ class UserService ]); } - public function updateUser(User $user, string $name, string $email, string|null $password, bool $isAdmin): User - { + public function updateUser( + User $user, + string $name, + string $email, + ?string $password, + ?bool $isAdmin = null, + ?string $avatar = null + ): User { throw_if($user->is_prospect, new UserProspectUpdateDeniedException()); $data = [ 'name' => $name, 'email' => $email, - 'is_admin' => $isAdmin, ]; + if ($isAdmin !== null) { + $data['is_admin'] = $isAdmin; + } + if ($password) { $data['password'] = $this->hash->make($password); } + if ($avatar) { + $oldAvatar = $user->getRawOriginal('avatar'); + + $path = self::generateUserAvatarPath(); + $this->imageWriter->write($path, $avatar, ['max_width' => 480]); + $data['avatar'] = basename($path); + + if ($oldAvatar) { + File::delete($oldAvatar); + } + } else { + $data['avatar'] = null; + } + $user->update($data); return $user; @@ -52,4 +77,9 @@ class UserService $user->save(); } + + private static function generateUserAvatarPath(): string + { + return user_avatar_path(sprintf('%s.webp', sha1(Str::uuid()))); + } } diff --git a/config/koel.php b/config/koel.php index f4b6f547..d450a3ac 100644 --- a/config/koel.php +++ b/config/koel.php @@ -14,6 +14,9 @@ return [ // The *relative* path to the directory to store playlist covers, *with* a trailing slash. 'playlist_cover_dir' => 'img/playlists/', + // The *relative* path to the directory to store user avatars, *with* a trailing slash. + 'user_avatar_dir' => 'img/avatars/', + /* |-------------------------------------------------------------------------- | Sync Options diff --git a/database/migrations/2024_03_19_204549_add_user_avatar.php b/database/migrations/2024_03_19_204549_add_user_avatar.php new file mode 100644 index 00000000..5e42e421 --- /dev/null +++ b/database/migrations/2024_03_19_204549_add_user_avatar.php @@ -0,0 +1,15 @@ +string('avatar')->nullable(); + }); + } +}; diff --git a/docs/assets/icons/times.svg b/docs/assets/icons/times.svg new file mode 100644 index 00000000..65e3fa9a --- /dev/null +++ b/docs/assets/icons/times.svg @@ -0,0 +1 @@ + diff --git a/docs/assets/icons/upload.svg b/docs/assets/icons/upload.svg new file mode 100644 index 00000000..8de62b51 --- /dev/null +++ b/docs/assets/icons/upload.svg @@ -0,0 +1 @@ + diff --git a/docs/usage/profile-preferences.md b/docs/usage/profile-preferences.md index 6bcf8569..a6d48af0 100644 --- a/docs/usage/profile-preferences.md +++ b/docs/usage/profile-preferences.md @@ -10,11 +10,19 @@ After that, you can update your name and email, and set a new password. Leaving the New Password field blank will keep your current password intact. :::tip Pick a strong password -Koel enforces a strong password policy. +Koel enforces a strong password policy. Make sure to pick a password that is at least 10 characters long and contains a mix of letters, numbers, and special characters. Your password will also be checked against a list of leaked passwords for extra security. ::: +## Custom Avatar + +By default, Koel uses [Gravatar](https://gravatar.com) to fetch your avatar based on your email address. +By hovering over the avatar and clicking the icon, you can select an image file from your computer, crop it, and set it as your custom avatar. +Remember to click Save for the change to take effect. + +To remove your custom avatar and revert to using Gravatar, click the icon. + ## Themes At the time of this writing, Koel comes with 17 themes built-in. You can activate a theme simply by clicking on it. The new theme will be applied immediately. @@ -38,3 +46,8 @@ These preferences are saved immediately upon change and synced across all of you ## Service Integration Statuses If your Koel installation is [integrated](../service-integrations) with any external services, such as Last.fm or Spotify, you can see their statuses here along with the ability to connect or disconnect them when applicable. + + diff --git a/package.json b/package.json index 2cda1ac7..3f5bc081 100644 --- a/package.json +++ b/package.json @@ -37,15 +37,11 @@ "youtube-player": "^3.0.4" }, "devDependencies": { - "@babel/core": "^7.17.9", - "@babel/polyfill": "^7.8.7", - "@babel/preset-env": "^7.9.6", "@faker-js/faker": "^6.2.0", "@floating-ui/dom": "^1.0.3", "@testing-library/user-event": "^14.4.3", "@testing-library/vue": "^6.6.1", "@types/axios": "^0.14.0", - "@types/blueimp-md5": "^2.7.0", "@types/local-storage": "^1.4.0", "@types/lodash": "^4.14.150", "@types/nprogress": "^0.2.0", @@ -55,10 +51,8 @@ "@typescript-eslint/eslint-plugin": "^5.22.0", "@typescript-eslint/parser": "^4.11.1", "@vitejs/plugin-vue": "^3.1.2", - "@vue/test-utils": "^2.1.0", "@vueuse/core": "^10.9.0", "cross-env": "^7.0.3", - "css-loader": "^0.28.7", "cypress": "^9.5.4", "eslint": "^8.14.0", "eslint-plugin-import": "^2.20.2", @@ -66,37 +60,32 @@ "eslint-plugin-promise": "^4.2.1", "eslint-plugin-standard": "^4.0.1", "eslint-plugin-vue": "^8.7.1", + "events": "^3.3.0", "factoria": "^4.0.0", - "file-loader": "^1.1.6", "husky": "^4.2.5", "jest-serializer-vue": "^2.0.2", + "js-md5": "^0.8.3", "jsdom": "^19.0.0", "kill-port": "^1.6.1", "laravel-vite-plugin": "^0.6.1", "lint-staged": "^10.3.0", - "postcss": "^8.4.12", - "resolve-url-loader": "^3.1.1", - "sass": "^1.50.0", - "sass-loader": "^12.6.0", + "sass": "^1.72.0", "start-server-and-test": "^2.0.3", - "ts-loader": "^9.3.0", "typescript": "^4.8.4", - "vite": "^3.1.6", + "vite": "^5.1.6", "vitepress": "^1.0.0-rc.45", "vitest": "^0.24.0", - "vue-loader": "^16.2.0", - "webpack": "^5.72.0", - "webpack-node-externals": "^3.0.0" + "vue-advanced-cropper": "^2.8.8" }, "scripts": { "lint": "eslint ./resources/assets/js/**/*.ts --no-error-on-unmatched-pattern && eslint ./cypress/**/*.ts --no-error-on-unmatched-pattern", + "test": "vitest", "test:unit": "vitest", "test:e2e": "kill-port 8080 && start-test dev http-get://localhost:8080/api/ping 'cypress open'", "test:e2e:ci": "kill-port 8080 && start-test 'php artisan serve --port=8080 --quiet' http-get://localhost:8080/api/ping 'cypress run --browser chromium'", "build": "vite build", "build-demo": "cross-env VITE_KOEL_ENV=demo vite build", "dev": "kill-port 8000 && start-test 'php artisan serve --port=8000 --quiet' 'http://127.0.0.1:8000/api/ping' 'vite'", - "prod": "npm run production", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", "docs:preview": "vitepress preview docs" @@ -117,5 +106,6 @@ "eslint" ] }, + "type": "module", "packageManager": "yarn@1.22.19" } diff --git a/public/.gitignore b/public/.gitignore index f031b0f0..63db03f0 100644 --- a/public/.gitignore +++ b/public/.gitignore @@ -13,6 +13,8 @@ img/artists/* !img/artists/.gitkeep img/playlists/* !img/playlists/.gitkeep +img/avatars/* +!img/avatars/.gitkeep images js diff --git a/resources/assets/js/components/auth/ForgotPasswordForm.vue b/resources/assets/js/components/auth/ForgotPasswordForm.vue index 29b8f7df..27a1eee5 100644 --- a/resources/assets/js/components/auth/ForgotPasswordForm.vue +++ b/resources/assets/js/components/auth/ForgotPasswordForm.vue @@ -1,9 +1,9 @@