diff --git a/js/src/admin/components/BasicsPage.js b/js/src/admin/components/BasicsPage.js index edf2c5907..c2b6175af 100644 --- a/js/src/admin/components/BasicsPage.js +++ b/js/src/admin/components/BasicsPage.js @@ -25,10 +25,6 @@ export default class BasicsPage extends Page { 'welcome_message', 'display_name_driver', ]; - this.values = {}; - - const settings = app.data.settings; - this.fields.forEach((key) => (this.values[key] = Stream(settings[key]))); this.localeOptions = {}; const locales = app.data.locales; @@ -42,8 +38,29 @@ export default class BasicsPage extends Page { this.displayNameOptions[identifier] = identifier; }, this); + this.slugDriverOptions = {}; + Object.keys(app.data.slugDrivers).forEach((model) => { + this.fields.push(`slug_driver_${model}`); + this.slugDriverOptions[model] = {}; + + app.data.slugDrivers[model].forEach((option) => { + this.slugDriverOptions[model][option] = option; + }); + }); + + this.values = {}; + + const settings = app.data.settings; + this.fields.forEach((key) => (this.values[key] = Stream(settings[key]))); + if (!this.values.display_name_driver() && displayNameDrivers.includes('username')) this.values.display_name_driver('username'); + Object.keys(app.data.slugDrivers).forEach((model) => { + if (!this.values[`slug_driver_${model}`]() && 'default' in this.slugDriverOptions[model]) { + this.values[`slug_driver_${model}`]('default'); + } + }); + if (typeof this.values.show_language_selector() !== 'number') this.values.show_language_selector(1); } @@ -132,20 +149,30 @@ export default class BasicsPage extends Page { ] )} - {Object.keys(this.displayNameOptions).length > 1 - ? FieldSet.component( - { - label: app.translator.trans('core.admin.basics.display_name_heading'), - }, - [ -
{app.translator.trans('core.admin.basics.display_name_text')}
, - Select.component({ - options: this.displayNameOptions, - bidi: this.values.display_name_driver, - }), - ] - ) - : ''} + {Object.keys(this.displayNameOptions).length > 1 ? ( +
+
{app.translator.trans('core.admin.basics.display_name_text')}
+ +
+ ) : ( + '' + )} + + {Object.keys(this.slugDriverOptions).map((model) => { + const options = this.slugDriverOptions[model]; + if (Object.keys(options).length > 1) { + return ( +
+
{app.translator.trans('core.admin.basics.slug_driver_text', { model })}
+ +
+ ); + } + })} {Button.component( { diff --git a/js/src/common/models/User.js b/js/src/common/models/User.js index 54d8f5071..30bc0567b 100644 --- a/js/src/common/models/User.js +++ b/js/src/common/models/User.js @@ -10,6 +10,7 @@ export default class User extends Model {} Object.assign(User.prototype, { username: Model.attribute('username'), + slug: Model.attribute('slug'), displayName: Model.attribute('displayName'), email: Model.attribute('email'), isEmailConfirmed: Model.attribute('isEmailConfirmed'), diff --git a/js/src/forum/components/DiscussionListItem.js b/js/src/forum/components/DiscussionListItem.js index ecd6a6c85..5fddbf950 100644 --- a/js/src/forum/components/DiscussionListItem.js +++ b/js/src/forum/components/DiscussionListItem.js @@ -14,6 +14,7 @@ import DiscussionControls from '../utils/DiscussionControls'; import slidable from '../utils/slidable'; import extractText from '../../common/utils/extractText'; import classList from '../../common/utils/classList'; +import DiscussionPage from './DiscussionPage'; import { escapeRegExp } from 'lodash-es'; /** @@ -156,9 +157,7 @@ export default class DiscussionListItem extends Component { * @return {Boolean} */ active() { - const idParam = m.route.param('id'); - - return idParam && idParam.split('-')[0] === this.attrs.discussion.id(); + return app.current.matches(DiscussionPage, { discussion: this.attrs.discussion }); } /** diff --git a/js/src/forum/components/DiscussionPage.js b/js/src/forum/components/DiscussionPage.js index beb0b2a5d..80dd5e8e0 100644 --- a/js/src/forum/components/DiscussionPage.js +++ b/js/src/forum/components/DiscussionPage.js @@ -109,7 +109,7 @@ export default class DiscussionPage extends Page { } else { const params = this.requestParams(); - app.store.find('discussions', m.route.param('id').split('-')[0], params).then(this.show.bind(this)); + app.store.find('discussions', m.route.param('id'), params).then(this.show.bind(this)); } m.redraw(); @@ -123,6 +123,7 @@ export default class DiscussionPage extends Page { */ requestParams() { return { + bySlug: true, page: { near: this.near }, }; } diff --git a/js/src/forum/components/Search.js b/js/src/forum/components/Search.js index 451afbf81..9698c58df 100644 --- a/js/src/forum/components/Search.js +++ b/js/src/forum/components/Search.js @@ -21,6 +21,8 @@ import UsersSearchSource from './UsersSearchSource'; * - state: SearchState instance. */ export default class Search extends Component { + static MIN_SEARCH_LEN = 3; + oninit(vnode) { super.oninit(vnode); this.state = this.attrs.state; @@ -152,7 +154,7 @@ export default class Search extends Component { search.searchTimeout = setTimeout(() => { if (state.isCached(query)) return; - if (query.length >= 3) { + if (query.length >= Search.MIN_SEARCH_LEN) { search.sources.map((source) => { if (!source.search) return; diff --git a/js/src/forum/components/UserPage.js b/js/src/forum/components/UserPage.js index 66bc22db6..37e950164 100644 --- a/js/src/forum/components/UserPage.js +++ b/js/src/forum/components/UserPage.js @@ -102,7 +102,7 @@ export default class UserPage extends Page { }); if (!this.user) { - app.store.find('users', username).then(this.show.bind(this)); + app.store.find('users', username, { bySlug: true }).then(this.show.bind(this)); } } diff --git a/js/src/forum/resolvers/DiscussionPageResolver.ts b/js/src/forum/resolvers/DiscussionPageResolver.ts index 80641c7c0..d208aa169 100644 --- a/js/src/forum/resolvers/DiscussionPageResolver.ts +++ b/js/src/forum/resolvers/DiscussionPageResolver.ts @@ -1,15 +1,6 @@ import DefaultResolver from '../../common/resolvers/DefaultResolver'; import DiscussionPage from '../components/DiscussionPage'; -/** - * This isn't exported as it is a temporary measure. - * A more robust system will be implemented alongside UTF-8 support in beta 15. - */ -function getDiscussionIdFromSlug(slug: string | undefined) { - if (!slug) return; - return slug.split('-')[0]; -} - /** * A custom route resolver for DiscussionPage that generates the same key to all posts * on the same discussion. It triggers a scroll when going from one post to another @@ -18,17 +9,32 @@ function getDiscussionIdFromSlug(slug: string | undefined) { export default class DiscussionPageResolver extends DefaultResolver { static scrollToPostNumber: string | null = null; + /** + * Remove optional parts of a discussion's slug to keep the substring + * that bijectively maps to a discussion object. By default this just + * extracts the numerical ID from the slug. If a custom discussion + * slugging driver is used, this may need to be overriden. + * @param slug + */ + canonicalizeDiscussionSlug(slug: string | undefined) { + if (!slug) return; + return slug.split('-')[0]; + } + + /** + * @inheritdoc + */ makeKey() { const params = { ...m.route.param() }; if ('near' in params) { delete params.near; } - params.id = getDiscussionIdFromSlug(params.id); + params.id = this.canonicalizeDiscussionSlug(params.id); return this.routeName.replace('.near', '') + JSON.stringify(params); } onmatch(args, requestedPath, route) { - if (app.current.matches(DiscussionPage) && getDiscussionIdFromSlug(args.id) === getDiscussionIdFromSlug(m.route.param('id'))) { + if (app.current.matches(DiscussionPage) && this.canonicalizeDiscussionSlug(args.id) === this.canonicalizeDiscussionSlug(m.route.param('id'))) { // By default, the first post number of any discussion is 1 DiscussionPageResolver.scrollToPostNumber = args.near || '1'; } diff --git a/js/src/forum/routes.js b/js/src/forum/routes.js index 9615b4ee8..6f10c27c5 100644 --- a/js/src/forum/routes.js +++ b/js/src/forum/routes.js @@ -34,9 +34,8 @@ export default function (app) { * @return {String} */ app.route.discussion = (discussion, near) => { - const slug = discussion.slug(); return app.route(near && near !== 1 ? 'discussion.near' : 'discussion', { - id: discussion.id() + (slug.trim() ? '-' + slug : ''), + id: discussion.slug(), near: near && near !== 1 ? near : undefined, }); }; @@ -59,7 +58,7 @@ export default function (app) { */ app.route.user = (user) => { return app.route('user', { - username: user.username(), + username: user.slug(), }); }; } diff --git a/src/Admin/Content/AdminPayload.php b/src/Admin/Content/AdminPayload.php index e766c072a..21347271e 100644 --- a/src/Admin/Content/AdminPayload.php +++ b/src/Admin/Content/AdminPayload.php @@ -75,6 +75,9 @@ class AdminPayload $document->payload['extensions'] = $this->extensions->getExtensions()->toArray(); $document->payload['displayNameDrivers'] = array_keys($this->container->make('flarum.user.display_name.supported_drivers')); + $document->payload['slugDrivers'] = array_map(function ($resourceDrivers) { + return array_keys($resourceDrivers); + }, $this->container->make('flarum.http.slugDrivers')); $document->payload['phpVersion'] = PHP_VERSION; $document->payload['mysqlVersion'] = $this->db->selectOne('select version() as version')->version; diff --git a/src/Api/Controller/ShowDiscussionController.php b/src/Api/Controller/ShowDiscussionController.php index b9fb36f7b..76710d7ed 100644 --- a/src/Api/Controller/ShowDiscussionController.php +++ b/src/Api/Controller/ShowDiscussionController.php @@ -12,6 +12,7 @@ namespace Flarum\Api\Controller; use Flarum\Api\Serializer\DiscussionSerializer; use Flarum\Discussion\Discussion; use Flarum\Discussion\DiscussionRepository; +use Flarum\Http\SlugManager; use Flarum\Post\PostRepository; use Flarum\User\User; use Illuminate\Support\Arr; @@ -31,6 +32,11 @@ class ShowDiscussionController extends AbstractShowController */ protected $posts; + /** + * @var SlugManager + */ + protected $slugManager; + /** * {@inheritdoc} */ @@ -61,11 +67,13 @@ class ShowDiscussionController extends AbstractShowController /** * @param \Flarum\Discussion\DiscussionRepository $discussions * @param \Flarum\Post\PostRepository $posts + * @param \Flarum\Http\SlugManager $slugManager */ - public function __construct(DiscussionRepository $discussions, PostRepository $posts) + public function __construct(DiscussionRepository $discussions, PostRepository $posts, SlugManager $slugManager) { $this->discussions = $discussions; $this->posts = $posts; + $this->slugManager = $slugManager; } /** @@ -77,7 +85,11 @@ class ShowDiscussionController extends AbstractShowController $actor = $request->getAttribute('actor'); $include = $this->extractInclude($request); - $discussion = $this->discussions->findOrFail($discussionId, $actor); + if (Arr::get($request->getQueryParams(), 'bySlug', false)) { + $discussion = $this->slugManager->forResource(Discussion::class)->fromSlug($discussionId, $actor); + } else { + $discussion = $this->discussions->findOrFail($discussionId, $actor); + } if (in_array('posts', $include)) { $postRelationships = $this->getPostRelationships($include); diff --git a/src/Api/Controller/ShowUserController.php b/src/Api/Controller/ShowUserController.php index 8f3826e55..60412362a 100644 --- a/src/Api/Controller/ShowUserController.php +++ b/src/Api/Controller/ShowUserController.php @@ -11,6 +11,8 @@ namespace Flarum\Api\Controller; use Flarum\Api\Serializer\CurrentUserSerializer; use Flarum\Api\Serializer\UserSerializer; +use Flarum\Http\SlugManager; +use Flarum\User\User; use Flarum\User\UserRepository; use Illuminate\Support\Arr; use Psr\Http\Message\ServerRequestInterface; @@ -29,15 +31,22 @@ class ShowUserController extends AbstractShowController public $include = ['groups']; /** - * @var \Flarum\User\UserRepository + * @var SlugManager + */ + protected $slugManager; + + /** + * @var UserRepository */ protected $users; /** - * @param \Flarum\User\UserRepository $users + * @param SlugManager $slugManager + * @param UserRepository $users */ - public function __construct(UserRepository $users) + public function __construct(SlugManager $slugManager, UserRepository $users) { + $this->slugManager = $slugManager; $this->users = $users; } @@ -47,17 +56,18 @@ class ShowUserController extends AbstractShowController protected function data(ServerRequestInterface $request, Document $document) { $id = Arr::get($request->getQueryParams(), 'id'); - - if (! is_numeric($id)) { - $id = $this->users->getIdForUsername($id); - } - $actor = $request->getAttribute('actor'); - if ($actor->id == $id) { + if (Arr::get($request->getQueryParams(), 'bySlug', false)) { + $user = $this->slugManager->forResource(User::class)->fromSlug($id, $actor); + } else { + $user = $this->users->findOrFail($id, $actor); + } + + if ($actor->id === $user->id) { $this->serializer = CurrentUserSerializer::class; } - return $this->users->findOrFail($id, $actor); + return $user; } } diff --git a/src/Api/Serializer/BasicDiscussionSerializer.php b/src/Api/Serializer/BasicDiscussionSerializer.php index cdbf491c5..64bbca752 100644 --- a/src/Api/Serializer/BasicDiscussionSerializer.php +++ b/src/Api/Serializer/BasicDiscussionSerializer.php @@ -10,6 +10,7 @@ namespace Flarum\Api\Serializer; use Flarum\Discussion\Discussion; +use Flarum\Http\SlugManager; use InvalidArgumentException; class BasicDiscussionSerializer extends AbstractSerializer @@ -19,6 +20,16 @@ class BasicDiscussionSerializer extends AbstractSerializer */ protected $type = 'discussions'; + /** + * @var SlugManager + */ + protected $slugManager; + + public function __construct(SlugManager $slugManager) + { + $this->slugManager = $slugManager; + } + /** * {@inheritdoc} * @@ -35,7 +46,7 @@ class BasicDiscussionSerializer extends AbstractSerializer return [ 'title' => $discussion->title, - 'slug' => $discussion->slug, + 'slug' => $this->slugManager->forResource(Discussion::class)->toSlug($discussion), ]; } diff --git a/src/Api/Serializer/BasicUserSerializer.php b/src/Api/Serializer/BasicUserSerializer.php index 6fb6c24d0..e024d338d 100644 --- a/src/Api/Serializer/BasicUserSerializer.php +++ b/src/Api/Serializer/BasicUserSerializer.php @@ -9,6 +9,7 @@ namespace Flarum\Api\Serializer; +use Flarum\Http\SlugManager; use Flarum\User\User; use InvalidArgumentException; @@ -19,6 +20,16 @@ class BasicUserSerializer extends AbstractSerializer */ protected $type = 'users'; + /** + * @var SlugManager + */ + protected $slugManager; + + public function __construct(SlugManager $slugManager) + { + $this->slugManager = $slugManager; + } + /** * {@inheritdoc} * @@ -36,7 +47,8 @@ class BasicUserSerializer extends AbstractSerializer return [ 'username' => $user->username, 'displayName' => $user->display_name, - 'avatarUrl' => $user->avatar_url + 'avatarUrl' => $user->avatar_url, + 'slug' => $this->slugManager->forResource(User::class)->toSlug($user) ]; } diff --git a/src/Discussion/IdWithTransliteratedSlugDriver.php b/src/Discussion/IdWithTransliteratedSlugDriver.php new file mode 100644 index 000000000..3a1cb58ef --- /dev/null +++ b/src/Discussion/IdWithTransliteratedSlugDriver.php @@ -0,0 +1,42 @@ +discussions = $discussions; + } + + public function toSlug(AbstractModel $instance): string + { + return $instance->id.(trim($instance->slug) ? '-'.$instance->slug : ''); + } + + public function fromSlug(string $slug, User $actor): AbstractModel + { + if (strpos($slug, '-')) { + $slug_array = explode('-', $slug); + $slug = $slug_array[0]; + } + + return $this->discussions->findOrFail($slug, $actor); + } +} diff --git a/src/Extend/ModelUrl.php b/src/Extend/ModelUrl.php new file mode 100644 index 000000000..7c410dfd6 --- /dev/null +++ b/src/Extend/ModelUrl.php @@ -0,0 +1,54 @@ +modelClass = $modelClass; + } + + /** + * Add a slug driver. + * + * @param string $identifier Identifier for slug driver. + * @param string $driver ::class attribute of driver class, which must implement Flarum\Http\SlugDriverInterface + * @return self + */ + public function addSlugDriver(string $identifier, string $driver) + { + $this->slugDrivers[$identifier] = $driver; + + return $this; + } + + public function extend(Container $container, Extension $extension = null) + { + if ($this->slugDrivers) { + $container->extend('flarum.http.slugDrivers', function ($existingDrivers) { + $existingDrivers[$this->modelClass] = array_merge(Arr::get($existingDrivers, $this->modelClass, []), $this->slugDrivers); + + return $existingDrivers; + }); + } + } +} diff --git a/src/Forum/Content/Discussion.php b/src/Forum/Content/Discussion.php index 748027383..0fc9b72e4 100644 --- a/src/Forum/Content/Discussion.php +++ b/src/Forum/Content/Discussion.php @@ -74,9 +74,7 @@ class Discussion unset($newQueryParams['id']); $queryString = http_build_query($newQueryParams); - $idWithSlug = $apiDocument->data->id.(trim($apiDocument->data->attributes->slug) ? '-'.$apiDocument->data->attributes->slug : ''); - - return $this->url->to('forum')->route('discussion', ['id' => $idWithSlug]). + return $this->url->to('forum')->route('discussion', ['id' => $apiDocument->data->attributes->slug]). ($queryString ? '?'.$queryString : ''); }; @@ -106,6 +104,7 @@ class Discussion */ protected function getApiDocument(User $actor, array $params) { + $params['bySlug'] = true; $response = $this->api->send('Flarum\Api\Controller\ShowDiscussionController', $actor, $params); $statusCode = $response->getStatusCode(); diff --git a/src/Forum/Content/User.php b/src/Forum/Content/User.php index 26a6764a6..f1995f03d 100644 --- a/src/Forum/Content/User.php +++ b/src/Forum/Content/User.php @@ -54,7 +54,7 @@ class User $user = $apiDocument->data->attributes; $document->title = $user->displayName; - $document->canonicalUrl = $this->url->to('forum')->route('user', ['username' => $user->username]); + $document->canonicalUrl = $this->url->to('forum')->route('user', ['username' => $user->slug]); $document->payload['apiDocument'] = $apiDocument; return $document; @@ -70,6 +70,7 @@ class User */ protected function getApiDocument(FlarumUser $actor, array $params) { + $params['bySlug'] = true; $response = $this->api->send(ShowUserController::class, $actor, $params); $statusCode = $response->getStatusCode(); diff --git a/src/Http/HttpServiceProvider.php b/src/Http/HttpServiceProvider.php index 4a7d71b24..0fd218200 100644 --- a/src/Http/HttpServiceProvider.php +++ b/src/Http/HttpServiceProvider.php @@ -9,7 +9,13 @@ namespace Flarum\Http; +use Flarum\Discussion\Discussion; +use Flarum\Discussion\IdWithTransliteratedSlugDriver; use Flarum\Foundation\AbstractServiceProvider; +use Flarum\Settings\SettingsRepositoryInterface; +use Flarum\User\User; +use Flarum\User\UsernameSlugDriver; +use Illuminate\Support\Arr; class HttpServiceProvider extends AbstractServiceProvider { @@ -25,5 +31,35 @@ class HttpServiceProvider extends AbstractServiceProvider $this->app->bind(Middleware\CheckCsrfToken::class, function ($app) { return new Middleware\CheckCsrfToken($app->make('flarum.http.csrfExemptPaths')); }); + + $this->app->singleton('flarum.http.slugDrivers', function () { + return [ + Discussion::class => [ + 'default' => IdWithTransliteratedSlugDriver::class + ], + User::class => [ + 'default' => UsernameSlugDriver::class + ], + ]; + }); + + $this->app->singleton('flarum.http.selectedSlugDrivers', function () { + $settings = $this->app->make(SettingsRepositoryInterface::class); + + $compiledDrivers = []; + + foreach ($this->app->make('flarum.http.slugDrivers') as $resourceClass => $resourceDrivers) { + $driverKey = $settings->get("slug_driver_$resourceClass", 'default'); + + $driverClass = Arr::get($resourceDrivers, $driverKey, $resourceDrivers['default']); + + $compiledDrivers[$resourceClass] = $this->app->make($driverClass); + } + + return $compiledDrivers; + }); + $this->app->bind(SlugManager::class, function () { + return new SlugManager($this->app->make('flarum.http.selectedSlugDrivers')); + }); } } diff --git a/src/Http/SlugDriverInterface.php b/src/Http/SlugDriverInterface.php new file mode 100644 index 000000000..84d3a621e --- /dev/null +++ b/src/Http/SlugDriverInterface.php @@ -0,0 +1,20 @@ +drivers = $drivers; + } + + public function forResource(string $resourceName): SlugDriverInterface + { + return Arr::get($this->drivers, $resourceName, null); + } +} diff --git a/src/User/UserRepository.php b/src/User/UserRepository.php index b37542a70..eb828e37a 100644 --- a/src/User/UserRepository.php +++ b/src/User/UserRepository.php @@ -40,6 +40,23 @@ class UserRepository return $this->scopeVisibleTo($query, $actor)->firstOrFail(); } + /** + * Find a user by username, optionally making sure it is visible to a certain + * user, or throw an exception. + * + * @param int $id + * @param User $actor + * @return User + * + * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + */ + public function findOrFailByUsername($username, User $actor = null) + { + $query = User::where('username', $username); + + return $this->scopeVisibleTo($query, $actor)->firstOrFail(); + } + /** * Find a user by an identification (username or email). * diff --git a/src/User/UsernameSlugDriver.php b/src/User/UsernameSlugDriver.php new file mode 100644 index 000000000..9bacf3740 --- /dev/null +++ b/src/User/UsernameSlugDriver.php @@ -0,0 +1,36 @@ +users = $users; + } + + public function toSlug(AbstractModel $instance): string + { + return $instance->username; + } + + public function fromSlug(string $slug, User $actor): AbstractModel + { + return $this->users->findOrFailByUsername($slug, $actor); + } +} diff --git a/tests/integration/api/discussions/ShowTest.php b/tests/integration/api/discussions/ShowTest.php index 131cd6776..b4300a34a 100644 --- a/tests/integration/api/discussions/ShowTest.php +++ b/tests/integration/api/discussions/ShowTest.php @@ -66,6 +66,24 @@ class ShowTest extends TestCase $this->assertEquals(200, $response->getStatusCode()); } + /** + * @test + */ + public function author_can_see_discussion_via_slug() + { + // Note that here, the slug doesn't actually have to match the real slug + // since the default slugging strategy only takes the numerical part into account + $response = $this->send( + $this->request('GET', '/api/discussions/1-fdsafdsajfsakf', [ + 'authenticatedAs' => 2, + ])->withQueryParams([ + 'bySlug' => true + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + } + /** * @test */ diff --git a/tests/integration/api/users/CreateTest.php b/tests/integration/api/users/CreateTest.php index c6d7bd198..65e821ab7 100644 --- a/tests/integration/api/users/CreateTest.php +++ b/tests/integration/api/users/CreateTest.php @@ -25,9 +25,10 @@ class CreateTest extends TestCase $this->prepareDatabase([ 'users' => [ $this->adminUser(), + $this->normalUser(), ], 'groups' => [ - $this->adminGroup(), + $this->adminGroup() ], 'group_user' => [ ['user_id' => 1, 'group_id' => 1], diff --git a/tests/integration/api/users/ShowTest.php b/tests/integration/api/users/ShowTest.php new file mode 100644 index 000000000..3ee210203 --- /dev/null +++ b/tests/integration/api/users/ShowTest.php @@ -0,0 +1,197 @@ +prepareDatabase([ + 'users' => [ + $this->adminUser(), + $this->normalUser(), + ], + 'groups' => [ + $this->adminGroup() + ], + 'group_user' => [ + ['user_id' => 1, 'group_id' => 1], + ], + 'settings' => [ + ['key' => 'mail_driver', 'value' => 'log'], + ], + ]); + } + + /** + * @test + */ + public function admin_can_see_user() + { + $response = $this->send( + $this->request('GET', '/api/users/2', [ + 'authenticatedAs' => 1, + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + } + + /** + * @test + */ + public function admin_can_see_user_via_slug() + { + $response = $this->send( + $this->request('GET', '/api/users/normal', [ + 'authenticatedAs' => 1, + ])->withQueryParams([ + 'bySlug' => true + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + } + + /** + * @test + */ + public function guest_cannot_see_user() + { + $response = $this->send( + $this->request('GET', '/api/users/2') + ); + + $this->assertEquals(404, $response->getStatusCode()); + } + + /** + * @test + */ + public function guest_cannot_see_user_by_slug() + { + $response = $this->send( + $this->request('GET', '/api/users/2')->withQueryParams([ + 'bySlug' => true + ]) + ); + + $this->assertEquals(404, $response->getStatusCode()); + } + + /** + * @test + */ + public function user_can_see_themselves() + { + $response = $this->send( + $this->request('GET', '/api/users/2', [ + 'authenticatedAs' => 2, + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + } + + /** + * @test + */ + public function user_can_see_themselves_via_slug() + { + $response = $this->send( + $this->request('GET', '/api/users/normal', [ + 'authenticatedAs' => 2, + ])->withQueryParams([ + 'bySlug' => true + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + } + + /** + * @test + */ + public function user_cant_see_others_by_default() + { + $response = $this->send( + $this->request('GET', '/api/users/1', [ + 'authenticatedAs' => 2, + ]) + ); + + $this->assertEquals(404, $response->getStatusCode()); + } + + /** + * @test + */ + public function user_cant_see_others_by_default_via_slug() + { + $response = $this->send( + $this->request('GET', '/api/users/admin', [ + 'authenticatedAs' => 2, + ])->withQueryParams([ + 'bySlug' => true + ]) + ); + + $this->assertEquals(404, $response->getStatusCode()); + } + + /** + * @test + */ + public function user_can_see_others_if_allowed() + { + $this->prepareDatabase([ + 'group_permission' => [ + ['permission' => 'viewDiscussions', 'group_id' => 3], + ] + ]); + + $response = $this->send( + $this->request('GET', '/api/users/1', [ + 'authenticatedAs' => 2, + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + } + + /** + * @test + */ + public function user_can_see_others_if_allowed_via_slug() + { + $this->prepareDatabase([ + 'group_permission' => [ + ['permission' => 'viewDiscussions', 'group_id' => 3], + ] + ]); + + $response = $this->send( + $this->request('GET', '/api/users/admin', [ + 'authenticatedAs' => 2, + ])->withQueryParams([ + 'bySlug' => true + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + } +} diff --git a/tests/integration/extenders/ModelUrlTest.php b/tests/integration/extenders/ModelUrlTest.php new file mode 100644 index 000000000..e5eea08ad --- /dev/null +++ b/tests/integration/extenders/ModelUrlTest.php @@ -0,0 +1,82 @@ +prepareDatabase([ + 'users' => [ + $this->adminUser(), + $this->normalUser(), + ], + 'settings' => [ + ['key' => "slug_driver_$userClass", 'value' => 'testDriver'], + ] + ]); + } + + /** + * @test + */ + public function uses_default_driver_by_default() + { + $this->prepDb(); + + $slugManager = $this->app()->getContainer()->make(SlugManager::class); + + $testUser = User::find(1); + + $this->assertEquals('admin', $slugManager->forResource(User::class)->toSlug($testUser)); + $this->assertEquals('1', $slugManager->forResource(User::class)->fromSlug('admin', $testUser)->id); + } + + /** + * @test + */ + public function custom_slug_driver_has_effect_if_added() + { + $this->extend((new Extend\ModelUrl(User::class))->addSlugDriver('testDriver', TestSlugDriver::class)); + + $this->prepDb(); + + $slugManager = $this->app()->getContainer()->make(SlugManager::class); + + $testUser = User::find(1); + + $this->assertEquals('test-slug', $slugManager->forResource(User::class)->toSlug($testUser)); + $this->assertEquals('1', $slugManager->forResource(User::class)->fromSlug('random-gibberish', $testUser)->id); + } +} + +class TestSlugDriver implements SlugDriverInterface +{ + public function toSlug(AbstractModel $instance): string + { + return 'test-slug'; + } + + public function fromSlug(string $slug, User $actor): AbstractModel + { + return User::find(1); + } +} diff --git a/views/frontend/content/index.blade.php b/views/frontend/content/index.blade.php index 809a3bd3a..af938247c 100644 --- a/views/frontend/content/index.blade.php +++ b/views/frontend/content/index.blade.php @@ -7,7 +7,7 @@ @foreach ($apiDocument->data as $discussion)
  • {{ $discussion->attributes->title }}