Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Model Visibility Scoping Extender and Tests #2460

Merged
merged 18 commits into from
Dec 8, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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