1
0
mirror of https://github.com/flarum/core.git synced 2025-01-17 22:29:15 +01:00

Model Visibility Scoping Extender and Tests (#2460)

This commit is contained in:
Alexander Skvortsov 2020-12-07 20:02:46 -05:00 committed by GitHub
parent e0437d237a
commit 8901073d12
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 527 additions and 145 deletions

View File

@ -12,19 +12,49 @@ namespace Flarum\Database;
use Flarum\Event\ScopeModelVisibility;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
trait ScopeVisibilityTrait
{
protected static $visibilityScopers = [];
public static function registerVisibilityScoper($scoper, $ability = null)
{
$model = static::class;
if ($ability === null) {
$ability = '*';
}
if (! Arr::has(static::$visibilityScopers, "$model.$ability")) {
Arr::set(static::$visibilityScopers, "$model.$ability", []);
}
static::$visibilityScopers[$model][$ability][] = $scoper;
}
/**
* Scope a query to only include records that are visible to a user.
*
* @param Builder $query
* @param User $actor
*/
public function scopeWhereVisibleTo(Builder $query, User $actor)
public function scopeWhereVisibleTo(Builder $query, User $actor, string $ability = 'view')
{
static::$dispatcher->dispatch(
new ScopeModelVisibility($query, $actor, 'view')
);
/**
* @deprecated beta 15, remove beta 15
*/
static::$dispatcher->dispatch(new ScopeModelVisibility($query, $actor, $ability));
foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) {
foreach (Arr::get(static::$visibilityScopers, "$class.*", []) as $listener) {
$listener($actor, $query, $ability);
}
foreach (Arr::get(static::$visibilityScopers, "$class.$ability", []) as $listener) {
$listener($actor, $query);
}
}
return $query;
}
}

View File

@ -0,0 +1,61 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Discussion\Access;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
class ScopeDiscussionVisibility
{
/**
* @param User $actor
* @param Builder $query
*/
public function __invoke(User $actor, $query)
{
if ($actor->cannot('viewDiscussions')) {
$query->whereRaw('FALSE');
return;
}
// Hide private discussions by default.
$query->where(function ($query) use ($actor) {
$query->where('discussions.is_private', false)
->orWhere(function ($query) use ($actor) {
$query->whereVisibleTo($actor, 'viewPrivate');
});
});
// Hide hidden discussions, unless they are authored by the current
// user, or the current user has permission to view hidden discussions.
if (! $actor->hasPermission('discussion.hide')) {
$query->where(function ($query) use ($actor) {
$query->whereNull('discussions.hidden_at')
->orWhere('discussions.user_id', $actor->id)
->orWhere(function ($query) use ($actor) {
$query->whereVisibleTo($actor, 'hide');
});
});
}
// Hide discussions with no comments, unless they are authored by the
// current user, or the user is allowed to edit the discussion's posts.
if (! $actor->hasPermission('discussion.editPosts')) {
$query->where(function ($query) use ($actor) {
$query->where('discussions.comment_count', '>', 0)
->orWhere('discussions.user_id', $actor->id)
->orWhere(function ($query) use ($actor) {
$query->whereVisibleTo($actor, 'editPosts');
});
});
}
}
}

View File

