Refactored the activity service

- Renamed to "ActivityLogger" to be more focused in usage.
- Extracted out query elements to seperate "ActivityQueries" class.
- Removed old 'addForEntity' activity method to limit activity record
  points.
This commit is contained in:
Dan Brown 2021-12-11 17:29:33 +00:00
parent f2cb3b94f9
commit 9079700170
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
21 changed files with 166 additions and 153 deletions

View File

@ -0,0 +1,95 @@
<?php
namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\Models\Entity;
use BookStack\Interfaces\Loggable;
use Illuminate\Support\Facades\Log;
class ActivityLogger
{
protected $permissionService;
public function __construct(PermissionService $permissionService)
{
$this->permissionService = $permissionService;
}
/**
* Add a generic activity event to the database.
*
* @param string|Loggable $detail
*/
public function add(string $type, $detail = '')
{
$detailToStore = ($detail instanceof Loggable) ? $detail->logDescriptor() : $detail;
$activity = $this->newActivityForUser($type);
$activity->detail = $detailToStore;
if ($detail instanceof Entity) {
$activity->entity_id = $detail->id;
$activity->entity_type = $detail->getMorphClass();
}
$activity->save();
$this->setNotification($type);
}
/**
* Get a new activity instance for the current user.
*/
protected function newActivityForUser(string $type): Activity
{
$ip = request()->ip() ?? '';
return (new Activity())->forceFill([
'type' => strtolower($type),
'user_id' => user()->id,
'ip' => config('app.env') === 'demo' ? '127.0.0.1' : $ip,
]);
}
/**
* Removes the entity attachment from each of its activities
* and instead uses the 'extra' field with the entities name.
* Used when an entity is deleted.
*/
public function removeEntity(Entity $entity)
{
$entity->activity()->update([
'detail' => $entity->name,
'entity_id' => null,
'entity_type' => null,
]);
}
/**
* Flashes a notification message to the session if an appropriate message is available.
*/
protected function setNotification(string $type)
{
$notificationTextKey = 'activities.' . $type . '_notification';
if (trans()->has($notificationTextKey)) {
$message = trans($notificationTextKey);
session()->flash('success', $message);
}
}
/**
* Log out a failed login attempt, Providing the given username
* as part of the message if the '%u' string is used.
*/
public function logFailedLogin(string $username)
{
$message = config('logging.failed_login.message');
if (!$message) {
return;
}
$message = str_replace('%u', $username, $message);
$channel = config('logging.failed_login.channel');
Log::channel($channel)->warning($message);
}
}

View File

