mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-04-17 00:39:06 +08:00
Watching: Prevent issues when watchable or user is deleted
- Adds filtering to the watched items list in notification preferences so that deleted (recycle bin) items are removed via query. - Adds relations and logic to properly remove watches upon user and entity delete events, to old watches in database do not linger. - Adds testing to cover the above. Did not add migration for existing data, since patch will be close to introduction, and lingering DB entries don't open a security concern, just some potential confusion in specific potential scenarios. Probably not work extra migration risk, although could add in future if concerns/issues are found. Related to #4499
This commit is contained in:
parent
1cd19c76ba
commit
817581aa0c
@ -37,7 +37,7 @@ class EntityProvider
|
||||
* Fetch all core entity types as an associated array
|
||||
* with their basic names as the keys.
|
||||
*
|
||||
* @return array<Entity>
|
||||
* @return array<string, Entity>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
|
@ -10,6 +10,7 @@ use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\Activity\Models\Tag;
|
||||
use BookStack\Activity\Models\View;
|
||||
use BookStack\Activity\Models\Viewable;
|
||||
use BookStack\Activity\Models\Watch;
|
||||
use BookStack\App\Model;
|
||||
use BookStack\App\Sluggable;
|
||||
use BookStack\Entities\Tools\SlugGenerator;
|
||||
@ -330,6 +331,14 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the related watches for this entity.
|
||||
*/
|
||||
public function watches(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Watch::class, 'watchable');
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
|
@ -376,6 +376,7 @@ class TrashCan
|
||||
$entity->searchTerms()->delete();
|
||||
$entity->deletions()->delete();
|
||||
$entity->favourites()->delete();
|
||||
$entity->watches()->delete();
|
||||
$entity->referencesTo()->delete();
|
||||
$entity->referencesFrom()->delete();
|
||||
|
||||
|
@ -3,6 +3,7 @@
|
||||
namespace BookStack\Permissions;
|
||||
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Entities\EntityProvider;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Permissions\Models\EntityPermission;
|
||||
@ -11,6 +12,7 @@ use BookStack\Users\Models\HasOwner;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class PermissionApplicator
|
||||
@ -147,6 +149,42 @@ class PermissionApplicator
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter out items that have related entity relations where
|
||||
* the entity is marked as deleted.
|
||||
*/
|
||||
public function filterDeletedFromEntityRelationQuery(Builder $query, string $tableName, string $entityIdColumn, string $entityTypeColumn): Builder
|
||||
{
|
||||
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
|
||||
$entityProvider = new EntityProvider();
|
||||
|
||||
$joinQuery = function ($query) use ($entityProvider) {
|
||||
$first = true;
|
||||
/** @var Builder $query */
|
||||
foreach ($entityProvider->all() as $entity) {
|
||||
$entityQuery = function ($query) use ($entity) {
|
||||
/** @var Builder $query */
|
||||
$query->select(['id', 'deleted_at'])
|
||||
->selectRaw("'{$entity->getMorphClass()}' as type")
|
||||
->from($entity->getTable())
|
||||
->whereNotNull('deleted_at');
|
||||
};
|
||||
|
||||
if ($first) {
|
||||
$entityQuery($query);
|
||||
$first = false;
|
||||
} else {
|
||||
$query->union($entityQuery);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return $query->leftJoinSub($joinQuery, 'deletions', function (JoinClause $join) use ($tableDetails) {
|
||||
$join->on($tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'], '=', 'deletions.id')
|
||||
->on($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', 'deletions.type');
|
||||
})->whereNull('deletions.deleted_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add conditions to a query for a model that's a relation of a page, so only the model results
|
||||
* on visible pages are returned by the query.
|
||||
|
@ -2,7 +2,6 @@
|
||||
|
||||
namespace BookStack\Users\Controllers;
|
||||
|
||||
use BookStack\Activity\Models\Watch;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Settings\UserNotificationPreferences;
|
||||
@ -68,8 +67,9 @@ class UserPreferencesController extends Controller
|
||||
|
||||
$preferences = (new UserNotificationPreferences(user()));
|
||||
|
||||
$query = Watch::query()->where('user_id', '=', user()->id);
|
||||
$query = user()->watches()->getQuery();
|
||||
$query = $permissions->restrictEntityRelationQuery($query, 'watches', 'watchable_id', 'watchable_type');
|
||||
$query = $permissions->filterDeletedFromEntityRelationQuery($query, 'watches', 'watchable_id', 'watchable_type');
|
||||
$watches = $query->with('watchable')->paginate(20);
|
||||
|
||||
$this->setPageTitle(trans('preferences.notifications'));
|
||||
|
@ -6,6 +6,7 @@ use BookStack\Access\Mfa\MfaValue;
|
||||
use BookStack\Access\SocialAccount;
|
||||
use BookStack\Activity\Models\Favourite;
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\Activity\Models\Watch;
|
||||
use BookStack\Api\ApiToken;
|
||||
use BookStack\App\Model;
|
||||
use BookStack\App\Sluggable;
|
||||
@ -291,6 +292,14 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
return $this->hasMany(MfaValue::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tracked entity watches for this user.
|
||||
*/
|
||||
public function watches(): HasMany
|
||||
{
|
||||
return $this->hasMany(Watch::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last activity time for this user.
|
||||
*/
|
||||
|
@ -18,18 +18,13 @@ use Illuminate\Support\Str;
|
||||
|
||||
class UserRepo
|
||||
{
|
||||
protected UserAvatars $userAvatar;
|
||||
protected UserInviteService $inviteService;
|
||||
|
||||
/**
|
||||
* UserRepo constructor.
|
||||
*/
|
||||
public function __construct(UserAvatars $userAvatar, UserInviteService $inviteService)
|
||||
{
|
||||
$this->userAvatar = $userAvatar;
|
||||
$this->inviteService = $inviteService;
|
||||
public function __construct(
|
||||
protected UserAvatars $userAvatar,
|
||||
protected UserInviteService $inviteService
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get a user by their email address.
|
||||
*/
|
||||
@ -155,6 +150,7 @@ class UserRepo
|
||||
$user->apiTokens()->delete();
|
||||
$user->favourites()->delete();
|
||||
$user->mfaValues()->delete();
|
||||
$user->watches()->delete();
|
||||
$user->delete();
|
||||
|
||||
// Delete user profile images
|
||||
|
@ -12,6 +12,7 @@ use BookStack\Activity\Tools\ActivityLogger;
|
||||
use BookStack\Activity\Tools\UserEntityWatchOptions;
|
||||
use BookStack\Activity\WatchLevels;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use BookStack\Settings\UserNotificationPreferences;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Tests\TestCase;
|
||||
@ -370,4 +371,32 @@ class WatchTest extends TestCase
|
||||
|
||||
$notifications->assertNothingSentTo($editor);
|
||||
}
|
||||
|
||||
public function test_watches_deleted_on_user_delete()
|
||||
{
|
||||
$editor = $this->users->editor();
|
||||
$page = $this->entities->page();
|
||||
|
||||
$watches = new UserEntityWatchOptions($editor, $page);
|
||||
$watches->updateLevelByValue(WatchLevels::COMMENTS);
|
||||
$this->assertDatabaseHas('watches', ['user_id' => $editor->id]);
|
||||
|
||||
$this->asAdmin()->delete($editor->getEditUrl());
|
||||
|
||||
$this->assertDatabaseMissing('watches', ['user_id' => $editor->id]);
|
||||
}
|
||||
|
||||
public function test_watches_deleted_on_item_delete()
|
||||
{
|
||||
$editor = $this->users->editor();
|
||||
$page = $this->entities->page();
|
||||
|
||||
$watches = new UserEntityWatchOptions($editor, $page);
|
||||
$watches->updateLevelByValue(WatchLevels::COMMENTS);
|
||||
$this->assertDatabaseHas('watches', ['watchable_type' => 'page', 'watchable_id' => $page->id]);
|
||||
|
||||
$this->entities->destroy($page);
|
||||
|
||||
$this->assertDatabaseMissing('watches', ['watchable_type' => 'page', 'watchable_id' => $page->id]);
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Repos\BookshelfRepo;
|
||||
use BookStack\Entities\Repos\ChapterRepo;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
@ -197,6 +198,16 @@ class EntityProvider
|
||||
return $draftPage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully destroy the given entity from the system, bypassing the recycle bin
|
||||
* stage. Still runs through main app deletion logic.
|
||||
*/
|
||||
public function destroy(Entity $entity)
|
||||
{
|
||||
$trash = app()->make(TrashCan::class);
|
||||
$trash->destroyEntity($entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Entity|Entity[] $entities
|
||||
*/
|
||||
|
@ -124,6 +124,23 @@ class UserPreferencesTest extends TestCase
|
||||
$resp->assertDontSee('All Page Updates & Comments');
|
||||
}
|
||||
|
||||
public function test_notification_preferences_dont_error_on_deleted_items()
|
||||
{
|
||||
$editor = $this->users->editor();
|
||||
$book = $this->entities->book();
|
||||
|
||||
$options = new UserEntityWatchOptions($editor, $book);
|
||||
$options->updateLevelByValue(WatchLevels::COMMENTS);
|
||||
|
||||
$this->actingAs($editor)->delete($book->getUrl());
|
||||
$book->refresh();
|
||||
$this->assertNotNull($book->deleted_at);
|
||||
|
||||
$resp = $this->actingAs($editor)->get('/preferences/notifications');
|
||||
$resp->assertOk();
|
||||
$resp->assertDontSee($book->name);
|
||||
}
|
||||
|
||||
public function test_notification_preferences_not_accessible_to_guest()
|
||||
{
|
||||
$this->setSettings(['app-public' => 'true']);
|
||||
|
Loading…
x
Reference in New Issue
Block a user