@ -9,12 +9,10 @@
namespace Flarum\Discussion;
use Flarum\Event\ScopeModelVisibility;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\AbstractPolicy;
use Flarum\User\User;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\Eloquent\Builder;
class DiscussionPolicy extends AbstractPolicy
{
@ -55,57 +53,6 @@ class DiscussionPolicy extends AbstractPolicy
}
}
/**
* @param User $actor
* @param Builder $query
*/
public function find(User $actor, Builder $query)
{
if ($actor->cannot('viewDiscussions')) {
$query->whereRaw('FALSE');
return;
}
// Hide private discussions by default.
$query->where(function ($query) use ($actor) {
$query->where('discussions.is_private', false)
->orWhere(function ($query) use ($actor) {
$this->events->dispatch(
new ScopeModelVisibility($query, $actor, 'viewPrivate')
);
});
});
// Hide hidden discussions, unless they are authored by the current
// user, or the current user has permission to view hidden discussions.
if (! $actor->hasPermission('discussion.hide')) {
$query->where(function ($query) use ($actor) {
$query->whereNull('discussions.hidden_at')
->orWhere('discussions.user_id', $actor->id)
->orWhere(function ($query) use ($actor) {
$this->events->dispatch(
new ScopeModelVisibility($query, $actor, 'hide')
);
});
});
}
// Hide discussions with no comments, unless they are authored by the
// current user, or the user is allowed to edit the discussion's posts.
if (! $actor->hasPermission('discussion.editPosts')) {
$query->where(function ($query) use ($actor) {
$query->where('discussions.comment_count', '>', 0)
->orWhere('discussions.user_id', $actor->id)
->orWhere(function ($query) use ($actor) {
$this->events->dispatch(
new ScopeModelVisibility($query, $actor, 'editPosts')
);
});
});
}
}
/**
* @param User $actor
* @param \Flarum\Discussion\Discussion $discussion

View File

@ -9,6 +9,7 @@
namespace Flarum\Discussion;
use Flarum\Discussion\Access\ScopeDiscussionVisibility;
use Flarum\Discussion\Event\Renamed;
use Flarum\Foundation\AbstractServiceProvider;
@ -28,5 +29,7 @@ class DiscussionServiceProvider extends AbstractServiceProvider
Renamed::class,
DiscussionRenamedLogger::class
);
Discussion::registerVisibilityScoper(new ScopeDiscussionVisibility(), 'view');
}
}

View File

@ -15,6 +15,8 @@ use Illuminate\Database\Eloquent\Builder;
/**
* The `ScopeModelVisibility` event allows constraints to be applied in a query
* to fetch a model, effectively scoping that model's visibility to the user.
*
* @deprecated beta 15, remove beta 16
*/
class ScopeModelVisibility
{

View File

@ -0,0 +1,102 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Extend;
use Exception;
use Flarum\Extension\Extension;
use Flarum\Foundation\ContainerUtil;
use Illuminate\Contracts\Container\Container;
/**
* Model visibility scoping allows us to scope queries based on the current user.
* The main usage of this is only showing model instances that a user is allowed to see.
*
* This is done by running a query through a series of "scoper" callbacks, which apply
* additional `where`s to the query based on the user.
*
* Scopers are classified under an ability. Calling `whereVisibleTo` on a query
* will apply scopers under the `view` ability. Generally, the main `view` scopers
* can request scoping with other abilities, which provides an entrypoint for extensions
* to modify some restriction to a query.
*
* Scopers registered via `scopeAll` will apply to all queries under a model, regardless
* of the ability, and will accept the ability name as an additional argument.
*/
class ModelVisibility implements ExtenderInterface
{
private $modelClass;
private $scopers = [];
private $allScopers = [];
/**
* @param string $modelClass The ::class attribute of the model you are applying scopers to.
* This model must extend from \Flarum\Database\AbstractModel,
* and use \Flarum\Database\ScopeVisibilityTrait.
*/
public function __construct(string $modelClass)
{
$this->modelClass = $modelClass;
if (! method_exists($modelClass, 'registerVisibilityScoper')) {
throw new Exception("Model $modelClass cannot be visibility scoped as it does not use Flarum\Database\ScopeVisibilityTrait.");
}
}
/**
* Add a scoper for a given ability.
*
* @param callable|string $callback
* @param string $ability, defaults to 'view'
*
* The callback can be a closure or invokable class, and should accept:
* - \Flarum\User\User $actor
* - \Illuminate\Database\Eloquent\Builder $query
*
* @return self
*/
public function scope($callback, $ability = 'view')
{
$this->scopers[$ability][] = $callback;
return $this;
}
/**
* Add a scoper scoper that will always run for this model, regardless of requested ability.
*
* @param callable|string $callback
*
* The callback can be a closure or invokable class, and should accept:
* - \Flarum\User\User $actor
* - \Illuminate\Database\Eloquent\Builder $query
* - string $ability
*
* @return self
*/
public function scopeAll($callback)
{
$this->allScopers[] = $callback;
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
foreach ($this->scopers as $ability => $scopers) {
foreach ($scopers as $scoper) {
$this->modelClass::registerVisibilityScoper(ContainerUtil::wrapCallback($scoper, $container), $ability);
}
}
foreach ($this->allScopers as $scoper) {
$this->modelClass::registerVisibilityScoper(ContainerUtil::wrapCallback($scoper, $container));
}
}
}

View File

@ -0,0 +1,27 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Group\Access;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
class ScopeGroupVisibility
{
/**
* @param User $actor
* @param Builder $query
*/
public function __invoke(User $actor, $query)
{
if ($actor->cannot('viewHiddenGroups')) {
$query->where('is_hidden', false);
}
}
}

View File

@ -11,7 +11,6 @@ namespace Flarum\Group;
use Flarum\User\AbstractPolicy;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
class GroupPolicy extends AbstractPolicy
{
@ -31,15 +30,4 @@ class GroupPolicy extends AbstractPolicy
return true;
}
}
/**
* @param User $actor
* @param Builder $query
*/
public function find(User $actor, Builder $query)
{
if ($actor->cannot('viewHiddenGroups')) {
$query->where('is_hidden', false);
}
}
}