@ -8,84 +8,25 @@ use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Interfaces\Loggable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\Log;
class ActivityService
class ActivityQueries
{
protected $activity;
protected $permissionService;
public function __construct(Activity $activity, PermissionService $permissionService)
public function __construct(PermissionService $permissionService)
{
$this->activity = $activity;
$this->permissionService = $permissionService;
}
/**
* Add activity data to database for an entity.
*/
public function addForEntity(Entity $entity, string $type)
{
$activity = $this->newActivityForUser($type);
$entity->activity()->save($activity);
$this->setNotification($type);
}
/**
* Add a generic activity event to the database.
*
* @param string|Loggable $detail
*/
public function add(string $type, $detail = '')
{
if ($detail instanceof Loggable) {
$detail = $detail->logDescriptor();
}
$activity = $this->newActivityForUser($type);
$activity->detail = $detail;
$activity->save();
$this->setNotification($type);
}
/**
* Get a new activity instance for the current user.
*/
protected function newActivityForUser(string $type): Activity
{
$ip = request()->ip() ?? '';
return $this->activity->newInstance()->forceFill([
'type' => strtolower($type),
'user_id' => user()->id,
'ip' => config('app.env') === 'demo' ? '127.0.0.1' : $ip,
]);
}
/**
* Removes the entity attachment from each of its activities
* and instead uses the 'extra' field with the entities name.
* Used when an entity is deleted.
*/
public function removeEntity(Entity $entity)
{
$entity->activity()->update([
'detail' => $entity->name,
'entity_id' => null,
'entity_type' => null,
]);
}
/**
* Gets the latest activity.
*/
public function latest(int $count = 20, int $page = 0): array
{
$activityList = $this->permissionService
->filterRestrictedEntityRelations($this->activity->newQuery(), 'activities', 'entity_id', 'entity_type')
->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')
->with(['user', 'entity'])
->skip($count * $page)
@ -111,7 +52,7 @@ class ActivityService
$queryIds[(new Page())->getMorphClass()] = $entity->pages()->scopes('visible')->pluck('id');
}
$query = $this->activity->newQuery();
$query = Activity::query();
$query->where(function (Builder $query) use ($queryIds) {
foreach ($queryIds as $morphClass => $idArr) {
$query->orWhere(function (Builder $innerQuery) use ($morphClass, $idArr) {
@ -138,7 +79,7 @@ class ActivityService
public function userActivity(User $user, int $count = 20, int $page = 0): array
{
$activityList = $this->permissionService
->filterRestrictedEntityRelations($this->activity->newQuery(), 'activities', 'entity_id', 'entity_type')
->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')
->where('user_id', '=', $user->id)
->skip($count * $page)
@ -152,8 +93,6 @@ class ActivityService
* Filters out similar activity.
*
* @param Activity[] $activities
*
* @return array
*/
protected function filterSimilar(iterable $activities): array
{
@ -171,31 +110,4 @@ class ActivityService
return $newActivity;
}
/**
* Flashes a notification message to the session if an appropriate message is available.
*/
protected function setNotification(string $type)
{
$notificationTextKey = 'activities.' . $type . '_notification';
if (trans()->has($notificationTextKey)) {
$message = trans($notificationTextKey);
session()->flash('success', $message);
}
}
/**
* Log out a failed login attempt, Providing the given username
* as part of the message if the '%u' string is used.
*/
public function logFailedLogin(string $username)
{
$message = config('logging.failed_login.message');
if (!$message) {
return;
}
$message = str_replace('%u', $username, $message);
$channel = config('logging.failed_login.channel');
Log::channel($channel)->warning($message);
}
}
}

View File

@ -45,7 +45,7 @@ class CommentRepo
$comment->parent_id = $parent_id;
$entity->comments()->save($comment);
ActivityService::addForEntity($entity, ActivityType::COMMENTED_ON);
ActivityService::add(ActivityType::COMMENTED_ON, $entity);
return $comment;
}

View File

@ -2,7 +2,6 @@
namespace BookStack\Auth;
use Activity;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
@ -215,14 +214,6 @@ class UserRepo
}
}
/**
* Get the latest activity for a user.
*/
public function getActivity(User $user, int $count = 20, int $page = 0): array
{
return Activity::userActivity($user, $count, $page);
}
/**
* Get the recently created content for this given user.
*/

View File

@ -14,6 +14,7 @@ use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Facades\Permissions;
use BookStack\Interfaces\Deletable;
use BookStack\Interfaces\Favouritable;
use BookStack\Interfaces\Loggable;
use BookStack\Interfaces\Sluggable;
use BookStack\Interfaces\Viewable;
use BookStack\Model;
@ -45,7 +46,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @method static Builder withLastView()
* @method static Builder withViewCount()
*/
abstract class Entity extends Model implements Sluggable, Favouritable, Viewable, Deletable
abstract class Entity extends Model implements Sluggable, Favouritable, Viewable, Deletable, Loggable
{
use SoftDeletes;
use HasCreatorAndUpdater;
@ -321,4 +322,12 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
->where('user_id', '=', user()->id)
->exists();
}
/**
* {@inheritdoc}
*/
public function logDescriptor(): string
{
return "({$this->id}) {$this->name}";
}
}

View File

@ -91,7 +91,7 @@ class BookRepo
{
$book = new Book();
$this->baseRepo->create($book, $input);
Activity::addForEntity($book, ActivityType::BOOK_CREATE);
Activity::add(ActivityType::BOOK_CREATE, $book);
return $book;
}
@ -102,7 +102,7 @@ class BookRepo
public function update(Book $book, array $input): Book
{
$this->baseRepo->update($book, $input);
Activity::addForEntity($book, ActivityType::BOOK_UPDATE);
Activity::add(ActivityType::BOOK_UPDATE, $book);
return $book;
}
@ -127,7 +127,7 @@ class BookRepo
{
$trashCan = new TrashCan();
$trashCan->softDestroyBook($book);
Activity::addForEntity($book, ActivityType::BOOK_DELETE);
Activity::add(ActivityType::BOOK_DELETE, $book);
$trashCan->autoClearOld();
}

View File

@ -90,7 +90,7 @@ class BookshelfRepo
$shelf = new Bookshelf();
$this->baseRepo->create($shelf, $input);
$this->updateBooks($shelf, $bookIds);
Activity::addForEntity($shelf, ActivityType::BOOKSHELF_CREATE);
Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf);
return $shelf;
}
@ -106,7 +106,7 @@ class BookshelfRepo
$this->updateBooks($shelf, $bookIds);
}
Activity::addForEntity($shelf, ActivityType::BOOKSHELF_UPDATE);
Activity::add(ActivityType::BOOKSHELF_UPDATE, $shelf);
return $shelf;
}
@ -177,7 +177,7 @@ class BookshelfRepo
{
$trashCan = new TrashCan();
$trashCan->softDestroyShelf($shelf);
Activity::addForEntity($shelf, ActivityType::BOOKSHELF_DELETE);
Activity::add(ActivityType::BOOKSHELF_DELETE, $shelf);
$trashCan->autoClearOld();
}
}

