Skip to content

Commit

Permalink
Model Visibility Scoping Extender and Tests (#2460)
Browse files Browse the repository at this point in the history
  • Loading branch information
askvortsov1 authored Dec 8, 2020
1 parent e0437d2 commit 8901073
Show file tree
Hide file tree
Showing 19 changed files with 527 additions and 145 deletions.
38 changes: 34 additions & 4 deletions src/Database/ScopeVisibilityTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,49 @@
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;
}
}
61 changes: 61 additions & 0 deletions src/Discussion/Access/ScopeDiscussionVisibility.php
Original file line number Diff line number Diff line change
@@ -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');
});
});
}
}
}
53 changes: 0 additions & 53 deletions src/Discussion/DiscussionPolicy.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -55,57 +53,6 @@ public function can(User $actor, $ability)
}
}

/**
* @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
Expand Down
3 changes: 3 additions & 0 deletions src/Discussion/DiscussionServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

namespace Flarum\Discussion;

use Flarum\Discussion\Access\ScopeDiscussionVisibility;
use Flarum\Discussion\Event\Renamed;
use Flarum\Foundation\AbstractServiceProvider;

Expand All @@ -28,5 +29,7 @@ public function boot()
Renamed::class,
DiscussionRenamedLogger::class
);

Discussion::registerVisibilityScoper(new ScopeDiscussionVisibility(), 'view');
}
}
2 changes: 2 additions & 0 deletions src/Event/ScopeModelVisibility.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
/**
* 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
{
Expand Down
102 changes: 102 additions & 0 deletions src/Extend/ModelVisibility.php
Original file line number Diff line number Diff line change
@@ -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));
}
}
}
27 changes: 27 additions & 0 deletions src/Group/Access/ScopeGroupVisibility.php
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
12 changes: 0 additions & 12 deletions src/Group/GroupPolicy.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@

use Flarum\User\AbstractPolicy;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;

class GroupPolicy extends AbstractPolicy
{
Expand All @@ -31,15 +30,4 @@ public function can(User $actor, $ability)
return true;
}
}

/**
* @param User $actor
* @param Builder $query
*/
public function find(User $actor, Builder $query)
{
if ($actor->cannot('viewHiddenGroups')) {
$query->where('is_hidden', false);
}
}
}
3 changes: 3 additions & 0 deletions src/Group/GroupServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
namespace Flarum\Group;

use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Group\Access\ScopeGroupVisibility;

class GroupServiceProvider extends AbstractServiceProvider
{
Expand All @@ -20,5 +21,7 @@ public function boot()
{
$events = $this->app->make('events');
$events->subscribe(GroupPolicy::class);

Group::registerVisibilityScoper(new ScopeGroupVisibility(), 'view');
}
}
Loading

0 comments on commit 8901073

Please sign in to comment.