View File

@ -10,6 +10,7 @@
namespace Flarum\Group;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Group\Access\ScopeGroupVisibility;
class GroupServiceProvider extends AbstractServiceProvider
{
@ -20,5 +21,7 @@ class GroupServiceProvider extends AbstractServiceProvider
{
$events = $this->app->make('events');
$events->subscribe(GroupPolicy::class);
Group::registerVisibilityScoper(new ScopeGroupVisibility(), 'view');
}
}

View File

@ -11,7 +11,6 @@ namespace Flarum\Notification;
use Carbon\Carbon;
use Flarum\Database\AbstractModel;
use Flarum\Event\ScopeModelVisibility;
use Flarum\Notification\Blueprint\BlueprintInterface;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
@ -161,9 +160,9 @@ class Notification extends AbstractModel
->from((new $class)->getTable())
->whereColumn('id', 'subject_id');
static::$dispatcher->dispatch(
new ScopeModelVisibility($class::query()->setQuery($query), $actor, 'view')
);
if (method_exists($class, 'registerVisibilityScoper')) {
$class::query()->setQuery($query)->whereVisibleTo($actor);
}
});
});
}

View File

@ -0,0 +1,62 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Post\Access;
use Flarum\Discussion\Discussion;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
class ScopePostVisibility
{
/**
* @param User $actor
* @param Builder $query
*/
public function __invoke(User $actor, $query)
{
// Make sure the post's discussion is visible as well.
$query->whereExists(function ($query) use ($actor) {
$query->selectRaw('1')
->from('discussions')
->whereColumn('discussions.id', 'posts.discussion_id');
Discussion::query()->setQuery($query)->whereVisibleTo($actor);
});
// Hide private posts by default.
$query->where(function ($query) use ($actor) {
$query->where('posts.is_private', false)
->orWhere(function ($query) use ($actor) {
$query->whereVisibleTo($actor, 'viewPrivate');
});
});
// Hide hidden posts, unless they are authored by the current user, or
// the current user has permission to view hidden posts in the
// discussion.
if (! $actor->hasPermission('discussion.hidePosts')) {
$query->where(function ($query) use ($actor) {
$query->whereNull('posts.hidden_at')
->orWhere('posts.user_id', $actor->id)
->orWhereExists(function ($query) use ($actor) {
$query->selectRaw('1')
->from('discussions')
->whereColumn('discussions.id', 'posts.discussion_id')
->where(function ($query) use ($actor) {
$query
->whereRaw('1=0')
->orWhere(function ($query) use ($actor) {
Discussion::query()->setQuery($query)->whereVisibleTo($actor, 'hidePosts');
});
});
});
});
}
}
}

View File