View File

@ -49,7 +49,7 @@ class ChapterRepo
$chapter->book_id = $parentBook->id;
$chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
$this->baseRepo->create($chapter, $input);
Activity::addForEntity($chapter, ActivityType::CHAPTER_CREATE);
Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
return $chapter;
}
@ -60,7 +60,7 @@ class ChapterRepo
public function update(Chapter $chapter, array $input): Chapter
{
$this->baseRepo->update($chapter, $input);
Activity::addForEntity($chapter, ActivityType::CHAPTER_UPDATE);
Activity::add(ActivityType::CHAPTER_UPDATE, $chapter);
return $chapter;
}
@ -74,7 +74,7 @@ class ChapterRepo
{
$trashCan = new TrashCan();
$trashCan->softDestroyChapter($chapter);
Activity::addForEntity($chapter, ActivityType::CHAPTER_DELETE);
Activity::add(ActivityType::CHAPTER_DELETE, $chapter);
$trashCan->autoClearOld();
}
@ -103,7 +103,7 @@ class ChapterRepo
$chapter->changeBook($parent->id);
$chapter->rebuildPermissions();
Activity::addForEntity($chapter, ActivityType::CHAPTER_MOVE);
Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
return $parent;
}

View File

@ -171,7 +171,7 @@ class PageRepo
$draft->indexForSearch();
$draft->refresh();
Activity::addForEntity($draft, ActivityType::PAGE_CREATE);
Activity::add(ActivityType::PAGE_CREATE, $draft);
return $draft;
}
@ -205,7 +205,7 @@ class PageRepo
$this->savePageRevision($page, $summary);
}
Activity::addForEntity($page, ActivityType::PAGE_UPDATE);
Activity::add(ActivityType::PAGE_UPDATE, $page);
return $page;
}
@ -281,7 +281,7 @@ class PageRepo
{
$trashCan = new TrashCan();
$trashCan->softDestroyPage($page);
Activity::addForEntity($page, ActivityType::PAGE_DELETE);
Activity::add(ActivityType::PAGE_DELETE, $page);
$trashCan->autoClearOld();
}
@ -312,7 +312,7 @@ class PageRepo
$summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]);
$this->savePageRevision($page, $summary);
Activity::addForEntity($page, ActivityType::PAGE_RESTORE);
Activity::add(ActivityType::PAGE_RESTORE, $page);
return $page;
}
@ -341,7 +341,7 @@ class PageRepo
$page->changeBook($newBookId);
$page->rebuildPermissions();
Activity::addForEntity($page, ActivityType::PAGE_MOVE);
Activity::add(ActivityType::PAGE_MOVE, $page);
return $parent;
}

View File

@ -35,7 +35,7 @@ class PermissionsUpdater
$entity->save();
$entity->rebuildPermissions();
Activity::addForEntity($entity, ActivityType::PERMISSIONS_UPDATE);
Activity::add(ActivityType::PERMISSIONS_UPDATE, $entity);
}
/**

View File

@ -2,11 +2,11 @@
namespace BookStack\Http\Controllers\Auth;
use Activity;
use BookStack\Auth\Access\LoginService;
use BookStack\Auth\Access\SocialAuthService;
use BookStack\Exceptions\LoginAttemptEmailNeededException;
use BookStack\Exceptions\LoginAttemptException;
use BookStack\Facades\Activity;
use BookStack\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;

View File

@ -3,6 +3,7 @@
namespace BookStack\Http\Controllers;
use Activity;
use BookStack\Actions\ActivityQueries;
use BookStack\Actions\ActivityType;
use BookStack\Actions\View;
use BookStack\Entities\Models\Bookshelf;
@ -101,7 +102,7 @@ class BookController extends Controller
if ($bookshelf) {
$bookshelf->appendBook($book);
Activity::addForEntity($bookshelf, ActivityType::BOOKSHELF_UPDATE);
Activity::add(ActivityType::BOOKSHELF_UPDATE, $bookshelf);
}
return redirect($book->getUrl());
@ -110,7 +111,7 @@ class BookController extends Controller
/**
* Display the specified book.
*/
public function show(Request $request, string $slug)
public function show(Request $request, ActivityQueries $activities, string $slug)
{
$book = $this->bookRepo->getBySlug($slug);
$bookChildren = (new BookContents($book))->getTree(true);
@ -128,7 +129,7 @@ class BookController extends Controller
'current' => $book,
'bookChildren' => $bookChildren,
'bookParentShelves' => $bookParentShelves,
'activity' => Activity::entityActivity($book, 20, 1),
'activity' => $activities->entityActivity($book, 20, 1),
]);
}

