diff --git a/app/Commands/CommandResponse.php b/app/Commands/CommandResponse.php index ddb5e62f..7af02cd0 100644 --- a/app/Commands/CommandResponse.php +++ b/app/Commands/CommandResponse.php @@ -30,11 +30,16 @@ class CommandResponse private $_validator; private $_response; private $_didFail; + /** + * @var int Used for HTTP responses. + */ + private $_statusCode; - public static function fail($validatorOrMessages) + public static function fail($validatorOrMessages, int $statusCode = 400) { $response = new CommandResponse(); $response->_didFail = true; + $response->_statusCode = $statusCode; if (is_array($validatorOrMessages)) { $response->_messages = $validatorOrMessages; @@ -47,11 +52,12 @@ class CommandResponse return $response; } - public static function succeed($response = null) + public static function succeed($response = null, int $statusCode = 200) { $cmdResponse = new CommandResponse(); $cmdResponse->_didFail = false; $cmdResponse->_response = $response; + $cmdResponse->_statusCode = $statusCode; return $cmdResponse; } @@ -76,6 +82,14 @@ class CommandResponse return $this->_response; } + /** + * @return int + */ + public function getStatusCode():int + { + return $this->_statusCode; + } + /** * @return Validator */ diff --git a/app/Commands/CreateUserCommand.php b/app/Commands/CreateUserCommand.php new file mode 100644 index 00000000..9870a2cd --- /dev/null +++ b/app/Commands/CreateUserCommand.php @@ -0,0 +1,93 @@ +. + */ + +namespace Poniverse\Ponyfm\Commands; + +use Gate; +use Poniverse\Ponyfm\Models\User; +use Validator; + +class CreateUserCommand extends CommandBase +{ + private $username; + private $displayName; + private $email; + private $createArchivedUser; + + public function __construct( + string $username, + string $displayName, + string $email = null, + bool $createArchivedUser = false + ) { + $this->username = $username; + $this->displayName = $displayName; + $this->email = $email; + $this->createArchivedUser = $createArchivedUser; + } + + /** + * @return bool + */ + public function authorize() + { + return Gate::allows('create-user'); + } + + /** + * @throws \Exception + * @return CommandResponse + */ + public function execute() + { + $rules = [ + 'username' => config('ponyfm.validation_rules.username'), + 'display_name' => config('ponyfm.validation_rules.display_name'), + 'email' => 'email', + 'create_archived_user' => 'boolean', + ]; + + $validator = Validator::make([ + 'username' => $this->username, + 'display_name' => $this->displayName, + ], $rules); + + if ($validator->fails()) { + return CommandResponse::fail([ + 'message' => $validator->getMessageBag()->first(), + 'user' => null + ]); + } + + // Attempt to create the user. + $user = User::findOrCreate($this->username, $this->displayName, $this->email, $this->createArchivedUser); + if ($user->wasRecentlyCreated) { + return CommandResponse::succeed([ + 'message' => 'New user successfully created!', + 'user' => User::mapPublicUserSummary($user) + ], 201); + } else { + return CommandResponse::fail([ + 'message' => 'A user with that username already exists.', + 'user' => User::mapPublicUserSummary($user) + ], 409); + } + } +} diff --git a/app/Commands/SaveAccountSettingsCommand.php b/app/Commands/SaveAccountSettingsCommand.php index 8454a53c..91901977 100644 --- a/app/Commands/SaveAccountSettingsCommand.php +++ b/app/Commands/SaveAccountSettingsCommand.php @@ -89,7 +89,7 @@ class SaveAccountSettingsCommand extends CommandBase 'slug' => [ 'required', 'unique:users,slug,'.$this->_user->id, - 'min:3', + 'min:'.config('ponyfm.user_slug_minimum_length'), 'regex:/^[a-z\d-]+$/', 'is_not_reserved_slug' ] diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php index ba061245..1e52d708 100644 --- a/app/Http/Controllers/AdminController.php +++ b/app/Http/Controllers/AdminController.php @@ -44,4 +44,9 @@ class AdminController extends Controller { return View::make('shared.null'); } + + public function getUsers() + { + return View::make('shared.null'); + } } diff --git a/app/Http/Controllers/Api/Web/ArtistsController.php b/app/Http/Controllers/Api/Web/ArtistsController.php index 1f3015fc..03946b11 100644 --- a/app/Http/Controllers/Api/Web/ArtistsController.php +++ b/app/Http/Controllers/Api/Web/ArtistsController.php @@ -21,6 +21,7 @@ namespace Poniverse\Ponyfm\Http\Controllers\Api\Web; use Gate; +use Poniverse\Ponyfm\Commands\CreateUserCommand; use Poniverse\Ponyfm\Models\Album; use Poniverse\Ponyfm\Models\Comment; use Poniverse\Ponyfm\Models\Favourite; @@ -29,9 +30,9 @@ use Poniverse\Ponyfm\Models\Image; use Poniverse\Ponyfm\Models\Track; use Poniverse\Ponyfm\Models\User; use Poniverse\Ponyfm\Models\Follower; -use Illuminate\Support\Facades\App; -use Illuminate\Support\Facades\Input; -use Illuminate\Support\Facades\Response; +use App; +use Input; +use Response; use ColorThief\ColorThief; use Helpers; @@ -228,4 +229,9 @@ class ArtistsController extends ApiControllerBase return Response::json(["artists" => $users, "current_page" => $page, "total_pages" => ceil($count / $perPage)], 200); } + + public function postIndex() { + $name = Input::json('username'); + return $this->execute(new CreateUserCommand($name, $name, null, true)); + } } diff --git a/app/Http/Controllers/ApiControllerBase.php b/app/Http/Controllers/ApiControllerBase.php index 414f93f2..a80e301c 100644 --- a/app/Http/Controllers/ApiControllerBase.php +++ b/app/Http/Controllers/ApiControllerBase.php @@ -43,10 +43,10 @@ abstract class ApiControllerBase extends Controller return Response::json([ 'message' => 'Validation failed', 'errors' => $result->getMessages() - ], 400); + ], $result->getStatusCode()); } - return Response::json($result->getResponse(), 200); + return Response::json($result->getResponse(), $result->getStatusCode()); } public function notAuthorized() diff --git a/app/Http/routes.php b/app/Http/routes.php index 96b68f56..dbfdfe3e 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -101,6 +101,7 @@ Route::group(['prefix' => 'api/web'], function() { Route::get('/comments/{type}/{id}', 'Api\Web\CommentsController@getIndex')->where('id', '\d+'); Route::get('/artists', 'Api\Web\ArtistsController@getIndex'); + Route::post('/artists', 'Api\Web\ArtistsController@postIndex'); Route::get('/artists/{slug}', 'Api\Web\ArtistsController@getShow'); Route::get('/artists/{slug}/content', 'Api\Web\ArtistsController@getContent'); Route::get('/artists/{slug}/favourites', 'Api\Web\ArtistsController@getFavourites'); @@ -175,6 +176,7 @@ Route::group(['prefix' => 'admin', 'middleware' => ['auth', 'can:access-admin-ar Route::get('/genres', 'AdminController@getGenres'); Route::get('/tracks', 'AdminController@getTracks'); Route::get('/show-songs', 'AdminController@getShowSongs'); + Route::get('/users', 'AdminController@getUsers'); Route::get('/', 'AdminController@getIndex'); }); diff --git a/app/Models/User.php b/app/Models/User.php index bf739743..dc82ccb6 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -20,6 +20,7 @@ namespace Poniverse\Ponyfm\Models; +use DB; use Gravatar; use Illuminate\Auth\Authenticatable; use Illuminate\Auth\Passwords\CanResetPassword; @@ -114,6 +115,12 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon */ private static function getUniqueSlugForName(string $name):string { $baseSlug = Str::slug($name); + + // Ensure that the slug we generate is long enough. + for ($i = Str::length($baseSlug); $i < config('ponyfm.user_slug_minimum_length'); $i++) { + $baseSlug = $baseSlug.'-'; + } + $slugBeingTried = $baseSlug; $counter = 2; @@ -135,15 +142,23 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon } /** - * @param string $username + * @param string $username used to perform the search * @param string $displayName - * @param string $email + * @param string|null $email set to null if creating an archived user + * @param bool $createArchivedUser if true, includes archived users in the search and creates an archived user * @return User */ - public static function findOrCreate(string $username, string $displayName, string $email) { - $user = static::where('username', $username) - ->where('is_archived', false) - ->first(); + public static function findOrCreate( + string $username, + string $displayName, + string $email = null, + bool $createArchivedUser = false + ) { + $user = static::where(DB::raw('LOWER(username)'), Str::lower($username)); + if (false === $createArchivedUser) { + $user = $user->where('is_archived', false); + } + $user = $user->first(); if (null !== $user) { return $user; @@ -156,7 +171,10 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon $user->slug = self::getUniqueSlugForName($displayName); $user->email = $email; $user->uses_gravatar = true; + $user->is_archived = $createArchivedUser; $user->save(); + $user = $user->fresh(); + $user->wasRecentlyCreated = true; return $user; } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 9a6abbe2..b20dadb2 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -68,6 +68,10 @@ class AuthServiceProvider extends ServiceProvider return $user->hasRole('admin'); }); + $gate->define('create-user', function(User $user) { + return $user->hasRole('admin'); + }); + $this->registerPolicies($gate); } } diff --git a/config/ponyfm.php b/config/ponyfm.php index c94ed5c1..6d7ae949 100644 --- a/config/ponyfm.php +++ b/config/ponyfm.php @@ -89,4 +89,31 @@ return [ 'indexing_queue' => 'indexing', + /* + |-------------------------------------------------------------------------- + | Global validation rules + |-------------------------------------------------------------------------- + | + | Data fields that are validated in multiple places have their validation + | rules centralized here. + | + */ + + 'validation_rules' => [ + 'username' => ['required', 'min:3', 'max:26'], + 'display_name' => ['required', 'min:3', 'max:26'], + ], + + /* + |-------------------------------------------------------------------------- + | Minimum length of a user slug + |-------------------------------------------------------------------------- + | + | No profile slugs shorter than this will be generated. This setting is + | intended to pre-emptively avoid collisions with very short URL's that may + | be desirable for future site functionality. + | + */ + + 'user_slug_minimum_length' => 3 ]; diff --git a/package.json b/package.json index 37609566..3a5dc6a4 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "packages": {}, "dependencies": {}, "devDependencies": { - "angular": "^1.5.0", + "angular": "^1.5.6", "angular-chart.js": "^1.0.0-alpha6", "angular-strap": "^2.3.8", "angular-ui-router": "^0.2.18", diff --git a/public/templates/admin/_layout.html b/public/templates/admin/_layout.html index 6e485deb..9a610319 100644 --- a/public/templates/admin/_layout.html +++ b/public/templates/admin/_layout.html @@ -2,6 +2,7 @@
  • All Tracks
  • Genres
  • Show Songs
  • +
  • Users
  • diff --git a/public/templates/admin/users.html b/public/templates/admin/users.html new file mode 100644 index 00000000..200dc1f1 --- /dev/null +++ b/public/templates/admin/users.html @@ -0,0 +1,21 @@ +

    User Management

    + +
    + +
    +

    Create an archived artist profile

    +

    Archived profiles are for organizing music by artists who aren't on Pony.fm themselves. They can always be claimed by the artist and converted to a "proper" profile later.

    + + +
    + + + +
    +

    Merge profiles

    +

    Ask a Pony.fm developer or sysadmin to run the following for you:

    ./artisan accounts:merge

    +
    +
    diff --git a/public/templates/directives/user-creator.html b/public/templates/directives/user-creator.html new file mode 100644 index 00000000..d2cbd8f6 --- /dev/null +++ b/public/templates/directives/user-creator.html @@ -0,0 +1,32 @@ +
    + + + + +
    + {{ $ctrl.creationMessage }} + +
    +
    diff --git a/resources/assets/scripts/app/app.coffee b/resources/assets/scripts/app/app.coffee index 4828465a..851c1c3e 100644 --- a/resources/assets/scripts/app/app.coffee +++ b/resources/assets/scripts/app/app.coffee @@ -283,6 +283,11 @@ ponyfm.config [ controller: 'admin-tracks' templateUrl: '/templates/admin/tracks.html' + state.state 'admin.users', + url: '/users' + controller: 'admin-users' + templateUrl: '/templates/admin/users.html' + # Homepage if window.pfm.auth.isLogged diff --git a/resources/assets/scripts/app/controllers/admin-users.coffee b/resources/assets/scripts/app/controllers/admin-users.coffee new file mode 100644 index 00000000..77808894 --- /dev/null +++ b/resources/assets/scripts/app/controllers/admin-users.coffee @@ -0,0 +1,22 @@ +# Pony.fm - A community for pony fan music. +# Copyright (C) 2016 Peter Deltchev +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +module.exports = angular.module('ponyfm').controller 'admin-users', [ + 'meta' + (meta) -> + meta.setTitle('🔒 User management') + +] diff --git a/resources/assets/scripts/app/directives/user-creator.coffee b/resources/assets/scripts/app/directives/user-creator.coffee new file mode 100644 index 00000000..c0cd3d01 --- /dev/null +++ b/resources/assets/scripts/app/directives/user-creator.coffee @@ -0,0 +1,51 @@ +# Pony.fm - A community for pony fan music. +# Copyright (C) 2016 Peter Deltchev +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +pfmUserCreatorController = [ + 'artists', + (artists)-> + ctrl = this + + ctrl.usernameToCreate = '' + ctrl.isCreating = false + ctrl.creationMessage = null + ctrl.creationSucceeded = false + ctrl.user = null + + ctrl.createUser = -> + ctrl.isCreating = true + ctrl.creationMessage = null + ctrl.creationSucceeded = false + + artists.create(ctrl.usernameToCreate) + .then (response)-> + ctrl.creationSucceeded = true + ctrl.user = response.data.user + ctrl.creationMessage = response.data.message + .catch (response)-> + ctrl.creationSucceeded = false + ctrl.user = response.data.errors.user + ctrl.creationMessage = response.data.errors.message + .finally -> + ctrl.isCreating = false + + return ctrl +] + +module.exports = angular.module('ponyfm').component('pfmUserCreator', { + templateUrl: '/templates/directives/user-creator.html', + controller: pfmUserCreatorController, +}) diff --git a/resources/assets/scripts/app/services/artists.coffee b/resources/assets/scripts/app/services/artists.coffee index 6fbbf822..b3af5da6 100644 --- a/resources/assets/scripts/app/services/artists.coffee +++ b/resources/assets/scripts/app/services/artists.coffee @@ -15,8 +15,8 @@ # along with this program. If not, see . module.exports = angular.module('ponyfm').factory('artists', [ - '$rootScope', '$http' - ($rootScope, $http) -> + '$rootScope', '$http', '$q' + ($rootScope, $http, $q) -> artistPage = [] artists = {} artistContent = {} @@ -72,5 +72,8 @@ module.exports = angular.module('ponyfm').factory('artists', [ artistFavourites[slug] = artistsDef.promise() + create: (username) -> + $http.post('/api/web/artists', {username: username}) + self ]) diff --git a/resources/assets/styles/app.less b/resources/assets/styles/app.less index 87fa9d2c..c741956b 100644 --- a/resources/assets/styles/app.less +++ b/resources/assets/styles/app.less @@ -35,4 +35,5 @@ @import 'components/uploader'; @import 'components/search'; @import 'components/track-editor'; +@import 'components/user-creator'; @import 'mobile'; diff --git a/resources/assets/styles/components/user-creator.less b/resources/assets/styles/components/user-creator.less new file mode 100644 index 00000000..c6686f6f --- /dev/null +++ b/resources/assets/styles/components/user-creator.less @@ -0,0 +1,37 @@ +/** + * Pony.fm - A community for pony fan music. + * Copyright (C) 2016 Peter Deltchev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +@import '../base/bootstrap/bootstrap'; +@import '../mixins'; + +.user-creator { + margin-bottom: 1em; + + .-usernameField { + } + + .-submitButton { + .btn; + .btn-primary; + } + + .-createdUser { + .alert; + margin-top: 1em; + } +}