@ -10,13 +10,10 @@
namespace Flarum\Post;
use Carbon\Carbon;
use Flarum\Discussion\Discussion;
use Flarum\Event\ScopeModelVisibility;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\AbstractPolicy;
use Flarum\User\User;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\Eloquent\Builder;
class PostPolicy extends AbstractPolicy
{
@ -58,58 +55,6 @@ class PostPolicy extends AbstractPolicy
}
}
/**
* @param User $actor
* @param Builder $query
*/
public function find(User $actor, $query)
{
// Make sure the post's discussion is visible as well.
$query->whereExists(function ($query) use ($actor) {
$query->selectRaw('1')
->from('discussions')
->whereColumn('discussions.id', 'posts.discussion_id');
$this->events->dispatch(
new ScopeModelVisibility(Discussion::query()->setQuery($query), $actor, 'view')
);
});
// Hide private posts by default.
$query->where(function ($query) use ($actor) {
$query->where('posts.is_private', false)
->orWhere(function ($query) use ($actor) {
$this->events->dispatch(
new ScopeModelVisibility($query, $actor, 'viewPrivate')
);
});
});
// Hide hidden posts, unless they are authored by the current user, or
// the current user has permission to view hidden posts in the
// discussion.
if (! $actor->hasPermission('discussion.hidePosts')) {
$query->where(function ($query) use ($actor) {
$query->whereNull('posts.hidden_at')
->orWhere('posts.user_id', $actor->id)
->orWhereExists(function ($query) use ($actor) {
$query->selectRaw('1')
->from('discussions')
->whereColumn('discussions.id', 'posts.discussion_id')
->where(function ($query) use ($actor) {
$query
->whereRaw('1=0')
->orWhere(function ($query) use ($actor) {
$this->events->dispatch(
new ScopeModelVisibility(Discussion::query()->setQuery($query), $actor, 'hidePosts')
);
});
});
});
});
}
}
/**
* @param User $actor
* @param Post $post

View File

@ -12,6 +12,7 @@ namespace Flarum\Post;
use DateTime;
use Flarum\Event\ConfigurePostTypes;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Post\Access\ScopePostVisibility;
class PostServiceProvider extends AbstractServiceProvider
{
@ -52,6 +53,8 @@ class PostServiceProvider extends AbstractServiceProvider
$events = $this->app->make('events');
$events->subscribe(PostPolicy::class);
Post::registerVisibilityScoper(new ScopePostVisibility(), 'view');
}
protected function setPostTypes()

View File

@ -54,6 +54,7 @@ abstract class AbstractPolicy
/**
* @param ScopeModelVisibility $event
* @deprecated beta 15, remove beta 16
*/
public function scopeModelVisibility(ScopeModelVisibility $event)
{

View File

@ -0,0 +1,31 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\User\Access;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
class ScopeUserVisibility
{
/**
* @param User $actor
* @param Builder $query
*/
public function __invoke(User $actor, $query)
{
if ($actor->cannot('viewDiscussions')) {
if ($actor->isGuest()) {
$query->whereRaw('FALSE');
} else {
$query->where('id', $actor->id);
}
}
}
}

View File

@ -9,8 +9,6 @@
namespace Flarum\User;
use Illuminate\Database\Eloquent\Builder;
class UserPolicy extends AbstractPolicy
{
/**
@ -29,19 +27,4 @@ class UserPolicy extends AbstractPolicy
return true;
}
}
/**
* @param User $actor
* @param Builder $query
*/
public function find(User $actor, Builder $query)
{
if ($actor->cannot('viewDiscussions')) {
if ($actor->isGuest()) {
$query->whereRaw('FALSE');
} else {
$query->where('id', $actor->id);
}
}
}
}

View File

@ -12,6 +12,7 @@ namespace Flarum\User;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Foundation\ContainerUtil;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\Access\ScopeUserVisibility;
use Flarum\User\DisplayName\DriverInterface;
use Flarum\User\DisplayName\UsernameDriver;
use Flarum\User\Event\EmailChangeRequested;
@ -96,5 +97,7 @@ class UserServiceProvider extends AbstractServiceProvider
User::registerPreference('discloseOnline', 'boolval', true);
User::registerPreference('indexProfile', 'boolval', true);
User::registerPreference('locale');
User::registerVisibilityScoper(new ScopeUserVisibility(), 'view');
}
}

View File