View File

@ -71,7 +71,7 @@ class BookSortController extends Controller
// Rebuild permissions and add activity for involved books.
$booksInvolved->each(function (Book $book) {
Activity::addForEntity($book, ActivityType::BOOK_SORT);
Activity::add(ActivityType::BOOK_SORT, $book);
});
return redirect($book->getUrl());

View File

@ -3,6 +3,7 @@
namespace BookStack\Http\Controllers;
use Activity;
use BookStack\Actions\ActivityQueries;
use BookStack\Actions\View;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Repos\BookshelfRepo;
@ -101,7 +102,7 @@ class BookshelfController extends Controller
*
* @throws NotFoundException
*/
public function show(string $slug)
public function show(ActivityQueries $activities, string $slug)
{
$shelf = $this->bookshelfRepo->getBySlug($slug);
$this->checkOwnablePermission('book-view', $shelf);
@ -124,7 +125,7 @@ class BookshelfController extends Controller
'shelf' => $shelf,
'sortedVisibleShelfBooks' => $sortedVisibleShelfBooks,
'view' => $view,
'activity' => Activity::entityActivity($shelf, 20, 1),
'activity' => $activities->entityActivity($shelf, 20, 1),
'order' => $order,
'sort' => $sort,
]);

View File

@ -2,7 +2,7 @@
namespace BookStack\Http\Controllers;
use Activity;
use BookStack\Actions\ActivityQueries;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\RecentlyViewed;
@ -16,9 +16,9 @@ class HomeController extends Controller
/**
* Display the homepage.
*/
public function index()
public function index(ActivityQueries $activities)
{
$activity = Activity::latest(10);
$activity = $activities->latest(10);
$draftPages = [];
if ($this->isSignedIn()) {

View File

@ -2,6 +2,7 @@
namespace BookStack\Http\Controllers;
use BookStack\Actions\ActivityQueries;
use BookStack\Auth\UserRepo;
class UserProfileController extends Controller
@ -9,11 +10,11 @@ class UserProfileController extends Controller
/**
* Show the user profile page.
*/
public function show(UserRepo $repo, string $slug)
public function show(UserRepo $repo, ActivityQueries $activities, string $slug)
{
$user = $repo->getBySlug($slug);
$userActivity = $repo->getActivity($user);
$userActivity = $activities->userActivity($user);
$recentlyCreated = $repo->getRecentlyCreated($user, 5);
$assetCounts = $repo->getAssetCounts($user);

View File

@ -2,7 +2,7 @@
namespace BookStack\Providers;
use BookStack\Actions\ActivityService;
use BookStack\Actions\ActivityLogger;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Theming\ThemeService;
use BookStack\Uploads\ImageService;
@ -28,7 +28,7 @@ class CustomFacadeProvider extends ServiceProvider
public function register()
{
$this->app->singleton('activity', function () {
return $this->app->make(ActivityService::class);
return $this->app->make(ActivityLogger::class);
});
$this->app->singleton('images', function () {

View File

@ -1,6 +1,6 @@
<?php
namespace Database\Factories;
namespace Database\Factories\Actions;
use BookStack\Actions\ActivityType;
use BookStack\Actions\Webhook;

View File

@ -3,7 +3,7 @@
namespace Tests\Actions;
use BookStack\Actions\Activity;
use BookStack\Actions\ActivityService;
use BookStack\Actions\ActivityLogger;
use BookStack\Actions\ActivityType;
use BookStack\Auth\UserRepo;
use BookStack\Entities\Models\Chapter;
@ -17,13 +17,13 @@ use function config;
class AuditLogTest extends TestCase
{
/** @var ActivityService */
/** @var ActivityLogger */
protected $activityService;
protected function setUp(): void
{
parent::setUp();
$this->activityService = app(ActivityService::class);
$this->activityService = app(ActivityLogger::class);
}
public function test_only_accessible_with_right_permissions()
@ -49,7 +49,7 @@ class AuditLogTest extends TestCase
$admin = $this->getAdmin();
$this->actingAs($admin);
$page = Page::query()->first();
$this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
$this->activityService->add(ActivityType::PAGE_CREATE, $page);
$activity = Activity::query()->orderBy('id', 'desc')->first();
$resp = $this->get('settings/audit');
@ -64,7 +64,7 @@ class AuditLogTest extends TestCase
$this->actingAs($this->getAdmin());
$page = Page::query()->first();
$pageName = $page->name;
$this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
$this->activityService->add(ActivityType::PAGE_CREATE, $page);
app(PageRepo::class)->destroy($page);
app(TrashCan::class)->empty();
@ -79,7 +79,7 @@ class AuditLogTest extends TestCase
$viewer = $this->getViewer();
$this->actingAs($viewer);
$page = Page::query()->first();
$this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
$this->activityService->add(ActivityType::PAGE_CREATE, $page);
$this->actingAs($this->getAdmin());
app(UserRepo::class)->destroy($viewer);
@ -92,7 +92,7 @@ class AuditLogTest extends TestCase
{
$this->actingAs($this->getAdmin());
$page = Page::query()->first();
$this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
$this->activityService->add(ActivityType::PAGE_CREATE, $page);
$resp = $this->get('settings/audit');
$resp->assertSeeText($page->name);
@ -105,7 +105,7 @@ class AuditLogTest extends TestCase
{
$this->actingAs($this->getAdmin());
$page = Page::query()->first();
$this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
$this->activityService->add(ActivityType::PAGE_CREATE, $page);
$yesterday = (Carbon::now()->subDay()->format('Y-m-d'));
$tomorrow = (Carbon::now()->addDay()->format('Y-m-d'));
@ -129,11 +129,11 @@ class AuditLogTest extends TestCase
$editor = $this->getEditor();
$this->actingAs($admin);
$page = Page::query()->first();
$this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
$this->activityService->add(ActivityType::PAGE_CREATE, $page);
$this->actingAs($editor);
$chapter = Chapter::query()->first();
$this->activityService->addForEntity($chapter, ActivityType::CHAPTER_UPDATE);
$this->activityService->add(ActivityType::CHAPTER_UPDATE, $chapter);
$resp = $this->actingAs($admin)->get('settings/audit?user=' . $admin->id);
$resp->assertSeeText($page->name);

View File

@ -4,6 +4,8 @@ namespace Tests\Commands;
use BookStack\Actions\ActivityType;
use BookStack\Entities\Models\Page;
use BookStack\Facades\Activity;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
@ -12,8 +14,9 @@ class ClearActivityCommandTest extends TestCase
public function test_clear_activity_command()
{
$this->asEditor();
$page = Page::first();
\Activity::addForEntity($page, ActivityType::PAGE_UPDATE);
/** @var Page $page */
$page = Page::query()->first();
Activity::add(ActivityType::PAGE_UPDATE, $page);
$this->assertDatabaseHas('activities', [
'type' => 'page_update',
@ -22,7 +25,7 @@ class ClearActivityCommandTest extends TestCase
]);
DB::rollBack();
$exitCode = \Artisan::call('bookstack:clear-activity');
$exitCode = Artisan::call('bookstack:clear-activity');
DB::beginTransaction();
$this->assertTrue($exitCode === 0, 'Command executed successfully');

View File

@ -64,8 +64,8 @@ class UserProfileTest extends TestCase
$newUser = User::factory()->create();
$this->actingAs($newUser);
$entities = $this->createEntityChainBelongingToUser($newUser, $newUser);
Activity::addForEntity($entities['book'], ActivityType::BOOK_UPDATE);
Activity::addForEntity($entities['page'], ActivityType::PAGE_CREATE);
Activity::add(ActivityType::BOOK_UPDATE, $entities['book']);
Activity::add(ActivityType::PAGE_CREATE, $entities['page']);
$this->asAdmin()->get('/user/' . $newUser->slug)
->assertElementContains('#recent-user-activity', 'updated book')
@ -78,8 +78,8 @@ class UserProfileTest extends TestCase
$newUser = User::factory()->create();
$this->actingAs($newUser);
$entities = $this->createEntityChainBelongingToUser($newUser, $newUser);
Activity::addForEntity($entities['book'], ActivityType::BOOK_UPDATE);
Activity::addForEntity($entities['page'], ActivityType::PAGE_CREATE);
Activity::add(ActivityType::BOOK_UPDATE, $entities['book']);
Activity::add(ActivityType::PAGE_CREATE, $entities['page']);
$linkSelector = '#recent-activity a[href$="/user/' . $newUser->slug . '"]';
$this->asAdmin()->get('/')