@ -0,0 +1,189 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tests\integration\extenders;
use Carbon\Carbon;
use Flarum\Discussion\Discussion;
use Flarum\Extend;
use Flarum\Post\CommentPost;
use Flarum\Post\Post;
use Flarum\Tests\integration\RetrievesAuthorizedUsers;
use Flarum\Tests\integration\TestCase;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
class ModelVisibilityTest extends TestCase
{
use RetrievesAuthorizedUsers;
protected function prepDb()
{
$this->prepareDatabase([
'discussions' => [
['id' => 1, 'title' => 'Empty discussion', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'first_post_id' => null, 'comment_count' => 0, 'is_private' => 0],
['id' => 2, 'title' => 'Discussion with post', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'first_post_id' => 1, 'comment_count' => 1, 'is_private' => 0],
['id' => 3, 'title' => 'Private discussion', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 1, 'first_post_id' => 2, 'comment_count' => 1, 'is_private' => 1],
],
'posts' => [
['id' => 1, 'discussion_id' => 2, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>a normal reply - too-obscure</p></t>'],
['id' => 2, 'discussion_id' => 3, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>private!</p></t>'],
],
'users' => [
$this->normalUser(),
],
'groups' => [
$this->guestGroup(),
$this->memberGroup(),
],
'group_user' => [
['user_id' => 2, 'group_id' => 3],
],
'group_permission' => [
['permission' => 'viewDiscussions', 'group_id' => 2],
['permission' => 'viewDiscussions', 'group_id' => 3],
]
]);
}
/**
* @test
*/
public function user_can_see_posts_by_default()
{
$this->prepDb();
$actor = User::find(2);
$visiblePosts = CommentPost::query()->whereVisibleTo($actor)->get();
$this->assertCount(1, $visiblePosts);
}
/**
* @test
*/
public function custom_visibility_scoper_can_stop_user_from_seeing_posts()
{
$this->extend(
(new Extend\ModelVisibility(CommentPost::class))
->scope(function (User $user, Builder $query) {
$query->whereRaw('1=0');
}, 'view')
);
$this->prepDb();
$actor = User::find(2);
$visiblePosts = CommentPost::query()->whereVisibleTo($actor)->get();
$this->assertCount(0, $visiblePosts);
}
/**
* @test
*/
public function custom_visibility_scoper_applies_if_added_to_parent_class()
{
$this->extend(
(new Extend\ModelVisibility(Post::class))
->scope(function (User $user, Builder $query) {
$query->whereRaw('1=0');
}, 'view')
);
$this->prepDb();
$actor = User::find(2);
$visiblePosts = CommentPost::query()->whereVisibleTo($actor)->get();
$this->assertCount(0, $visiblePosts);
}
/**
* @test
*/
public function custom_visibility_scoper_for_class_applied_after_scopers_for_parent_class()
{
$this->extend(
(new Extend\ModelVisibility(CommentPost::class))
->scope(function (User $user, Builder $query) {
$query->orWhereRaw('1=1');
}, 'view'),
(new Extend\ModelVisibility(Post::class))
->scope(function (User $user, Builder $query) {
$query->whereRaw('1=0');
}, 'view')
);
$this->prepDb();
$actor = User::find(2);
$visiblePosts = CommentPost::query()->whereVisibleTo($actor)->get();
$this->assertCount(2, $visiblePosts);
}
/**
* @test
*/
public function custom_scoper_works_for_abilities_other_than_view()
{
$this->extend(
(new Extend\ModelVisibility(Discussion::class))
->scope(function (User $user, Builder $query) {
$query->whereRaw('1=1');
}, 'viewPrivate'),
(new Extend\ModelVisibility(Post::class))
->scope(function (User $user, Builder $query) {
$query->whereRaw('1=1');
}, 'viewPrivate')
);
$this->prepDb();
$actor = User::find(2);
$visiblePosts = CommentPost::query()->whereVisibleTo($actor)->get();
$this->assertCount(2, $visiblePosts);
}
/**
* @test
*/
public function universal_scoper_works()
{
$this->extend(
(new Extend\ModelVisibility(Discussion::class))
->scopeAll(function (User $user, Builder $query, string $ability) {
if ($ability == 'viewPrivate') {
$query->whereRaw('1=1');
}
}),
(new Extend\ModelVisibility(Post::class))
->scopeAll(function (User $user, Builder $query, string $ability) {
if ($ability == 'viewPrivate') {
$query->whereRaw('1=1');
}
})
);
$this->prepDb();
$actor = User::find(2);
$visiblePosts = CommentPost::query()->whereVisibleTo($actor)->get();
$this->assertCount(2, $visiblePosts);
}
}

View File

@ -33,6 +33,9 @@ class UserTest extends TestCase
'settings' => [
['key' => 'display_name_driver', 'value' => 'custom'],
],
'group_permission' => [
['permission' => 'viewUserList', 'group_id' => 3],
]
]);
}