mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-17 17:28:02 +00:00
feat: allow guests to post/reply when given permissions
This commit is contained in:
@@ -81,6 +81,23 @@ class AdminController extends OCSController {
|
||||
$topContributorsAllTime = $this->forumUserMapper->getTopContributors(5);
|
||||
$topContributorsRecent = $this->forumUserMapper->getTopContributorsSince($weekAgo, 5);
|
||||
|
||||
// Enrich contributors with display names
|
||||
$allContributorIds = array_unique(array_merge(
|
||||
array_map(fn ($c) => $c['userId'], $topContributorsAllTime),
|
||||
array_map(fn ($c) => $c['userId'], $topContributorsRecent),
|
||||
));
|
||||
$enrichedUsers = $this->userService->enrichMultipleUsers($allContributorIds);
|
||||
|
||||
$enrichContributors = function (array $contributors) use ($enrichedUsers): array {
|
||||
return array_map(function ($c) use ($enrichedUsers) {
|
||||
$userData = $enrichedUsers[$c['userId']] ?? null;
|
||||
$c['displayName'] = $userData['displayName'] ?? $c['userId'];
|
||||
$c['isGuest'] = $userData['isGuest'] ?? false;
|
||||
$c['roles'] = $userData['roles'] ?? [];
|
||||
return $c;
|
||||
}, $contributors);
|
||||
};
|
||||
|
||||
return new DataResponse([
|
||||
'totals' => [
|
||||
'users' => $totalUsers,
|
||||
@@ -93,8 +110,8 @@ class AdminController extends OCSController {
|
||||
'threads' => $recentThreads,
|
||||
'posts' => $recentPosts,
|
||||
],
|
||||
'topContributorsAllTime' => $topContributorsAllTime,
|
||||
'topContributorsRecent' => $topContributorsRecent,
|
||||
'topContributorsAllTime' => $enrichContributors($topContributorsAllTime),
|
||||
'topContributorsRecent' => $enrichContributors($topContributorsRecent),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error fetching dashboard stats: ' . $e->getMessage());
|
||||
|
||||
59
lib/Controller/GuestController.php
Normal file
59
lib/Controller/GuestController.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Forum\Controller;
|
||||
|
||||
use OCA\Forum\Service\GuestService;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\Attribute\PublicPage;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\IRequest;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class GuestController extends OCSController {
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
private GuestService $guestService,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a guest identity
|
||||
*
|
||||
* @param string $guestToken 32-character hex token
|
||||
* @return DataResponse<Http::STATUS_OK, array{displayName: string, guestToken: string, isGuest: true}, array{}>
|
||||
*
|
||||
* 200: Guest identity returned
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[PublicPage]
|
||||
#[NoCSRFRequired]
|
||||
#[ApiRoute(verb: 'GET', url: '/api/guest/me')]
|
||||
public function me(string $guestToken): DataResponse {
|
||||
try {
|
||||
$session = $this->guestService->getOrCreateSession($guestToken);
|
||||
|
||||
return new DataResponse([
|
||||
'displayName' => $session->getDisplayName(),
|
||||
'guestToken' => $session->getSessionToken(),
|
||||
'isGuest' => true,
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error resolving guest identity: ' . $e->getMessage());
|
||||
return new DataResponse(['error' => 'Failed to resolve guest identity'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ use OCA\Forum\Db\ReadMarkerMapper;
|
||||
use OCA\Forum\Db\ThreadMapper;
|
||||
use OCA\Forum\Db\ThreadSubscriptionMapper;
|
||||
use OCA\Forum\Service\BBCodeService;
|
||||
use OCA\Forum\Service\GuestService;
|
||||
use OCA\Forum\Service\NotificationService;
|
||||
use OCA\Forum\Service\PermissionService;
|
||||
use OCA\Forum\Service\PostEnrichmentService;
|
||||
@@ -27,6 +28,7 @@ use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\Attribute\PublicPage;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\OCSController;
|
||||
@@ -53,6 +55,7 @@ class PostController extends OCSController {
|
||||
private UserService $userService,
|
||||
private UserPreferencesService $userPreferencesService,
|
||||
private ThreadSubscriptionMapper $threadSubscriptionMapper,
|
||||
private GuestService $guestService,
|
||||
private IUserSession $userSession,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
@@ -319,23 +322,36 @@ class PostController extends OCSController {
|
||||
*
|
||||
* @param int $threadId Thread ID
|
||||
* @param string $content Post content
|
||||
* @param string $guestToken Guest session token (32-char hex, for unauthenticated users)
|
||||
* @return DataResponse<Http::STATUS_CREATED, array<string, mixed>, array{}>
|
||||
*
|
||||
* 201: Post created
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[PublicPage]
|
||||
#[NoCSRFRequired]
|
||||
#[RequirePermission('canReply', resourceType: 'category', resourceIdFromThreadId: 'threadId')]
|
||||
#[ApiRoute(verb: 'POST', url: '/api/posts')]
|
||||
public function create(int $threadId, string $content): DataResponse {
|
||||
public function create(int $threadId, string $content, string $guestToken = ''): DataResponse {
|
||||
try {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
|
||||
// Resolve author identity
|
||||
if ($user) {
|
||||
$authorId = $user->getUID();
|
||||
} elseif ($guestToken !== '') {
|
||||
try {
|
||||
$authorId = $this->guestService->resolveGuestIdentity($guestToken);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
} else {
|
||||
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$post = new \OCA\Forum\Db\Post();
|
||||
$post->setThreadId($threadId);
|
||||
$post->setAuthorId($user->getUID());
|
||||
$post->setAuthorId($authorId);
|
||||
$post->setContent($content);
|
||||
$post->setIsEdited(false);
|
||||
$post->setIsFirstPost(false);
|
||||
@@ -345,16 +361,39 @@ class PostController extends OCSController {
|
||||
/** @var \OCA\Forum\Db\Post */
|
||||
$createdPost = $this->postMapper->insert($post);
|
||||
|
||||
// Mark thread as read up to and including the new post
|
||||
try {
|
||||
$this->readMarkerMapper->createOrUpdate(
|
||||
$user->getUID(),
|
||||
$threadId,
|
||||
$createdPost->getId()
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to update read marker after creating post: ' . $e->getMessage());
|
||||
// Don't fail the request if read marker update fails
|
||||
// User-only operations (read markers, forum user stats, auto-subscribe)
|
||||
if ($user) {
|
||||
// Mark thread as read up to and including the new post
|
||||
try {
|
||||
$this->readMarkerMapper->createOrUpdate(
|
||||
$user->getUID(),
|
||||
$threadId,
|
||||
$createdPost->getId()
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to update read marker after creating post: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Update forum user post count (auto-creates forum user if needed)
|
||||
try {
|
||||
$this->forumUserMapper->incrementPostCount($user->getUID());
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to update forum user post count: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Auto-subscribe the user to the thread if preference is enabled and not already subscribed
|
||||
try {
|
||||
$autoSubscribe = $this->userPreferencesService->getPreference(
|
||||
$user->getUID(),
|
||||
UserPreferencesService::PREF_AUTO_SUBSCRIBE_REPLIED_THREADS
|
||||
);
|
||||
|
||||
if ($autoSubscribe && !$this->threadSubscriptionMapper->isUserSubscribed($user->getUID(), $threadId)) {
|
||||
$this->threadSubscriptionMapper->subscribe($user->getUID(), $threadId);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to auto-subscribe user to thread: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Update the thread's post count and timestamps
|
||||
@@ -366,15 +405,6 @@ class PostController extends OCSController {
|
||||
$this->threadMapper->update($thread);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to update thread post count: ' . $e->getMessage());
|
||||
// Don't fail the request if thread update fails
|
||||
}
|
||||
|
||||
// Update forum user post count (auto-creates forum user if needed)
|
||||
try {
|
||||
$this->forumUserMapper->incrementPostCount($user->getUID());
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to update forum user post count: ' . $e->getMessage());
|
||||
// Don't fail the request if forum user update fails
|
||||
}
|
||||
|
||||
// Update the category's post count
|
||||
@@ -384,39 +414,21 @@ class PostController extends OCSController {
|
||||
$this->categoryMapper->update($category);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to update category post count: ' . $e->getMessage());
|
||||
// Don't fail the request if category update fails
|
||||
}
|
||||
|
||||
// Notify registered users about the new post
|
||||
try {
|
||||
$this->notificationService->notifyThreadSubscribers($threadId, $createdPost->getId(), $user->getUID());
|
||||
$this->notificationService->notifyThreadSubscribers($threadId, $createdPost->getId(), $authorId);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to send notifications for new post: ' . $e->getMessage());
|
||||
// Don't fail the request if notification sending fails
|
||||
}
|
||||
|
||||
// Notify mentioned users
|
||||
try {
|
||||
$mentionedUsers = $this->notificationService->extractMentions($content);
|
||||
$this->notificationService->notifyMentionedUsers($createdPost->getId(), $threadId, $user->getUID(), $mentionedUsers);
|
||||
$this->notificationService->notifyMentionedUsers($createdPost->getId(), $threadId, $authorId, $mentionedUsers);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to send mention notifications: ' . $e->getMessage());
|
||||
// Don't fail the request if mention notification sending fails
|
||||
}
|
||||
|
||||
// Auto-subscribe the user to the thread if preference is enabled and not already subscribed
|
||||
try {
|
||||
$autoSubscribe = $this->userPreferencesService->getPreference(
|
||||
$user->getUID(),
|
||||
UserPreferencesService::PREF_AUTO_SUBSCRIBE_REPLIED_THREADS
|
||||
);
|
||||
|
||||
if ($autoSubscribe && !$this->threadSubscriptionMapper->isUserSubscribed($user->getUID(), $threadId)) {
|
||||
$this->threadSubscriptionMapper->subscribe($user->getUID(), $threadId);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to auto-subscribe user to thread: ' . $e->getMessage());
|
||||
// Don't fail the request if auto-subscribe fails
|
||||
}
|
||||
|
||||
return new DataResponse($this->postEnrichmentService->enrichPost($createdPost), Http::STATUS_CREATED);
|
||||
|
||||
@@ -17,6 +17,7 @@ use OCA\Forum\Db\ReadMarkerMapper;
|
||||
use OCA\Forum\Db\Thread;
|
||||
use OCA\Forum\Db\ThreadMapper;
|
||||
use OCA\Forum\Db\ThreadSubscriptionMapper;
|
||||
use OCA\Forum\Service\GuestService;
|
||||
use OCA\Forum\Service\NotificationService;
|
||||
use OCA\Forum\Service\PermissionService;
|
||||
use OCA\Forum\Service\ThreadEnrichmentService;
|
||||
@@ -26,6 +27,7 @@ use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\Attribute\PublicPage;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\OCSController;
|
||||
@@ -49,6 +51,7 @@ class ThreadController extends OCSController {
|
||||
private UserService $userService,
|
||||
private PermissionService $permissionService,
|
||||
private NotificationService $notificationService,
|
||||
private GuestService $guestService,
|
||||
private IUserSession $userSession,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
@@ -275,35 +278,50 @@ class ThreadController extends OCSController {
|
||||
* @param int $categoryId Category ID
|
||||
* @param string $title Thread title
|
||||
* @param string $content Initial post content
|
||||
* @param string $guestToken Guest session token (32-char hex, for unauthenticated users)
|
||||
* @return DataResponse<Http::STATUS_CREATED, array<string, mixed>, array{}>
|
||||
*
|
||||
* 201: Thread created
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[PublicPage]
|
||||
#[NoCSRFRequired]
|
||||
#[RequirePermission('canPost', resourceType: 'category', resourceIdBody: 'categoryId')]
|
||||
#[ApiRoute(verb: 'POST', url: '/api/threads')]
|
||||
public function create(int $categoryId, string $title, string $content): DataResponse {
|
||||
public function create(int $categoryId, string $title, string $content, string $guestToken = ''): DataResponse {
|
||||
try {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
|
||||
// Resolve author identity
|
||||
if ($user) {
|
||||
$authorId = $user->getUID();
|
||||
} elseif ($guestToken !== '') {
|
||||
try {
|
||||
$authorId = $this->guestService->resolveGuestIdentity($guestToken);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
} else {
|
||||
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Check if the category was already read before creating the thread
|
||||
// (used later to decide whether to update the category read marker)
|
||||
$wasCategoryRead = false;
|
||||
try {
|
||||
$lastActivity = $this->threadMapper->getLastActivityForCategory($categoryId);
|
||||
if ($lastActivity === null) {
|
||||
$wasCategoryRead = true;
|
||||
} else {
|
||||
$marker = $this->readMarkerMapper->findByUserAndCategory($user->getUID(), $categoryId);
|
||||
$wasCategoryRead = $marker->getReadAt() >= $lastActivity;
|
||||
if ($user) {
|
||||
try {
|
||||
$lastActivity = $this->threadMapper->getLastActivityForCategory($categoryId);
|
||||
if ($lastActivity === null) {
|
||||
$wasCategoryRead = true;
|
||||
} else {
|
||||
$marker = $this->readMarkerMapper->findByUserAndCategory($user->getUID(), $categoryId);
|
||||
$wasCategoryRead = $marker->getReadAt() >= $lastActivity;
|
||||
}
|
||||
} catch (DoesNotExistException $e) {
|
||||
// No read marker means the category is unread
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to check category read state: ' . $e->getMessage());
|
||||
}
|
||||
} catch (DoesNotExistException $e) {
|
||||
// No read marker means the category is unread
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to check category read state: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Generate slug from title
|
||||
@@ -314,7 +332,7 @@ class ThreadController extends OCSController {
|
||||
|
||||
$thread = new \OCA\Forum\Db\Thread();
|
||||
$thread->setCategoryId($categoryId);
|
||||
$thread->setAuthorId($user->getUID());
|
||||
$thread->setAuthorId($authorId);
|
||||
$thread->setTitle($title);
|
||||
$thread->setSlug($slug);
|
||||
$thread->setViewCount(0);
|
||||
@@ -331,7 +349,7 @@ class ThreadController extends OCSController {
|
||||
// Create the initial post
|
||||
$post = new \OCA\Forum\Db\Post();
|
||||
$post->setThreadId($createdThread->getId());
|
||||
$post->setAuthorId($user->getUID());
|
||||
$post->setAuthorId($authorId);
|
||||
$post->setContent($content);
|
||||
$post->setIsEdited(false);
|
||||
$post->setIsFirstPost(true);
|
||||
@@ -356,57 +374,60 @@ class ThreadController extends OCSController {
|
||||
$this->logger->warning('Failed to update category counts: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Update forum user (thread count only, first post doesn't count)
|
||||
try {
|
||||
$this->forumUserMapper->incrementThreadCount($user->getUID());
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to update forum user: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Auto-subscribe the thread creator to receive notifications (if preference is enabled)
|
||||
try {
|
||||
$autoSubscribe = $this->userPreferencesService->getPreference(
|
||||
$user->getUID(),
|
||||
UserPreferencesService::PREF_AUTO_SUBSCRIBE_CREATED_THREADS
|
||||
);
|
||||
|
||||
if ($autoSubscribe) {
|
||||
$this->threadSubscriptionMapper->subscribe($user->getUID(), $createdThread->getId());
|
||||
// User-only operations (forum user stats, subscriptions, read markers, drafts)
|
||||
if ($user) {
|
||||
// Update forum user (thread count only, first post doesn't count)
|
||||
try {
|
||||
$this->forumUserMapper->incrementThreadCount($user->getUID());
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to update forum user: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Auto-subscribe the thread creator to receive notifications (if preference is enabled)
|
||||
try {
|
||||
$autoSubscribe = $this->userPreferencesService->getPreference(
|
||||
$user->getUID(),
|
||||
UserPreferencesService::PREF_AUTO_SUBSCRIBE_CREATED_THREADS
|
||||
);
|
||||
|
||||
if ($autoSubscribe) {
|
||||
$this->threadSubscriptionMapper->subscribe($user->getUID(), $createdThread->getId());
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to subscribe thread creator: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Update category read marker so the category stays read,
|
||||
// but only if it was not already unread before this thread was created
|
||||
if ($wasCategoryRead) {
|
||||
try {
|
||||
$this->readMarkerMapper->createOrUpdateCategoryMarker($user->getUID(), $categoryId);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to update category read marker: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Delete any draft for this category now that the thread is created
|
||||
try {
|
||||
$this->draftMapper->deleteThreadDraft($user->getUID(), $categoryId);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to delete thread draft: ' . $e->getMessage());
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to subscribe thread creator: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Notify mentioned users in the initial post
|
||||
// Notify mentioned users in the initial post (works for both guest and authenticated posts)
|
||||
try {
|
||||
$mentionedUsers = $this->notificationService->extractMentions($content);
|
||||
$this->notificationService->notifyMentionedUsers(
|
||||
$createdPost->getId(),
|
||||
$createdThread->getId(),
|
||||
$user->getUID(),
|
||||
$authorId,
|
||||
$mentionedUsers
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to send mention notifications: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Update category read marker so the category stays read,
|
||||
// but only if it was not already unread before this thread was created
|
||||
if ($wasCategoryRead) {
|
||||
try {
|
||||
$this->readMarkerMapper->createOrUpdateCategoryMarker($user->getUID(), $categoryId);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to update category read marker: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Delete any draft for this category now that the thread is created
|
||||
try {
|
||||
$this->draftMapper->deleteThreadDraft($user->getUID(), $categoryId);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to delete thread draft: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return new DataResponse($createdThread->jsonSerialize(), Http::STATUS_CREATED);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error creating thread: ' . $e->getMessage());
|
||||
|
||||
@@ -8,6 +8,7 @@ declare(strict_types=1);
|
||||
namespace OCA\Forum\Dashboard;
|
||||
|
||||
use OCA\Forum\AppInfo\Application;
|
||||
use OCA\Forum\Service\UserService;
|
||||
use OCP\Dashboard\IAPIWidgetV2;
|
||||
use OCP\Dashboard\IButtonWidget;
|
||||
use OCP\Dashboard\IIconWidget;
|
||||
@@ -22,6 +23,7 @@ class RecentActivityWidget implements IAPIWidgetV2, IIconWidget, IButtonWidget {
|
||||
private IL10N $l,
|
||||
private IURLGenerator $urlGenerator,
|
||||
private WidgetService $widgetService,
|
||||
private UserService $userService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -66,6 +68,18 @@ class RecentActivityWidget implements IAPIWidgetV2, IIconWidget, IButtonWidget {
|
||||
public function getItemsV2(string $userId, ?string $since = null, int $limit = 7): WidgetItems {
|
||||
$activity = $this->widgetService->getRecentActivity($userId, $limit);
|
||||
|
||||
// Collect all author IDs and resolve display names in batch
|
||||
$authorIds = [];
|
||||
foreach ($activity as $entry) {
|
||||
if ($entry['type'] === 'thread' && $entry['thread'] !== null) {
|
||||
$authorIds[] = $entry['thread']->getAuthorId();
|
||||
} elseif ($entry['type'] === 'reply') {
|
||||
$authorIds[] = $entry['item']->getAuthorId();
|
||||
}
|
||||
}
|
||||
$authorIds = array_unique($authorIds);
|
||||
$enrichedAuthors = !empty($authorIds) ? $this->userService->enrichMultipleUsers($authorIds) : [];
|
||||
|
||||
$items = [];
|
||||
foreach ($activity as $entry) {
|
||||
$thread = $entry['thread'];
|
||||
@@ -78,10 +92,22 @@ class RecentActivityWidget implements IAPIWidgetV2, IIconWidget, IButtonWidget {
|
||||
$sinceId = (string)$entry['createdAt'];
|
||||
|
||||
if ($entry['type'] === 'thread') {
|
||||
$subtitle = $this->l->t('New thread by %1$s', [$thread->getAuthorId()]);
|
||||
$authorId = $thread->getAuthorId();
|
||||
$authorData = $enrichedAuthors[$authorId] ?? null;
|
||||
$displayName = $authorData['displayName'] ?? $authorId;
|
||||
if (!empty($authorData['isGuest'])) {
|
||||
$displayName = $this->l->t('%1$s (Guest)', [$displayName]);
|
||||
}
|
||||
$subtitle = $this->l->t('New thread by %1$s', [$displayName]);
|
||||
} else {
|
||||
$post = $entry['item'];
|
||||
$subtitle = $this->l->t('Reply by %1$s', [$post->getAuthorId()]);
|
||||
$authorId = $post->getAuthorId();
|
||||
$authorData = $enrichedAuthors[$authorId] ?? null;
|
||||
$displayName = $authorData['displayName'] ?? $authorId;
|
||||
if (!empty($authorData['isGuest'])) {
|
||||
$displayName = $this->l->t('%1$s (Guest)', [$displayName]);
|
||||
}
|
||||
$subtitle = $this->l->t('Reply by %1$s', [$displayName]);
|
||||
}
|
||||
|
||||
$items[] = new WidgetItem(
|
||||
|
||||
43
lib/Db/GuestSession.php
Normal file
43
lib/Db/GuestSession.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Forum\Db;
|
||||
|
||||
use JsonSerializable;
|
||||
use OCP\AppFramework\Db\Entity;
|
||||
|
||||
/**
|
||||
* @method int getId()
|
||||
* @method void setId(int $value)
|
||||
* @method string getSessionToken()
|
||||
* @method void setSessionToken(string $value)
|
||||
* @method string getDisplayName()
|
||||
* @method void setDisplayName(string $value)
|
||||
* @method int getCreatedAt()
|
||||
* @method void setCreatedAt(int $value)
|
||||
*/
|
||||
class GuestSession extends Entity implements JsonSerializable {
|
||||
protected $sessionToken;
|
||||
protected $displayName;
|
||||
protected $createdAt;
|
||||
|
||||
public function __construct() {
|
||||
$this->addType('id', 'integer');
|
||||
$this->addType('sessionToken', 'string');
|
||||
$this->addType('displayName', 'string');
|
||||
$this->addType('createdAt', 'integer');
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
return [
|
||||
'id' => $this->getId(),
|
||||
'sessionToken' => $this->getSessionToken(),
|
||||
'displayName' => $this->getDisplayName(),
|
||||
'createdAt' => $this->getCreatedAt(),
|
||||
];
|
||||
}
|
||||
}
|
||||
55
lib/Db/GuestSessionMapper.php
Normal file
55
lib/Db/GuestSessionMapper.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Forum\Db;
|
||||
|
||||
use OCA\Forum\AppInfo\Application;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/**
|
||||
* @template-extends QBMapper<GuestSession>
|
||||
*/
|
||||
class GuestSessionMapper extends QBMapper {
|
||||
public function __construct(
|
||||
IDBConnection $db,
|
||||
) {
|
||||
parent::__construct($db, Application::tableName('forum_guest_sessions'), GuestSession::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
||||
* @throws DoesNotExistException
|
||||
*/
|
||||
public function findByToken(string $token): GuestSession {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where(
|
||||
$qb->expr()->eq('session_token', $qb->createNamedParameter($token, IQueryBuilder::PARAM_STR))
|
||||
);
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a display name already exists
|
||||
*/
|
||||
public function displayNameExists(string $displayName): bool {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select($qb->func()->count('*', 'cnt'))
|
||||
->from($this->getTableName())
|
||||
->where(
|
||||
$qb->expr()->eq('display_name', $qb->createNamedParameter($displayName, IQueryBuilder::PARAM_STR))
|
||||
);
|
||||
$result = $qb->executeQuery();
|
||||
$count = (int)$result->fetchOne();
|
||||
$result->closeCursor();
|
||||
return $count > 0;
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ class PermissionMiddleware extends Middleware {
|
||||
// If user is not authenticated
|
||||
if (!$user) {
|
||||
// Allow unauthenticated access for public pages or when guest access is enabled for read-only
|
||||
$allowUnauthenticated = $isPublicPage || ($guestAccessEnabled && $isReadOnlyMethod);
|
||||
$allowUnauthenticated = $isPublicPage || $guestAccessEnabled;
|
||||
|
||||
if ($allowUnauthenticated) {
|
||||
// Check if there are permission requirements - if so, check guest permissions
|
||||
|
||||
54
lib/Migration/Version24Date20260317000000.php
Normal file
54
lib/Migration/Version24Date20260317000000.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Forum\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\DB\Types;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
class Version24Date20260317000000 extends SimpleMigrationStep {
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
* @param array $options
|
||||
* @return null|ISchemaWrapper
|
||||
*/
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
if (!$schema->hasTable('forum_guest_sessions')) {
|
||||
$table = $schema->createTable('forum_guest_sessions');
|
||||
|
||||
$table->addColumn('id', Types::BIGINT, [
|
||||
'autoincrement' => true,
|
||||
'notnull' => true,
|
||||
'unsigned' => true,
|
||||
]);
|
||||
$table->addColumn('session_token', Types::STRING, [
|
||||
'notnull' => true,
|
||||
'length' => 64,
|
||||
]);
|
||||
$table->addColumn('display_name', Types::STRING, [
|
||||
'notnull' => true,
|
||||
'length' => 64,
|
||||
]);
|
||||
$table->addColumn('created_at', Types::BIGINT, [
|
||||
'notnull' => true,
|
||||
'unsigned' => true,
|
||||
]);
|
||||
|
||||
$table->setPrimaryKey(['id']);
|
||||
$table->addUniqueIndex(['session_token'], 'forum_guest_sess_token_idx');
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
}
|
||||
164
lib/Service/GuestService.php
Normal file
164
lib/Service/GuestService.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Forum\Service;
|
||||
|
||||
use OCA\Forum\Db\GuestSession;
|
||||
use OCA\Forum\Db\GuestSessionMapper;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
|
||||
class GuestService {
|
||||
private const ADJECTIVES = [
|
||||
'Bright', 'Swift', 'Calm', 'Bold', 'Keen',
|
||||
'Wise', 'Fair', 'Warm', 'Cool', 'Pure',
|
||||
'Sharp', 'Brave', 'Clear', 'Quick', 'Glad',
|
||||
'Kind', 'Free', 'Noble', 'Proud', 'True',
|
||||
'Lucky', 'Merry', 'Jolly', 'Lively', 'Gentle',
|
||||
'Vivid', 'Quiet', 'Eager', 'Happy', 'Witty',
|
||||
'Daring', 'Clever', 'Humble', 'Cosmic', 'Sunny',
|
||||
'Golden', 'Silver', 'Crystal', 'Mystic', 'Velvet',
|
||||
'Amber', 'Azure', 'Coral', 'Ivory', 'Jade',
|
||||
'Ruby', 'Sage', 'Teal', 'Rustic', 'Nimble',
|
||||
'Polar', 'Lunar', 'Solar', 'Stellar', 'Radiant',
|
||||
'Serene', 'Dapper', 'Frosty', 'Mossy', 'Dusty',
|
||||
'Misty', 'Breezy', 'Stormy', 'Snowy', 'Rainy',
|
||||
'Zesty', 'Peppy', 'Perky', 'Chipper', 'Plucky',
|
||||
'Hardy', 'Sturdy', 'Steady', 'Loyal', 'Fierce',
|
||||
'Savvy', 'Crafty', 'Nifty', 'Handy', 'Zippy',
|
||||
'Glossy', 'Sleek', 'Crisp', 'Fresh', 'Rosy',
|
||||
'Dusky', 'Ashen', 'Oaken', 'Marble', 'Pewter',
|
||||
'Copper', 'Bronze', 'Cobalt', 'Scarlet', 'Indigo',
|
||||
];
|
||||
|
||||
private const NOUNS = [
|
||||
'Mountain', 'River', 'Forest', 'Meadow', 'Ocean',
|
||||
'Valley', 'Desert', 'Island', 'Canyon', 'Prairie',
|
||||
'Falcon', 'Eagle', 'Raven', 'Phoenix', 'Tiger',
|
||||
'Dolphin', 'Panther', 'Wolf', 'Fox', 'Hawk',
|
||||
'Storm', 'Thunder', 'Breeze', 'Frost', 'Aurora',
|
||||
'Comet', 'Star', 'Nebula', 'Galaxy', 'Cosmos',
|
||||
'Oak', 'Cedar', 'Willow', 'Maple', 'Birch',
|
||||
'Spark', 'Flame', 'Wave', 'Stone', 'Crystal',
|
||||
'Arrow', 'Shield', 'Crown', 'Compass', 'Lantern',
|
||||
'Harbor', 'Summit', 'Ridge', 'Cliff', 'Shore',
|
||||
'Heron', 'Otter', 'Panda', 'Lynx', 'Crane',
|
||||
'Badger', 'Osprey', 'Wren', 'Finch', 'Condor',
|
||||
'Glacier', 'Lagoon', 'Tundra', 'Steppe', 'Reef',
|
||||
'Plateau', 'Ravine', 'Basin', 'Delta', 'Fjord',
|
||||
'Pebble', 'Ember', 'Geyser', 'Prism', 'Quartz',
|
||||
'Beacon', 'Anchor', 'Vessel', 'Scepter', 'Banner',
|
||||
'Thistle', 'Clover', 'Orchid', 'Lotus', 'Fern',
|
||||
'Ivy', 'Aspen', 'Cypress', 'Spruce', 'Sequoia',
|
||||
'Dusk', 'Dawn', 'Zenith', 'Horizon', 'Eclipse',
|
||||
'Cascade', 'Torrent', 'Zephyr', 'Tempest', 'Monsoon',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private GuestSessionMapper $guestSessionMapper,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a guest token to a guest author ID.
|
||||
* Creates a new guest session if the token does not exist yet.
|
||||
*
|
||||
* @param string $guestToken 32-character hex token
|
||||
* @return string Author ID in the format "guest:<token>"
|
||||
* @throws \InvalidArgumentException If token is invalid
|
||||
*/
|
||||
public function resolveGuestIdentity(string $guestToken): string {
|
||||
// Validate token format: 32 hex characters
|
||||
if (!preg_match('/^[0-9a-f]{32}$/', $guestToken)) {
|
||||
throw new \InvalidArgumentException('Invalid guest token format');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->guestSessionMapper->findByToken($guestToken);
|
||||
} catch (DoesNotExistException $e) {
|
||||
// Create new guest session
|
||||
$session = new GuestSession();
|
||||
$session->setSessionToken($guestToken);
|
||||
$session->setDisplayName($this->generateGuestName());
|
||||
$session->setCreatedAt(time());
|
||||
$this->guestSessionMapper->insert($session);
|
||||
}
|
||||
|
||||
return 'guest:' . $guestToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display name for a guest author ID
|
||||
*
|
||||
* @param string $authorId Author ID in the format "guest:<token>"
|
||||
* @return string|null Display name, or null if not found
|
||||
*/
|
||||
public function getGuestDisplayName(string $authorId): ?string {
|
||||
if (!self::isGuestAuthor($authorId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$token = substr($authorId, 6); // Remove "guest:" prefix
|
||||
try {
|
||||
$session = $this->guestSessionMapper->findByToken($token);
|
||||
return $session->getDisplayName();
|
||||
} catch (DoesNotExistException $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get guest session by token, creating one if it does not exist
|
||||
*
|
||||
* @param string $guestToken 32-character hex token
|
||||
* @return GuestSession
|
||||
* @throws \InvalidArgumentException If token is invalid
|
||||
*/
|
||||
public function getOrCreateSession(string $guestToken): GuestSession {
|
||||
if (!preg_match('/^[0-9a-f]{32}$/', $guestToken)) {
|
||||
throw new \InvalidArgumentException('Invalid guest token format');
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->guestSessionMapper->findByToken($guestToken);
|
||||
} catch (DoesNotExistException $e) {
|
||||
$session = new GuestSession();
|
||||
$session->setSessionToken($guestToken);
|
||||
$session->setDisplayName($this->generateGuestName());
|
||||
$session->setCreatedAt(time());
|
||||
/** @var GuestSession */
|
||||
return $this->guestSessionMapper->insert($session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an author ID represents a guest user
|
||||
*/
|
||||
public static function isGuestAuthor(string $authorId): bool {
|
||||
return str_starts_with($authorId, 'guest:');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique guest display name
|
||||
* Format: AdjectiveNounNN (e.g., "BrightMountain42")
|
||||
*/
|
||||
private function generateGuestName(): string {
|
||||
$maxAttempts = 20;
|
||||
for ($i = 0; $i < $maxAttempts; $i++) {
|
||||
$adjective = self::ADJECTIVES[array_rand(self::ADJECTIVES)];
|
||||
$noun = self::NOUNS[array_rand(self::NOUNS)];
|
||||
$number = str_pad((string)random_int(0, 99), 2, '0', STR_PAD_LEFT);
|
||||
$name = $adjective . $noun . $number;
|
||||
|
||||
if (!$this->guestSessionMapper->displayNameExists($name)) {
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use timestamp-based name
|
||||
return 'Guest' . dechex(time()) . str_pad((string)random_int(0, 99), 2, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ namespace OCA\Forum\Service;
|
||||
|
||||
use OCA\Forum\Db\BBCodeMapper;
|
||||
use OCA\Forum\Db\ForumUserMapper;
|
||||
use OCA\Forum\Db\Role;
|
||||
use OCA\Forum\Db\RoleMapper;
|
||||
use OCA\Forum\Db\UserRoleMapper;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
@@ -27,6 +28,7 @@ class UserService {
|
||||
private UserRoleMapper $userRoleMapper,
|
||||
private BBCodeMapper $bbCodeMapper,
|
||||
private BBCodeService $bbCodeService,
|
||||
private GuestService $guestService,
|
||||
private IL10N $l10n,
|
||||
) {
|
||||
}
|
||||
@@ -75,6 +77,26 @@ class UserService {
|
||||
* @return array{userId: string, displayName: string, isDeleted: bool, roles: array, signature: ?string, signatureRaw: ?string}
|
||||
*/
|
||||
public function enrichUserData(string $userId, ?array $roles = null, ?array $bbcodes = null): array {
|
||||
// Handle guest authors
|
||||
if (GuestService::isGuestAuthor($userId)) {
|
||||
$guestDisplayName = $this->guestService->getGuestDisplayName($userId) ?? $this->l10n->t('Guest');
|
||||
try {
|
||||
$guestRole = $this->roleMapper->findByRoleType(Role::ROLE_TYPE_GUEST);
|
||||
$guestRoles = [$guestRole->jsonSerialize()];
|
||||
} catch (\Exception $e) {
|
||||
$guestRoles = [];
|
||||
}
|
||||
return [
|
||||
'userId' => $userId,
|
||||
'displayName' => $guestDisplayName,
|
||||
'isDeleted' => false,
|
||||
'isGuest' => true,
|
||||
'roles' => $guestRoles,
|
||||
'signature' => null,
|
||||
'signatureRaw' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$isDeleted = $this->isUserDeleted($userId);
|
||||
$displayName = $this->getUserDisplayName($userId);
|
||||
|
||||
@@ -130,38 +152,77 @@ class UserService {
|
||||
public function enrichMultipleUsers(array $userIds, ?array $rolesMap = null, ?array $bbcodes = null): array {
|
||||
$result = [];
|
||||
|
||||
// If roles not provided, fetch them all at once
|
||||
if ($rolesMap === null) {
|
||||
$rolesMap = $this->fetchRolesForUsers($userIds);
|
||||
}
|
||||
|
||||
// Fetch all forum users at once for signatures
|
||||
$signaturesMap = $this->fetchSignaturesForUsers($userIds);
|
||||
|
||||
// Fetch BBCodes once for parsing all signatures (if not provided)
|
||||
if ($bbcodes === null) {
|
||||
$bbcodes = $this->bbCodeMapper->findAllEnabled();
|
||||
}
|
||||
|
||||
// Separate guest and real user IDs
|
||||
$guestIds = [];
|
||||
$realUserIds = [];
|
||||
foreach ($userIds as $userId) {
|
||||
$isDeleted = $this->isUserDeleted($userId);
|
||||
$displayName = $this->getUserDisplayName($userId);
|
||||
if (GuestService::isGuestAuthor($userId)) {
|
||||
$guestIds[] = $userId;
|
||||
} else {
|
||||
$realUserIds[] = $userId;
|
||||
}
|
||||
}
|
||||
|
||||
$signatureRaw = $signaturesMap[$userId] ?? null;
|
||||
$signature = null;
|
||||
if ($signatureRaw !== null && $signatureRaw !== '') {
|
||||
$signature = $this->bbCodeService->parse($signatureRaw, $bbcodes);
|
||||
// Handle guest users
|
||||
if (!empty($guestIds)) {
|
||||
$guestRoles = [];
|
||||
try {
|
||||
$guestRole = $this->roleMapper->findByRoleType(Role::ROLE_TYPE_GUEST);
|
||||
$guestRoles = [$guestRole->jsonSerialize()];
|
||||
} catch (\Exception $e) {
|
||||
// Guest role not found
|
||||
}
|
||||
|
||||
$result[$userId] = [
|
||||
'userId' => $userId,
|
||||
'displayName' => $displayName,
|
||||
'isDeleted' => $isDeleted,
|
||||
'roles' => $rolesMap[$userId] ?? [],
|
||||
'signature' => $signature,
|
||||
'signatureRaw' => $signatureRaw,
|
||||
];
|
||||
foreach ($guestIds as $guestId) {
|
||||
$guestDisplayName = $this->guestService->getGuestDisplayName($guestId) ?? $this->l10n->t('Guest');
|
||||
$result[$guestId] = [
|
||||
'userId' => $guestId,
|
||||
'displayName' => $guestDisplayName,
|
||||
'isDeleted' => false,
|
||||
'isGuest' => true,
|
||||
'roles' => $guestRoles,
|
||||
'signature' => null,
|
||||
'signatureRaw' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Handle real users
|
||||
if (!empty($realUserIds)) {
|
||||
// If roles not provided, fetch them all at once
|
||||
if ($rolesMap === null) {
|
||||
$rolesMap = $this->fetchRolesForUsers($realUserIds);
|
||||
}
|
||||
|
||||
// Fetch all forum users at once for signatures
|
||||
$signaturesMap = $this->fetchSignaturesForUsers($realUserIds);
|
||||
|
||||
// Fetch BBCodes once for parsing all signatures (if not provided)
|
||||
if ($bbcodes === null) {
|
||||
$bbcodes = $this->bbCodeMapper->findAllEnabled();
|
||||
}
|
||||
|
||||
foreach ($realUserIds as $userId) {
|
||||
$isDeleted = $this->isUserDeleted($userId);
|
||||
$displayName = $this->getUserDisplayName($userId);
|
||||
|
||||
$signatureRaw = $signaturesMap[$userId] ?? null;
|
||||
$signature = null;
|
||||
if ($signatureRaw !== null && $signatureRaw !== '') {
|
||||
$signature = $this->bbCodeService->parse($signatureRaw, $bbcodes);
|
||||
}
|
||||
|
||||
$result[$userId] = [
|
||||
'userId' => $userId,
|
||||
'displayName' => $displayName,
|
||||
'isDeleted' => $isDeleted,
|
||||
'roles' => $rolesMap[$userId] ?? [],
|
||||
'signature' => $signature,
|
||||
'signatureRaw' => $signatureRaw,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
@@ -4840,6 +4840,96 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/forum/api/guest/me": {
|
||||
"get": {
|
||||
"operationId": "guest-me",
|
||||
"summary": "Get or create a guest identity",
|
||||
"tags": [
|
||||
"guest"
|
||||
],
|
||||
"security": [
|
||||
{},
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "guestToken",
|
||||
"in": "query",
|
||||
"description": "32-character hex token",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Guest identity returned",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"displayName",
|
||||
"guestToken",
|
||||
"isGuest"
|
||||
],
|
||||
"properties": {
|
||||
"displayName": {
|
||||
"type": "string"
|
||||
},
|
||||
"guestToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"isGuest": {
|
||||
"type": "boolean",
|
||||
"enum": [
|
||||
true
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/forum/api/threads/{threadId}/posts": {
|
||||
"get": {
|
||||
"operationId": "post-by-thread",
|
||||
@@ -5561,6 +5651,7 @@
|
||||
"post"
|
||||
],
|
||||
"security": [
|
||||
{},
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
@@ -5587,6 +5678,11 @@
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Post content"
|
||||
},
|
||||
"guestToken": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Guest session token (32-char hex, for unauthenticated users)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5638,34 +5734,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8651,6 +8719,7 @@
|
||||
"thread"
|
||||
],
|
||||
"security": [
|
||||
{},
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
@@ -8682,6 +8751,11 @@
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Initial post content"
|
||||
},
|
||||
"guestToken": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Guest session token (32-char hex, for unauthenticated users)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8733,34 +8807,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
158
openapi.json
158
openapi.json
@@ -4840,6 +4840,96 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/forum/api/guest/me": {
|
||||
"get": {
|
||||
"operationId": "guest-me",
|
||||
"summary": "Get or create a guest identity",
|
||||
"tags": [
|
||||
"guest"
|
||||
],
|
||||
"security": [
|
||||
{},
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "guestToken",
|
||||
"in": "query",
|
||||
"description": "32-character hex token",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Guest identity returned",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"displayName",
|
||||
"guestToken",
|
||||
"isGuest"
|
||||
],
|
||||
"properties": {
|
||||
"displayName": {
|
||||
"type": "string"
|
||||
},
|
||||
"guestToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"isGuest": {
|
||||
"type": "boolean",
|
||||
"enum": [
|
||||
true
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/forum/api/threads/{threadId}/posts": {
|
||||
"get": {
|
||||
"operationId": "post-by-thread",
|
||||
@@ -5561,6 +5651,7 @@
|
||||
"post"
|
||||
],
|
||||
"security": [
|
||||
{},
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
@@ -5587,6 +5678,11 @@
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Post content"
|
||||
},
|
||||
"guestToken": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Guest session token (32-char hex, for unauthenticated users)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5638,34 +5734,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8651,6 +8719,7 @@
|
||||
"thread"
|
||||
],
|
||||
"security": [
|
||||
{},
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
@@ -8682,6 +8751,11 @@
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Initial post content"
|
||||
},
|
||||
"guestToken": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Guest session token (32-char hex, for unauthenticated users)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8733,34 +8807,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
17
src/axios.ts
17
src/axios.ts
@@ -1,10 +1,27 @@
|
||||
import { generateOcsUrl } from '@nextcloud/router'
|
||||
import _axios from '@nextcloud/axios'
|
||||
import { getCurrentUser } from '@nextcloud/auth'
|
||||
|
||||
const baseURL = generateOcsUrl('/apps/forum/api')
|
||||
export const http = _axios.create({ baseURL })
|
||||
export const ocs = _axios.create({ baseURL })
|
||||
export const webDav = _axios.create({ baseURL: '' })
|
||||
|
||||
// Inject guestToken for unauthenticated users
|
||||
ocs.interceptors.request.use((config) => {
|
||||
if (getCurrentUser() === null) {
|
||||
const guestToken = localStorage.getItem('forum_guest_token')
|
||||
if (guestToken) {
|
||||
if (config.method === 'get' || config.method === 'GET') {
|
||||
config.params = { ...config.params, guestToken }
|
||||
} else {
|
||||
config.data = { ...config.data, guestToken }
|
||||
}
|
||||
}
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
ocs.interceptors.response.use(
|
||||
(response) => {
|
||||
const ocsData = response?.data?.ocs?.data
|
||||
|
||||
@@ -188,6 +188,20 @@
|
||||
<div v-if="!isLoading && userId" class="sidebar-footer">
|
||||
<UserInfo :user-id="userId" :display-name="displayName" :avatar-size="32" />
|
||||
</div>
|
||||
<div v-else-if="!isLoading && isGuest && guestDisplayName" class="sidebar-footer">
|
||||
<UserInfo
|
||||
:user-id="'guest'"
|
||||
:display-name="guestDisplayName"
|
||||
:avatar-size="32"
|
||||
:is-guest="true"
|
||||
:clickable="false"
|
||||
layout="inline"
|
||||
>
|
||||
<template #meta>
|
||||
<span class="guest-label">{{ strings.guestLabel }}</span>
|
||||
</template>
|
||||
</UserInfo>
|
||||
</div>
|
||||
</template>
|
||||
</NcAppNavigation>
|
||||
</template>
|
||||
@@ -219,6 +233,7 @@ import { useCategories } from '@/composables/useCategories'
|
||||
import { useCurrentUser } from '@/composables/useCurrentUser'
|
||||
import { useUserRole } from '@/composables/useUserRole'
|
||||
import { useCurrentThread } from '@/composables/useCurrentThread'
|
||||
import { useGuestSession } from '@/composables/useGuestSession'
|
||||
import type { Category } from '@/types'
|
||||
|
||||
export default defineComponent({
|
||||
@@ -250,6 +265,7 @@ export default defineComponent({
|
||||
const { userId, displayName, fetchCurrentUser } = useCurrentUser()
|
||||
const { canAccessAdmin, canEditRoles, canEditCategories, fetchUserRoles } = useUserRole()
|
||||
const { categoryId: currentThreadCategoryId, fetchThread, clearThread } = useCurrentThread()
|
||||
const { isGuest, guestDisplayName, fetchGuestIdentity } = useGuestSession()
|
||||
|
||||
return {
|
||||
categoryHeaders,
|
||||
@@ -264,6 +280,9 @@ export default defineComponent({
|
||||
currentThreadCategoryId,
|
||||
fetchThread,
|
||||
clearThread,
|
||||
isGuest,
|
||||
guestDisplayName,
|
||||
fetchGuestIdentity,
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -289,6 +308,7 @@ export default defineComponent({
|
||||
navAdminBBCodes: t('forum', 'BBCodes'),
|
||||
expand: t('forum', 'Expand'),
|
||||
collapse: t('forum', 'Collapse'),
|
||||
guestLabel: t('forum', '(Guest)'),
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -310,6 +330,11 @@ export default defineComponent({
|
||||
await this.fetchUserRoles(userResult.value.userId).catch((e) => {
|
||||
console.error('Failed to load user roles:', e)
|
||||
})
|
||||
} else if (this.isGuest) {
|
||||
// Fetch guest identity for sidebar display
|
||||
await this.fetchGuestIdentity().catch((e) => {
|
||||
console.error('Failed to load guest identity:', e)
|
||||
})
|
||||
}
|
||||
|
||||
// Log any errors from categories fetch
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
:user-id="post.author?.userId || post.authorId"
|
||||
:display-name="post.author?.displayName || post.authorId"
|
||||
:is-deleted="post.author?.isDeleted || false"
|
||||
:is-guest="post.author?.isGuest || false"
|
||||
:avatar-size="32"
|
||||
:roles="post.author?.roles || []"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { ref, computed } from 'vue'
|
||||
import { createIconMock, createComponentMock } from '@/test-utils'
|
||||
import PostReplyForm from './PostReplyForm.vue'
|
||||
|
||||
@@ -24,7 +25,7 @@ vi.mock('@/components/BBCodeEditor', () =>
|
||||
vi.mock('@/components/UserInfo', () =>
|
||||
createComponentMock('UserInfo', {
|
||||
template: '<div class="user-info-mock" :data-user-id="userId">{{ displayName }}</div>',
|
||||
props: ['userId', 'displayName', 'avatarSize', 'clickable'],
|
||||
props: ['userId', 'displayName', 'avatarSize', 'clickable', 'isGuest'],
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -47,6 +48,14 @@ vi.mock('@/composables/useCurrentUser', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useGuestSession composable
|
||||
vi.mock('@/composables/useGuestSession', () => ({
|
||||
useGuestSession: () => ({
|
||||
isGuest: computed(() => false),
|
||||
guestDisplayName: ref(null),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('PostReplyForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<template>
|
||||
<div class="post-reply-form">
|
||||
<div v-if="userId" class="reply-header">
|
||||
<div v-if="userId || isGuest" class="reply-header">
|
||||
<UserInfo
|
||||
:user-id="userId"
|
||||
:display-name="displayName"
|
||||
:user-id="userId || 'guest'"
|
||||
:display-name="userId ? displayName : guestDisplayName || ''"
|
||||
:avatar-size="40"
|
||||
:clickable="false"
|
||||
:is-guest="isGuest"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -46,6 +47,7 @@ import UserInfo from '@/components/UserInfo'
|
||||
import BBCodeEditor from '@/components/BBCodeEditor'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { useCurrentUser } from '@/composables/useCurrentUser'
|
||||
import { useGuestSession } from '@/composables/useGuestSession'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PostReplyForm',
|
||||
@@ -59,10 +61,13 @@ export default defineComponent({
|
||||
emits: ['submit', 'cancel'],
|
||||
setup() {
|
||||
const { userId, displayName } = useCurrentUser()
|
||||
const { isGuest, guestDisplayName } = useGuestSession()
|
||||
|
||||
return {
|
||||
userId,
|
||||
displayName,
|
||||
isGuest,
|
||||
guestDisplayName,
|
||||
}
|
||||
},
|
||||
data() {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
:user-id="thread.author?.userId || thread.authorId"
|
||||
:display-name="thread.author?.displayName || thread.authorId"
|
||||
:is-deleted="thread.author?.isDeleted || false"
|
||||
:is-guest="thread.author?.isGuest || false"
|
||||
:avatar-size="32"
|
||||
:roles="thread.author?.roles || []"
|
||||
:show-roles="false"
|
||||
@@ -106,7 +107,10 @@ export default defineComponent({
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
background: var(--color-main-background);
|
||||
transition: box-shadow 0.2s ease, border-color 0.2s ease, transform 0.1s ease;
|
||||
transition:
|
||||
box-shadow 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
transform 0.1s ease;
|
||||
cursor: pointer;
|
||||
|
||||
* {
|
||||
|
||||
@@ -11,6 +11,14 @@ vi.mock('@/composables/useCurrentUser', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useGuestSession composable
|
||||
vi.mock('@/composables/useGuestSession', () => ({
|
||||
useGuestSession: () => ({
|
||||
isGuest: computed(() => false),
|
||||
guestDisplayName: ref(null),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock icons
|
||||
vi.mock('@icons/Check.vue', () => createIconMock('CheckIcon'))
|
||||
vi.mock('@icons/ContentSave.vue', () => createIconMock('ContentSaveIcon'))
|
||||
@@ -21,7 +29,7 @@ vi.mock('@icons/ContentSaveAlert.vue', () => createIconMock('ContentSaveAlertIco
|
||||
vi.mock('@/components/UserInfo', () =>
|
||||
createComponentMock('UserInfo', {
|
||||
template: '<div class="user-info-mock">{{ displayName }}</div>',
|
||||
props: ['userId', 'displayName', 'avatarSize', 'clickable'],
|
||||
props: ['userId', 'displayName', 'avatarSize', 'clickable', 'isGuest'],
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<template>
|
||||
<div class="thread-create-form">
|
||||
<div v-if="userId" class="form-header">
|
||||
<div v-if="userId || isGuest" class="form-header">
|
||||
<UserInfo
|
||||
:user-id="userId"
|
||||
:display-name="displayName"
|
||||
:user-id="userId || 'guest'"
|
||||
:display-name="userId ? displayName : guestDisplayName || ''"
|
||||
:avatar-size="40"
|
||||
:clickable="false"
|
||||
:is-guest="isGuest"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -65,6 +66,7 @@ import UserInfo from '@/components/UserInfo'
|
||||
import BBCodeEditor from '@/components/BBCodeEditor'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { useCurrentUser } from '@/composables/useCurrentUser'
|
||||
import { useGuestSession } from '@/composables/useGuestSession'
|
||||
|
||||
export type DraftStatus = 'saving' | 'saved' | 'dirty' | null
|
||||
|
||||
@@ -90,10 +92,13 @@ export default defineComponent({
|
||||
},
|
||||
setup() {
|
||||
const { userId, displayName } = useCurrentUser()
|
||||
const { isGuest, guestDisplayName } = useGuestSession()
|
||||
|
||||
return {
|
||||
userId,
|
||||
displayName,
|
||||
isGuest,
|
||||
guestDisplayName,
|
||||
}
|
||||
},
|
||||
data() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="!isDeleted"
|
||||
v-if="!isDeleted && !isGuest"
|
||||
class="user-avatar"
|
||||
:style="{ height: size + 'px' }"
|
||||
:class="{ clickable: isClickable }"
|
||||
@@ -9,7 +9,7 @@
|
||||
<NcAvatar :user="userId" :size="size" />
|
||||
</div>
|
||||
<div v-else class="user-avatar">
|
||||
<NcAvatar :display-name="displayName" :size="size" />
|
||||
<NcAvatar :display-name="avatarDisplayName" :size="size" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -39,6 +39,10 @@ export default defineComponent({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isGuest: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
clickable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
@@ -47,7 +51,15 @@ export default defineComponent({
|
||||
emits: ['click'],
|
||||
computed: {
|
||||
isClickable(): boolean {
|
||||
return this.clickable && !this.isDeleted
|
||||
return this.clickable && !this.isDeleted && !this.isGuest
|
||||
},
|
||||
avatarDisplayName(): string {
|
||||
if (!this.isGuest || !this.displayName) {
|
||||
return this.displayName
|
||||
}
|
||||
// Split CamelCase into words so NcAvatar generates 2-letter initials
|
||||
// e.g. "BrightMountain42" → "Bright Mountain"
|
||||
return this.displayName.replace(/[0-9]+$/, '').replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -7,7 +7,7 @@ import UserInfo from './UserInfo.vue'
|
||||
vi.mock('@/components/UserAvatar', () =>
|
||||
createComponentMock('UserAvatar', {
|
||||
template: '<div class="user-avatar-mock" :data-user-id="userId"></div>',
|
||||
props: ['userId', 'displayName', 'size', 'isDeleted', 'clickable'],
|
||||
props: ['userId', 'displayName', 'size', 'isDeleted', 'isGuest', 'clickable'],
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -112,6 +112,49 @@ describe('UserInfo', () => {
|
||||
expect(wrapper.find('.role-badge-mock').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should display moderator role', () => {
|
||||
const modRole = createMockRole({ id: 2, name: 'Moderator', roleType: 'moderator' })
|
||||
const wrapper = mount(UserInfo, {
|
||||
props: { userId: 'testuser', roles: [modRole] },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
expect(wrapper.find('.role-badge-mock').exists()).toBe(true)
|
||||
expect(wrapper.find('.role-badge-mock').text()).toBe('Moderator')
|
||||
})
|
||||
|
||||
it('should display admin role over moderator when both present', () => {
|
||||
const adminRole = createMockRole({ id: 1, name: 'Admin', roleType: 'admin' })
|
||||
const modRole = createMockRole({ id: 2, name: 'Moderator', roleType: 'moderator' })
|
||||
const wrapper = mount(UserInfo, {
|
||||
props: { userId: 'testuser', roles: [modRole, adminRole] },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
const badges = wrapper.findAll('.role-badge-mock')
|
||||
expect(badges).toHaveLength(1)
|
||||
expect(badges[0].text()).toBe('Admin')
|
||||
})
|
||||
|
||||
it('should display admin role with custom roles', () => {
|
||||
const adminRole = createMockRole({ id: 1, name: 'Admin', roleType: 'admin' })
|
||||
const customRole = createMockRole({ id: 10, name: 'VIP', roleType: 'custom' })
|
||||
const wrapper = mount(UserInfo, {
|
||||
props: { userId: 'testuser', roles: [adminRole, customRole] },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
const badges = wrapper.findAll('.role-badge-mock')
|
||||
expect(badges).toHaveLength(2)
|
||||
expect(badges[0].text()).toBe('Admin')
|
||||
expect(badges[1].text()).toBe('VIP')
|
||||
})
|
||||
|
||||
it('should not display any badges when roles array is empty', () => {
|
||||
const wrapper = mount(UserInfo, {
|
||||
props: { userId: 'testuser', roles: [] },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
expect(wrapper.find('.role-badges').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should not display default role', () => {
|
||||
const defaultRole = createMockRole({ id: 3, name: 'User', roleType: 'default' })
|
||||
const wrapper = mount(UserInfo, {
|
||||
@@ -120,6 +163,46 @@ describe('UserInfo', () => {
|
||||
})
|
||||
expect(wrapper.find('.role-badge-mock').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should not display guest role for non-guest users', () => {
|
||||
const guestRole = createMockRole({ id: 4, name: 'Guest', roleType: 'guest' })
|
||||
const wrapper = mount(UserInfo, {
|
||||
props: { userId: 'testuser', roles: [guestRole], isGuest: false },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
expect(wrapper.find('.role-badge-mock').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should display guest role badge when isGuest is true', () => {
|
||||
const guestRole = createMockRole({ id: 4, name: 'Guest', roleType: 'guest' })
|
||||
const wrapper = mount(UserInfo, {
|
||||
props: { userId: 'guest:abc123', roles: [guestRole], isGuest: true },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
expect(wrapper.find('.role-badge-mock').exists()).toBe(true)
|
||||
expect(wrapper.find('.role-badge-mock').text()).toBe('Guest')
|
||||
})
|
||||
|
||||
it('should display guest role alongside custom roles for guests', () => {
|
||||
const guestRole = createMockRole({ id: 4, name: 'Guest', roleType: 'guest' })
|
||||
const customRole = createMockRole({ id: 10, name: 'VIP', roleType: 'custom' })
|
||||
const wrapper = mount(UserInfo, {
|
||||
props: { userId: 'guest:abc123', roles: [guestRole, customRole], isGuest: true },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
const badges = wrapper.findAll('.role-badge-mock')
|
||||
expect(badges).toHaveLength(2)
|
||||
expect(badges[0].text()).toBe('Guest')
|
||||
expect(badges[1].text()).toBe('VIP')
|
||||
})
|
||||
|
||||
it('should not be clickable when isGuest is true', () => {
|
||||
const wrapper = mount(UserInfo, {
|
||||
props: { userId: 'guest:abc123', isGuest: true, clickable: true },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
expect(wrapper.find('.user-name').classes()).not.toContain('clickable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('slots', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
:display-name="displayName"
|
||||
:size="avatarSize"
|
||||
:is-deleted="isDeleted"
|
||||
:is-guest="isGuest"
|
||||
:clickable="clickable"
|
||||
/>
|
||||
<div class="user-details" :class="{ 'details-inline': layout === 'inline' }">
|
||||
@@ -40,7 +41,7 @@ import { defineComponent, type PropType } from 'vue'
|
||||
import UserAvatar from '@/components/UserAvatar'
|
||||
import RoleBadge from '@/components/RoleBadge'
|
||||
import type { Role } from '@/types'
|
||||
import { isAdminRole, isModeratorRole, isCustomRole } from '@/constants'
|
||||
import { isAdminRole, isModeratorRole, isGuestRole, isCustomRole } from '@/constants'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'UserInfo',
|
||||
@@ -65,6 +66,10 @@ export default defineComponent({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isGuest: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
clickable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
@@ -85,7 +90,7 @@ export default defineComponent({
|
||||
},
|
||||
computed: {
|
||||
isClickable(): boolean {
|
||||
return this.clickable && !this.isDeleted
|
||||
return this.clickable && !this.isDeleted && !this.isGuest
|
||||
},
|
||||
|
||||
displayRoles(): Role[] {
|
||||
@@ -96,13 +101,14 @@ export default defineComponent({
|
||||
// Find the highest priority system role (admin > moderator, mutually exclusive)
|
||||
const adminRole = this.roles.find((role) => isAdminRole(role))
|
||||
const moderatorRole = this.roles.find((role) => isModeratorRole(role))
|
||||
const primaryRole = adminRole ?? moderatorRole
|
||||
const guestRole = this.isGuest ? this.roles.find((role) => isGuestRole(role)) : undefined
|
||||
const primaryRole = adminRole ?? moderatorRole ?? guestRole
|
||||
|
||||
// Get custom roles (shown after the primary role)
|
||||
const customRoles = this.roles.filter((role) => isCustomRole(role))
|
||||
|
||||
// Build the display list: primary system role (if any) + custom roles
|
||||
// Note: "default" (user) and "guest" roles are intentionally excluded
|
||||
// Note: "default" (user) role is intentionally excluded
|
||||
return primaryRole ? [primaryRole, ...customRoles] : customRoles
|
||||
},
|
||||
},
|
||||
|
||||
67
src/composables/useGuestSession.ts
Normal file
67
src/composables/useGuestSession.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { ocs } from '@/axios'
|
||||
import { getCurrentUser } from '@nextcloud/auth'
|
||||
|
||||
const STORAGE_TOKEN_KEY = 'forum_guest_token'
|
||||
const STORAGE_DISPLAY_NAME_KEY = 'forum_guest_display_name'
|
||||
|
||||
const guestDisplayName = ref<string | null>(null)
|
||||
const identityLoaded = ref(false)
|
||||
|
||||
function getOrCreateToken(): string {
|
||||
let token = localStorage.getItem(STORAGE_TOKEN_KEY)
|
||||
if (token && /^[0-9a-f]{32}$/.test(token)) {
|
||||
return token
|
||||
}
|
||||
|
||||
// Generate 32-char hex token
|
||||
const bytes = new Uint8Array(16)
|
||||
crypto.getRandomValues(bytes)
|
||||
token = Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
localStorage.setItem(STORAGE_TOKEN_KEY, token)
|
||||
return token
|
||||
}
|
||||
|
||||
export function useGuestSession() {
|
||||
const isGuest = computed(() => getCurrentUser() === null)
|
||||
const guestToken = computed(() => (isGuest.value ? getOrCreateToken() : null))
|
||||
|
||||
const fetchGuestIdentity = async (): Promise<void> => {
|
||||
if (!isGuest.value || !guestToken.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check localStorage cache first
|
||||
const cached = localStorage.getItem(STORAGE_DISPLAY_NAME_KEY)
|
||||
if (cached) {
|
||||
guestDisplayName.value = cached
|
||||
identityLoaded.value = true
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await ocs.get<{ displayName: string; guestToken: string; isGuest: true }>(
|
||||
'/guest/me',
|
||||
{ params: { guestToken: guestToken.value } },
|
||||
)
|
||||
|
||||
if (response.data) {
|
||||
guestDisplayName.value = response.data.displayName
|
||||
identityLoaded.value = true
|
||||
localStorage.setItem(STORAGE_DISPLAY_NAME_KEY, response.data.displayName)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch guest identity', e)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isGuest,
|
||||
guestToken,
|
||||
guestDisplayName,
|
||||
identityLoaded,
|
||||
fetchGuestIdentity,
|
||||
}
|
||||
}
|
||||
@@ -33,11 +33,18 @@ export interface User {
|
||||
userId: string
|
||||
displayName: string
|
||||
isDeleted: boolean
|
||||
isGuest?: boolean
|
||||
roles: Role[]
|
||||
signature: string | null
|
||||
signatureRaw: string | null
|
||||
}
|
||||
|
||||
export interface GuestIdentity {
|
||||
displayName: string
|
||||
guestToken: string
|
||||
isGuest: true
|
||||
}
|
||||
|
||||
export interface Thread {
|
||||
id: number
|
||||
categoryId: number
|
||||
|
||||
@@ -372,8 +372,8 @@ export default defineComponent({
|
||||
},
|
||||
|
||||
createThread() {
|
||||
// Redirect guests to login
|
||||
if (this.userId === null) {
|
||||
// Redirect guests to login only if they cannot post
|
||||
if (this.userId === null && !this.canPost) {
|
||||
const returnUrl = generateUrl(
|
||||
`/apps/forum/c/${this.category?.slug || this.category?.id}/new`,
|
||||
)
|
||||
|
||||
@@ -74,6 +74,8 @@ import { ocs } from '@/axios'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { showError, showSuccess } from '@nextcloud/dialogs'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { useCurrentUser } from '@/composables/useCurrentUser'
|
||||
import { useGuestSession } from '@/composables/useGuestSession'
|
||||
|
||||
const DRAFT_DEBOUNCE_DELAY = 1500 // 1.5 seconds
|
||||
|
||||
@@ -81,7 +83,9 @@ export default defineComponent({
|
||||
name: 'CreateThreadView',
|
||||
setup() {
|
||||
const { checkCategoryPermission } = usePermissions()
|
||||
return { checkCategoryPermission }
|
||||
const { userId } = useCurrentUser()
|
||||
const { isGuest, fetchGuestIdentity } = useGuestSession()
|
||||
return { checkCategoryPermission, userId, isGuest, fetchGuestIdentity }
|
||||
},
|
||||
components: {
|
||||
NcButton,
|
||||
@@ -154,6 +158,11 @@ export default defineComponent({
|
||||
}
|
||||
this.category = resp!.data
|
||||
|
||||
// Fetch guest identity if guest
|
||||
if (this.isGuest) {
|
||||
await this.fetchGuestIdentity()
|
||||
}
|
||||
|
||||
// Check canPost permission
|
||||
const canPost = await this.checkCategoryPermission(this.category.id, 'canPost')
|
||||
if (!canPost) {
|
||||
@@ -161,8 +170,10 @@ export default defineComponent({
|
||||
return
|
||||
}
|
||||
|
||||
// After loading category, fetch any existing draft
|
||||
await this.fetchDraft()
|
||||
// After loading category, fetch any existing draft (authenticated users only)
|
||||
if (this.userId !== null) {
|
||||
await this.fetchDraft()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch category', e)
|
||||
this.error = t('forum', 'Category not found')
|
||||
@@ -222,6 +233,11 @@ export default defineComponent({
|
||||
},
|
||||
|
||||
scheduleDraftSave() {
|
||||
// Guests cannot save drafts
|
||||
if (this.userId === null) {
|
||||
return
|
||||
}
|
||||
|
||||
// Clear any existing timer
|
||||
if (this.draftDebounceTimer) {
|
||||
clearTimeout(this.draftDebounceTimer)
|
||||
|
||||
@@ -295,8 +295,12 @@
|
||||
<p>{{ strings.noReplyPermission }}</p>
|
||||
</NcNoteCard>
|
||||
|
||||
<!-- Guest user message -->
|
||||
<NcNoteCard v-if="!loading && !error && thread && userId === null" type="info" class="mt-16">
|
||||
<!-- Guest user message (only when guest cannot reply) -->
|
||||
<NcNoteCard
|
||||
v-if="!loading && !error && thread && userId === null && !canReply"
|
||||
type="info"
|
||||
class="mt-16"
|
||||
>
|
||||
<p>{{ strings.guestMessage }}</p>
|
||||
<template #action>
|
||||
<NcButton @click="replyToThread" variant="primary">
|
||||
@@ -305,13 +309,13 @@
|
||||
</template>
|
||||
</NcNoteCard>
|
||||
|
||||
<!-- Reply form (authenticated users only, with canReply permission, moderators can reply even when locked) -->
|
||||
<!-- Reply form (authenticated users or guests with canReply permission, moderators can reply even when locked) -->
|
||||
<PostReplyForm
|
||||
v-if="
|
||||
!loading &&
|
||||
!error &&
|
||||
thread &&
|
||||
userId !== null &&
|
||||
(userId !== null || isGuest) &&
|
||||
canReply &&
|
||||
(!thread.isLocked || canModerate)
|
||||
"
|
||||
@@ -371,6 +375,7 @@ import { showError, showSuccess } from '@nextcloud/dialogs'
|
||||
import { useCurrentThread } from '@/composables/useCurrentThread'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { useCurrentUser } from '@/composables/useCurrentUser'
|
||||
import { useGuestSession } from '@/composables/useGuestSession'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ThreadView',
|
||||
@@ -408,12 +413,15 @@ export default defineComponent({
|
||||
const { currentThread: thread, fetchThread } = useCurrentThread()
|
||||
const { checkCategoryPermission } = usePermissions()
|
||||
const { userId } = useCurrentUser()
|
||||
const { isGuest, fetchGuestIdentity } = useGuestSession()
|
||||
|
||||
return {
|
||||
thread,
|
||||
fetchThread,
|
||||
checkCategoryPermission,
|
||||
userId,
|
||||
isGuest,
|
||||
fetchGuestIdentity,
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -554,6 +562,11 @@ export default defineComponent({
|
||||
throw new Error(t('forum', 'Thread not found'))
|
||||
}
|
||||
|
||||
// Fetch guest identity if guest
|
||||
if (this.isGuest) {
|
||||
await this.fetchGuestIdentity()
|
||||
}
|
||||
|
||||
// Fetch posts - use page from query param if present
|
||||
const initialPage = this.pageFromQuery || 0
|
||||
await this.fetchPosts(initialPage)
|
||||
@@ -828,8 +841,8 @@ export default defineComponent({
|
||||
},
|
||||
|
||||
replyToThread(): void {
|
||||
// Redirect guests to login
|
||||
if (this.userId === null) {
|
||||
// Redirect guests to login (only if they cannot reply)
|
||||
if (this.userId === null && !this.canReply) {
|
||||
const returnUrl = generateUrl(`/apps/forum/t/${this.thread?.slug}`)
|
||||
window.location.href = generateUrl(`/login?redirect_url=${encodeURIComponent(returnUrl)}`)
|
||||
return
|
||||
|
||||
@@ -576,17 +576,9 @@ export default defineComponent({
|
||||
this.selectedReplyTargets = [memberOption]
|
||||
}
|
||||
|
||||
// Moderate: Admin and Moderator
|
||||
const adminRole = this.roles.find(isAdminRole)
|
||||
// Moderate: Moderator only (admin has hardcoded full access)
|
||||
const moderatorRole = this.roles.find(isModeratorRole)
|
||||
this.selectedModerateTargets = []
|
||||
if (adminRole) {
|
||||
this.selectedModerateTargets.push({
|
||||
id: `role:${adminRole.id}`,
|
||||
label: adminRole.name,
|
||||
type: 'role',
|
||||
})
|
||||
}
|
||||
if (moderatorRole) {
|
||||
this.selectedModerateTargets.push({
|
||||
id: `role:${moderatorRole.id}`,
|
||||
@@ -643,10 +635,11 @@ export default defineComponent({
|
||||
const rolePerms = perms.filter((p) => p.targetType === 'role')
|
||||
const teamPerms = perms.filter((p) => p.targetType === 'team')
|
||||
|
||||
// Map role permissions to PermTarget
|
||||
// Map role permissions to PermTarget (skip admin - has hardcoded full access)
|
||||
const mapRolePerm = (p: CategoryPerm): PermTarget | null => {
|
||||
const role = this.roles.find((r) => String(r.id) === p.targetId)
|
||||
return role ? { id: `role:${role.id}`, label: role.name, type: 'role' } : null
|
||||
if (!role || isAdminRole(role)) return null
|
||||
return { id: `role:${role.id}`, label: role.name, type: 'role' }
|
||||
}
|
||||
|
||||
// Map team permissions to PermTarget
|
||||
@@ -677,7 +670,8 @@ export default defineComponent({
|
||||
.filter((p) => p.canModerate)
|
||||
.map((p) => {
|
||||
const role = this.roles.find((r) => String(r.id) === p.targetId)
|
||||
if (!role || isGuestRole(role) || isDefaultRole(role)) return null
|
||||
if (!role || isAdminRole(role) || isGuestRole(role) || isDefaultRole(role))
|
||||
return null
|
||||
return { id: `role:${role.id}`, label: role.name, type: 'role' } as PermTarget
|
||||
}),
|
||||
...teamPerms.filter((p) => p.canModerate).map(mapTeamPerm),
|
||||
|
||||
@@ -121,7 +121,9 @@
|
||||
<div class="contributor-rank">{{ index + 1 }}</div>
|
||||
<UserInfo
|
||||
:user-id="contributor.userId"
|
||||
:display-name="contributor.userId"
|
||||
:display-name="contributor.displayName || contributor.userId"
|
||||
:is-guest="contributor.isGuest || false"
|
||||
:roles="contributor.roles || []"
|
||||
:avatar-size="40"
|
||||
>
|
||||
<template #meta>
|
||||
@@ -148,7 +150,9 @@
|
||||
<div class="contributor-rank">{{ index + 1 }}</div>
|
||||
<UserInfo
|
||||
:user-id="contributor.userId"
|
||||
:display-name="contributor.userId"
|
||||
:display-name="contributor.displayName || contributor.userId"
|
||||
:is-guest="contributor.isGuest || false"
|
||||
:roles="contributor.roles || []"
|
||||
:avatar-size="40"
|
||||
>
|
||||
<template #meta>
|
||||
@@ -182,6 +186,7 @@ import AccountPlusIcon from '@icons/AccountPlus.vue'
|
||||
import ForumIcon from '@icons/Forum.vue'
|
||||
import MessageTextIcon from '@icons/MessageText.vue'
|
||||
import FolderIcon from '@icons/Folder.vue'
|
||||
import type { Role } from '@/types'
|
||||
import { ocs } from '@/axios'
|
||||
import { t, n } from '@nextcloud/l10n'
|
||||
|
||||
@@ -199,11 +204,17 @@ interface DashboardStats {
|
||||
}
|
||||
topContributorsAllTime: Array<{
|
||||
userId: string
|
||||
displayName: string
|
||||
isGuest: boolean
|
||||
roles: Role[]
|
||||
postCount: number
|
||||
threadCount: number
|
||||
}>
|
||||
topContributorsRecent: Array<{
|
||||
userId: string
|
||||
displayName: string
|
||||
isGuest: boolean
|
||||
roles: Role[]
|
||||
postCount: number
|
||||
threadCount: number
|
||||
}>
|
||||
|
||||
@@ -267,6 +267,9 @@ export default defineComponent({
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-top: 32px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.success-message {
|
||||
|
||||
278
src/views/admin/__tests__/AdminDashboard.test.ts
Normal file
278
src/views/admin/__tests__/AdminDashboard.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createIconMock, createComponentMock } from '@/test-utils'
|
||||
import { createMockRole } from '@/test-mocks'
|
||||
|
||||
// Mock axios
|
||||
vi.mock('@/axios', () => ({
|
||||
ocs: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock icons
|
||||
vi.mock('@icons/AccountMultiple.vue', () => createIconMock('AccountMultipleIcon'))
|
||||
vi.mock('@icons/AccountPlus.vue', () => createIconMock('AccountPlusIcon'))
|
||||
vi.mock('@icons/Forum.vue', () => createIconMock('ForumIcon'))
|
||||
vi.mock('@icons/MessageText.vue', () => createIconMock('MessageTextIcon'))
|
||||
vi.mock('@icons/Folder.vue', () => createIconMock('FolderIcon'))
|
||||
|
||||
// Mock components
|
||||
vi.mock('@/components/PageWrapper', () =>
|
||||
createComponentMock('PageWrapper', {
|
||||
template: '<div class="page-wrapper-mock"><slot /></div>',
|
||||
}),
|
||||
)
|
||||
|
||||
vi.mock('@/components/PageHeader', () =>
|
||||
createComponentMock('PageHeader', {
|
||||
template: '<div class="page-header-mock">{{ title }}</div>',
|
||||
props: ['title', 'subtitle'],
|
||||
}),
|
||||
)
|
||||
|
||||
vi.mock('@/components/UserInfo', () =>
|
||||
createComponentMock('UserInfo', {
|
||||
template:
|
||||
'<div class="user-info-mock" :data-user-id="userId" :data-display-name="displayName" :data-is-guest="isGuest"><slot name="meta" /></div>',
|
||||
props: ['userId', 'displayName', 'avatarSize', 'isGuest', 'roles', 'clickable', 'showRoles'],
|
||||
}),
|
||||
)
|
||||
|
||||
import AdminDashboard from '../AdminDashboard.vue'
|
||||
import { ocs } from '@/axios'
|
||||
|
||||
const mockOcsGet = vi.mocked(ocs.get)
|
||||
|
||||
function createDashboardStats(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
data: {
|
||||
totals: { users: 10, threads: 20, posts: 50, categories: 5 },
|
||||
recent: { users: 2, threads: 3, posts: 8 },
|
||||
topContributorsAllTime: [],
|
||||
topContributorsRecent: [],
|
||||
...overrides,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('AdminDashboard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockOcsGet.mockResolvedValue(createDashboardStats())
|
||||
})
|
||||
|
||||
const createWrapper = () => {
|
||||
return mount(AdminDashboard)
|
||||
}
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders the dashboard', async () => {
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.admin-dashboard').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows loading state initially', () => {
|
||||
let _resolve: (v: unknown) => void
|
||||
mockOcsGet.mockImplementation(() => new Promise((r) => (_resolve = r)))
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('Loading')
|
||||
})
|
||||
|
||||
it('shows stats after loading', async () => {
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.dashboard-content').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows total statistics', async () => {
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('10') // users
|
||||
expect(wrapper.text()).toContain('20') // threads
|
||||
expect(wrapper.text()).toContain('50') // posts
|
||||
expect(wrapper.text()).toContain('5') // categories
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('shows error state on fetch failure', async () => {
|
||||
mockOcsGet.mockRejectedValue(new Error('Network error'))
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('Error loading dashboard')
|
||||
})
|
||||
})
|
||||
|
||||
describe('top contributors', () => {
|
||||
it('shows no contributors message when lists are empty', async () => {
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('No contributors yet')
|
||||
})
|
||||
|
||||
it('renders regular user contributors with display name', async () => {
|
||||
mockOcsGet.mockResolvedValue(
|
||||
createDashboardStats({
|
||||
topContributorsAllTime: [
|
||||
{
|
||||
userId: 'alice',
|
||||
displayName: 'Alice Smith',
|
||||
isGuest: false,
|
||||
roles: [],
|
||||
postCount: 10,
|
||||
threadCount: 3,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
const userInfos = wrapper.findAll('.user-info-mock')
|
||||
const aliceInfo = userInfos.find((el) => el.attributes('data-display-name') === 'Alice Smith')
|
||||
expect(aliceInfo).toBeDefined()
|
||||
expect(aliceInfo!.attributes('data-is-guest')).toBe('false')
|
||||
})
|
||||
|
||||
it('renders guest contributors with isGuest flag', async () => {
|
||||
const guestRole = createMockRole({ id: 4, name: 'Guest', roleType: 'guest' })
|
||||
mockOcsGet.mockResolvedValue(
|
||||
createDashboardStats({
|
||||
topContributorsRecent: [
|
||||
{
|
||||
userId: 'guest:abc123',
|
||||
displayName: 'BrightMountain42',
|
||||
isGuest: true,
|
||||
roles: [guestRole],
|
||||
postCount: 5,
|
||||
threadCount: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
const userInfos = wrapper.findAll('.user-info-mock')
|
||||
const guestInfo = userInfos.find(
|
||||
(el) => el.attributes('data-display-name') === 'BrightMountain42',
|
||||
)
|
||||
expect(guestInfo).toBeDefined()
|
||||
expect(guestInfo!.attributes('data-is-guest')).toBe('true')
|
||||
})
|
||||
|
||||
it('passes roles to UserInfo for guest contributors', async () => {
|
||||
const guestRole = createMockRole({ id: 4, name: 'Guest', roleType: 'guest' })
|
||||
mockOcsGet.mockResolvedValue(
|
||||
createDashboardStats({
|
||||
topContributorsRecent: [
|
||||
{
|
||||
userId: 'guest:abc123',
|
||||
displayName: 'SwiftRiver99',
|
||||
isGuest: true,
|
||||
roles: [guestRole],
|
||||
postCount: 3,
|
||||
threadCount: 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
const userInfo = wrapper.findComponent({ name: 'UserInfo' })
|
||||
expect(userInfo.props('roles')).toEqual([guestRole])
|
||||
expect(userInfo.props('isGuest')).toBe(true)
|
||||
})
|
||||
|
||||
it('passes roles to UserInfo for regular contributors', async () => {
|
||||
const adminRole = createMockRole({ id: 1, name: 'Admin', roleType: 'admin' })
|
||||
mockOcsGet.mockResolvedValue(
|
||||
createDashboardStats({
|
||||
topContributorsAllTime: [
|
||||
{
|
||||
userId: 'admin',
|
||||
displayName: 'Admin User',
|
||||
isGuest: false,
|
||||
roles: [adminRole],
|
||||
postCount: 20,
|
||||
threadCount: 5,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
const userInfo = wrapper.findComponent({ name: 'UserInfo' })
|
||||
expect(userInfo.props('roles')).toEqual([adminRole])
|
||||
expect(userInfo.props('isGuest')).toBe(false)
|
||||
})
|
||||
|
||||
it('shows contributor stats (threads and posts counts)', async () => {
|
||||
mockOcsGet.mockResolvedValue(
|
||||
createDashboardStats({
|
||||
topContributorsAllTime: [
|
||||
{
|
||||
userId: 'alice',
|
||||
displayName: 'Alice',
|
||||
isGuest: false,
|
||||
roles: [],
|
||||
postCount: 10,
|
||||
threadCount: 3,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
const contributorStats = wrapper.find('.contributor-stats')
|
||||
expect(contributorStats.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows rank numbers for contributors', async () => {
|
||||
mockOcsGet.mockResolvedValue(
|
||||
createDashboardStats({
|
||||
topContributorsRecent: [
|
||||
{
|
||||
userId: 'alice',
|
||||
displayName: 'Alice',
|
||||
isGuest: false,
|
||||
roles: [],
|
||||
postCount: 10,
|
||||
threadCount: 3,
|
||||
},
|
||||
{
|
||||
userId: 'bob',
|
||||
displayName: 'Bob',
|
||||
isGuest: false,
|
||||
roles: [],
|
||||
postCount: 5,
|
||||
threadCount: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
const ranks = wrapper.findAll('.contributor-rank')
|
||||
expect(ranks.length).toBeGreaterThanOrEqual(2)
|
||||
expect(ranks[0].text()).toBe('1')
|
||||
expect(ranks[1].text()).toBe('2')
|
||||
})
|
||||
})
|
||||
})
|
||||
176
tests/unit/Controller/AdminControllerTest.php
Normal file
176
tests/unit/Controller/AdminControllerTest.php
Normal file
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Forum\Tests\Controller;
|
||||
|
||||
use OCA\Forum\AppInfo\Application;
|
||||
use OCA\Forum\Controller\AdminController;
|
||||
use OCA\Forum\Db\CategoryMapper;
|
||||
use OCA\Forum\Db\ForumUserMapper;
|
||||
use OCA\Forum\Db\PostMapper;
|
||||
use OCA\Forum\Db\RoleMapper;
|
||||
use OCA\Forum\Db\ThreadMapper;
|
||||
use OCA\Forum\Service\AdminSettingsService;
|
||||
use OCA\Forum\Service\UserRoleService;
|
||||
use OCA\Forum\Service\UserService;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserManager;
|
||||
use OCP\IUserSession;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class AdminControllerTest extends TestCase {
|
||||
private AdminController $controller;
|
||||
|
||||
/** @var ForumUserMapper&MockObject */
|
||||
private ForumUserMapper $forumUserMapper;
|
||||
/** @var UserService&MockObject */
|
||||
private UserService $userService;
|
||||
/** @var ThreadMapper&MockObject */
|
||||
private ThreadMapper $threadMapper;
|
||||
/** @var PostMapper&MockObject */
|
||||
private PostMapper $postMapper;
|
||||
/** @var CategoryMapper&MockObject */
|
||||
private CategoryMapper $categoryMapper;
|
||||
/** @var RoleMapper&MockObject */
|
||||
private RoleMapper $roleMapper;
|
||||
/** @var UserRoleService&MockObject */
|
||||
private UserRoleService $userRoleService;
|
||||
/** @var IUserManager&MockObject */
|
||||
private IUserManager $userManager;
|
||||
/** @var IUserSession&MockObject */
|
||||
private IUserSession $userSession;
|
||||
/** @var AdminSettingsService&MockObject */
|
||||
private AdminSettingsService $settingsService;
|
||||
/** @var LoggerInterface&MockObject */
|
||||
private LoggerInterface $logger;
|
||||
/** @var IRequest&MockObject */
|
||||
private IRequest $request;
|
||||
|
||||
protected function setUp(): void {
|
||||
$this->request = $this->createMock(IRequest::class);
|
||||
$this->forumUserMapper = $this->createMock(ForumUserMapper::class);
|
||||
$this->userService = $this->createMock(UserService::class);
|
||||
$this->threadMapper = $this->createMock(ThreadMapper::class);
|
||||
$this->postMapper = $this->createMock(PostMapper::class);
|
||||
$this->categoryMapper = $this->createMock(CategoryMapper::class);
|
||||
$this->roleMapper = $this->createMock(RoleMapper::class);
|
||||
$this->userRoleService = $this->createMock(UserRoleService::class);
|
||||
$this->userManager = $this->createMock(IUserManager::class);
|
||||
$this->userSession = $this->createMock(IUserSession::class);
|
||||
$this->settingsService = $this->createMock(AdminSettingsService::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$this->controller = new AdminController(
|
||||
Application::APP_ID,
|
||||
$this->request,
|
||||
$this->forumUserMapper,
|
||||
$this->userService,
|
||||
$this->threadMapper,
|
||||
$this->postMapper,
|
||||
$this->categoryMapper,
|
||||
$this->roleMapper,
|
||||
$this->userRoleService,
|
||||
$this->userManager,
|
||||
$this->userSession,
|
||||
$this->settingsService,
|
||||
$this->logger
|
||||
);
|
||||
}
|
||||
|
||||
public function testDashboardEnrichesContributorsWithDisplayNames(): void {
|
||||
$user = $this->createMock(IUser::class);
|
||||
$user->method('getUID')->willReturn('admin');
|
||||
$this->userSession->method('getUser')->willReturn($user);
|
||||
|
||||
$this->forumUserMapper->method('countAll')->willReturn(5);
|
||||
$this->threadMapper->method('countAll')->willReturn(10);
|
||||
$this->postMapper->method('countAll')->willReturn(50);
|
||||
$this->categoryMapper->method('countAll')->willReturn(3);
|
||||
$this->forumUserMapper->method('countSince')->willReturn(1);
|
||||
$this->threadMapper->method('countSince')->willReturn(2);
|
||||
$this->postMapper->method('countSince')->willReturn(5);
|
||||
|
||||
$this->forumUserMapper->method('getTopContributors')->willReturn([
|
||||
['userId' => 'alice', 'postCount' => 10, 'threadCount' => 3],
|
||||
]);
|
||||
$this->forumUserMapper->method('getTopContributorsSince')->willReturn([
|
||||
['userId' => 'alice', 'postCount' => 2, 'threadCount' => 1],
|
||||
]);
|
||||
|
||||
$this->userService->expects($this->once())
|
||||
->method('enrichMultipleUsers')
|
||||
->with(['alice'])
|
||||
->willReturn([
|
||||
'alice' => [
|
||||
'userId' => 'alice',
|
||||
'displayName' => 'Alice Smith',
|
||||
'isDeleted' => false,
|
||||
'roles' => [['id' => 3, 'name' => 'User', 'roleType' => 'default']],
|
||||
'signature' => null,
|
||||
'signatureRaw' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->controller->dashboard();
|
||||
$data = $response->getData();
|
||||
|
||||
$this->assertEquals('Alice Smith', $data['topContributorsAllTime'][0]['displayName']);
|
||||
$this->assertFalse($data['topContributorsAllTime'][0]['isGuest']);
|
||||
$this->assertNotEmpty($data['topContributorsAllTime'][0]['roles']);
|
||||
|
||||
$this->assertEquals('Alice Smith', $data['topContributorsRecent'][0]['displayName']);
|
||||
$this->assertFalse($data['topContributorsRecent'][0]['isGuest']);
|
||||
}
|
||||
|
||||
public function testDashboardEnrichesGuestContributors(): void {
|
||||
$user = $this->createMock(IUser::class);
|
||||
$user->method('getUID')->willReturn('admin');
|
||||
$this->userSession->method('getUser')->willReturn($user);
|
||||
|
||||
$this->forumUserMapper->method('countAll')->willReturn(5);
|
||||
$this->threadMapper->method('countAll')->willReturn(10);
|
||||
$this->postMapper->method('countAll')->willReturn(50);
|
||||
$this->categoryMapper->method('countAll')->willReturn(3);
|
||||
$this->forumUserMapper->method('countSince')->willReturn(1);
|
||||
$this->threadMapper->method('countSince')->willReturn(2);
|
||||
$this->postMapper->method('countSince')->willReturn(5);
|
||||
|
||||
$guestId = 'guest:abcdef1234567890abcdef1234567890';
|
||||
$guestRole = ['id' => 4, 'name' => 'Guest', 'roleType' => 'guest'];
|
||||
|
||||
$this->forumUserMapper->method('getTopContributors')->willReturn([]);
|
||||
$this->forumUserMapper->method('getTopContributorsSince')->willReturn([
|
||||
['userId' => $guestId, 'postCount' => 3, 'threadCount' => 1],
|
||||
]);
|
||||
|
||||
$this->userService->expects($this->once())
|
||||
->method('enrichMultipleUsers')
|
||||
->with([$guestId])
|
||||
->willReturn([
|
||||
$guestId => [
|
||||
'userId' => $guestId,
|
||||
'displayName' => 'BrightMountain42',
|
||||
'isDeleted' => false,
|
||||
'isGuest' => true,
|
||||
'roles' => [$guestRole],
|
||||
'signature' => null,
|
||||
'signatureRaw' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->controller->dashboard();
|
||||
$data = $response->getData();
|
||||
|
||||
$this->assertCount(1, $data['topContributorsRecent']);
|
||||
$contributor = $data['topContributorsRecent'][0];
|
||||
$this->assertEquals('BrightMountain42', $contributor['displayName']);
|
||||
$this->assertTrue($contributor['isGuest']);
|
||||
$this->assertEquals([$guestRole], $contributor['roles']);
|
||||
$this->assertEquals(3, $contributor['postCount']);
|
||||
$this->assertEquals(1, $contributor['threadCount']);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ use OCA\Forum\Db\Thread;
|
||||
use OCA\Forum\Db\ThreadMapper;
|
||||
use OCA\Forum\Db\ThreadSubscriptionMapper;
|
||||
use OCA\Forum\Service\BBCodeService;
|
||||
use OCA\Forum\Service\GuestService;
|
||||
use OCA\Forum\Service\NotificationService;
|
||||
use OCA\Forum\Service\PermissionService;
|
||||
use OCA\Forum\Service\PostEnrichmentService;
|
||||
@@ -69,6 +70,8 @@ class PostControllerTest extends TestCase {
|
||||
private UserPreferencesService $userPreferencesService;
|
||||
/** @var ThreadSubscriptionMapper&MockObject */
|
||||
private ThreadSubscriptionMapper $threadSubscriptionMapper;
|
||||
/** @var GuestService&MockObject */
|
||||
private GuestService $guestService;
|
||||
/** @var IUserSession&MockObject */
|
||||
private IUserSession $userSession;
|
||||
/** @var LoggerInterface&MockObject */
|
||||
@@ -93,6 +96,7 @@ class PostControllerTest extends TestCase {
|
||||
$this->userService = $this->createMock(UserService::class);
|
||||
$this->userPreferencesService = $this->createMock(UserPreferencesService::class);
|
||||
$this->threadSubscriptionMapper = $this->createMock(ThreadSubscriptionMapper::class);
|
||||
$this->guestService = $this->createMock(GuestService::class);
|
||||
$this->userSession = $this->createMock(IUserSession::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
@@ -123,6 +127,7 @@ class PostControllerTest extends TestCase {
|
||||
$this->userService,
|
||||
$this->userPreferencesService,
|
||||
$this->threadSubscriptionMapper,
|
||||
$this->guestService,
|
||||
$this->userSession,
|
||||
$this->logger
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ use OCA\Forum\Db\ReadMarkerMapper;
|
||||
use OCA\Forum\Db\Thread;
|
||||
use OCA\Forum\Db\ThreadMapper;
|
||||
use OCA\Forum\Db\ThreadSubscriptionMapper;
|
||||
use OCA\Forum\Service\GuestService;
|
||||
use OCA\Forum\Service\NotificationService;
|
||||
use OCA\Forum\Service\PermissionService;
|
||||
use OCA\Forum\Service\ThreadEnrichmentService;
|
||||
@@ -71,6 +72,9 @@ class ThreadControllerTest extends TestCase {
|
||||
/** @var NotificationService&MockObject */
|
||||
private NotificationService $notificationService;
|
||||
|
||||
/** @var GuestService&MockObject */
|
||||
private GuestService $guestService;
|
||||
|
||||
/** @var IUserSession&MockObject */
|
||||
private IUserSession $userSession;
|
||||
|
||||
@@ -94,6 +98,7 @@ class ThreadControllerTest extends TestCase {
|
||||
$this->userService = $this->createMock(UserService::class);
|
||||
$this->permissionService = $this->createMock(PermissionService::class);
|
||||
$this->notificationService = $this->createMock(NotificationService::class);
|
||||
$this->guestService = $this->createMock(GuestService::class);
|
||||
$this->userSession = $this->createMock(IUserSession::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
@@ -123,6 +128,7 @@ class ThreadControllerTest extends TestCase {
|
||||
$this->userService,
|
||||
$this->permissionService,
|
||||
$this->notificationService,
|
||||
$this->guestService,
|
||||
$this->userSession,
|
||||
$this->logger
|
||||
);
|
||||
|
||||
160
tests/unit/Dashboard/RecentActivityWidgetTest.php
Normal file
160
tests/unit/Dashboard/RecentActivityWidgetTest.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Forum\Tests\Dashboard;
|
||||
|
||||
use OCA\Forum\Dashboard\RecentActivityWidget;
|
||||
use OCA\Forum\Dashboard\WidgetService;
|
||||
use OCA\Forum\Db\Post;
|
||||
use OCA\Forum\Db\Thread;
|
||||
use OCA\Forum\Service\UserService;
|
||||
use OCP\IL10N;
|
||||
use OCP\IURLGenerator;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class RecentActivityWidgetTest extends TestCase {
|
||||
private RecentActivityWidget $widget;
|
||||
|
||||
/** @var IL10N&MockObject */
|
||||
private IL10N $l;
|
||||
/** @var IURLGenerator&MockObject */
|
||||
private IURLGenerator $urlGenerator;
|
||||
/** @var WidgetService&MockObject */
|
||||
private WidgetService $widgetService;
|
||||
/** @var UserService&MockObject */
|
||||
private UserService $userService;
|
||||
|
||||
protected function setUp(): void {
|
||||
$this->l = $this->createMock(IL10N::class);
|
||||
$this->l->method('t')->willReturnCallback(function (string $text, array $params = []) {
|
||||
foreach ($params as $i => $param) {
|
||||
$text = str_replace('%1$s', (string)$param, $text);
|
||||
}
|
||||
return $text;
|
||||
});
|
||||
|
||||
$this->urlGenerator = $this->createMock(IURLGenerator::class);
|
||||
$this->widgetService = $this->createMock(WidgetService::class);
|
||||
$this->userService = $this->createMock(UserService::class);
|
||||
|
||||
$this->widgetService->method('getThreadUrl')->willReturn('https://example.com/thread');
|
||||
$this->widgetService->method('getThreadIconUrl')->willReturn('icon.svg');
|
||||
|
||||
$this->widget = new RecentActivityWidget(
|
||||
$this->l,
|
||||
$this->urlGenerator,
|
||||
$this->widgetService,
|
||||
$this->userService,
|
||||
);
|
||||
}
|
||||
|
||||
private function createMockThread(string $authorId, string $title = 'Test Thread'): Thread {
|
||||
$thread = new Thread();
|
||||
$thread->setId(1);
|
||||
$thread->setAuthorId($authorId);
|
||||
$thread->setTitle($title);
|
||||
$thread->setSlug('test-thread');
|
||||
$thread->setCreatedAt(time());
|
||||
return $thread;
|
||||
}
|
||||
|
||||
private function createMockPost(string $authorId, int $threadId = 1): Post {
|
||||
$post = new Post();
|
||||
$post->setId(10);
|
||||
$post->setThreadId($threadId);
|
||||
$post->setAuthorId($authorId);
|
||||
$post->setContent('Test content');
|
||||
$post->setCreatedAt(time());
|
||||
return $post;
|
||||
}
|
||||
|
||||
public function testRegularUserThreadShowsDisplayName(): void {
|
||||
$thread = $this->createMockThread('alice');
|
||||
|
||||
$this->widgetService->method('getRecentActivity')->willReturn([
|
||||
['type' => 'thread', 'item' => $thread, 'thread' => $thread, 'createdAt' => time()],
|
||||
]);
|
||||
|
||||
$this->userService->expects($this->once())
|
||||
->method('enrichMultipleUsers')
|
||||
->with(['alice'])
|
||||
->willReturn([
|
||||
'alice' => ['displayName' => 'Alice Smith', 'isGuest' => false],
|
||||
]);
|
||||
|
||||
$result = $this->widget->getItemsV2('viewer');
|
||||
$items = $result->getItems();
|
||||
|
||||
$this->assertCount(1, $items);
|
||||
$this->assertEquals('New thread by Alice Smith', $items[0]->getSubtitle());
|
||||
}
|
||||
|
||||
public function testGuestThreadShowsDisplayNameWithGuestLabel(): void {
|
||||
$guestId = 'guest:abcdef1234567890abcdef1234567890';
|
||||
$thread = $this->createMockThread($guestId);
|
||||
|
||||
$this->widgetService->method('getRecentActivity')->willReturn([
|
||||
['type' => 'thread', 'item' => $thread, 'thread' => $thread, 'createdAt' => time()],
|
||||
]);
|
||||
|
||||
$this->userService->expects($this->once())
|
||||
->method('enrichMultipleUsers')
|
||||
->with([$guestId])
|
||||
->willReturn([
|
||||
$guestId => ['displayName' => 'BrightMountain42', 'isGuest' => true],
|
||||
]);
|
||||
|
||||
$result = $this->widget->getItemsV2('viewer');
|
||||
$items = $result->getItems();
|
||||
|
||||
$this->assertCount(1, $items);
|
||||
$this->assertEquals('New thread by BrightMountain42 (Guest)', $items[0]->getSubtitle());
|
||||
}
|
||||
|
||||
public function testGuestReplyShowsDisplayNameWithGuestLabel(): void {
|
||||
$guestId = 'guest:abcdef1234567890abcdef1234567890';
|
||||
$thread = $this->createMockThread('alice');
|
||||
$post = $this->createMockPost($guestId);
|
||||
|
||||
$this->widgetService->method('getRecentActivity')->willReturn([
|
||||
['type' => 'reply', 'item' => $post, 'thread' => $thread, 'createdAt' => time()],
|
||||
]);
|
||||
|
||||
$this->userService->expects($this->once())
|
||||
->method('enrichMultipleUsers')
|
||||
->with([$guestId])
|
||||
->willReturn([
|
||||
$guestId => ['displayName' => 'SwiftRiver99', 'isGuest' => true],
|
||||
]);
|
||||
|
||||
$result = $this->widget->getItemsV2('viewer');
|
||||
$items = $result->getItems();
|
||||
|
||||
$this->assertCount(1, $items);
|
||||
$this->assertEquals('Reply by SwiftRiver99 (Guest)', $items[0]->getSubtitle());
|
||||
}
|
||||
|
||||
public function testRegularUserReplyDoesNotAppendGuestLabel(): void {
|
||||
$thread = $this->createMockThread('alice');
|
||||
$post = $this->createMockPost('bob');
|
||||
|
||||
$this->widgetService->method('getRecentActivity')->willReturn([
|
||||
['type' => 'reply', 'item' => $post, 'thread' => $thread, 'createdAt' => time()],
|
||||
]);
|
||||
|
||||
$this->userService->expects($this->once())
|
||||
->method('enrichMultipleUsers')
|
||||
->with(['bob'])
|
||||
->willReturn([
|
||||
'bob' => ['displayName' => 'Bob Jones', 'isGuest' => false],
|
||||
]);
|
||||
|
||||
$result = $this->widget->getItemsV2('viewer');
|
||||
$items = $result->getItems();
|
||||
|
||||
$this->assertCount(1, $items);
|
||||
$this->assertEquals('Reply by Bob Jones', $items[0]->getSubtitle());
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,42 @@ class PermissionMiddlewareTest extends TestCase {
|
||||
$this->middleware->beforeController($this->controller, 'methodWithoutPermissions');
|
||||
}
|
||||
|
||||
public function testUnauthenticatedUserWithGuestAccessDisabledAndPostMethodIsDenied(): void {
|
||||
$this->userSession->method('getUser')->willReturn(null);
|
||||
$this->config->method('getAppValueBool')
|
||||
->with('allow_guest_access', false, true)
|
||||
->willReturn(false);
|
||||
$this->request->method('getMethod')->willReturn('POST');
|
||||
|
||||
$this->expectException(OCSForbiddenException::class);
|
||||
$this->expectExceptionMessage('User not authenticated');
|
||||
$this->middleware->beforeController($this->controller, 'methodWithoutPermissions');
|
||||
}
|
||||
|
||||
public function testUnauthenticatedUserWithGuestAccessDisabledAndPutMethodIsDenied(): void {
|
||||
$this->userSession->method('getUser')->willReturn(null);
|
||||
$this->config->method('getAppValueBool')
|
||||
->with('allow_guest_access', false, true)
|
||||
->willReturn(false);
|
||||
$this->request->method('getMethod')->willReturn('PUT');
|
||||
|
||||
$this->expectException(OCSForbiddenException::class);
|
||||
$this->expectExceptionMessage('User not authenticated');
|
||||
$this->middleware->beforeController($this->controller, 'methodWithoutPermissions');
|
||||
}
|
||||
|
||||
public function testUnauthenticatedUserWithGuestAccessDisabledAndDeleteMethodIsDenied(): void {
|
||||
$this->userSession->method('getUser')->willReturn(null);
|
||||
$this->config->method('getAppValueBool')
|
||||
->with('allow_guest_access', false, true)
|
||||
->willReturn(false);
|
||||
$this->request->method('getMethod')->willReturn('DELETE');
|
||||
|
||||
$this->expectException(OCSForbiddenException::class);
|
||||
$this->expectExceptionMessage('User not authenticated');
|
||||
$this->middleware->beforeController($this->controller, 'methodWithoutPermissions');
|
||||
}
|
||||
|
||||
public function testUnauthenticatedUserWithGuestAccessEnabledAndGetMethodIsAllowed(): void {
|
||||
$this->userSession->method('getUser')->willReturn(null);
|
||||
$this->config->method('getAppValueBool')
|
||||
@@ -175,40 +211,40 @@ class PermissionMiddlewareTest extends TestCase {
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function testUnauthenticatedUserWithGuestAccessEnabledAndPostMethodIsDenied(): void {
|
||||
public function testUnauthenticatedUserWithGuestAccessEnabledAndPostMethodIsAllowed(): void {
|
||||
$this->userSession->method('getUser')->willReturn(null);
|
||||
$this->config->method('getAppValueBool')
|
||||
->with('allow_guest_access', false, true)
|
||||
->willReturn(true);
|
||||
$this->request->method('getMethod')->willReturn('POST');
|
||||
|
||||
$this->expectException(OCSForbiddenException::class);
|
||||
$this->expectExceptionMessage('User not authenticated');
|
||||
// Guest access enabled allows all HTTP methods (permission checks handle authorization)
|
||||
$this->middleware->beforeController($this->controller, 'methodWithoutPermissions');
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function testUnauthenticatedUserWithGuestAccessEnabledAndPutMethodIsDenied(): void {
|
||||
public function testUnauthenticatedUserWithGuestAccessEnabledAndPutMethodIsAllowed(): void {
|
||||
$this->userSession->method('getUser')->willReturn(null);
|
||||
$this->config->method('getAppValueBool')
|
||||
->with('allow_guest_access', false, true)
|
||||
->willReturn(true);
|
||||
$this->request->method('getMethod')->willReturn('PUT');
|
||||
|
||||
$this->expectException(OCSForbiddenException::class);
|
||||
$this->expectExceptionMessage('User not authenticated');
|
||||
// Guest access enabled allows all HTTP methods (permission checks handle authorization)
|
||||
$this->middleware->beforeController($this->controller, 'methodWithoutPermissions');
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function testUnauthenticatedUserWithGuestAccessEnabledAndDeleteMethodIsDenied(): void {
|
||||
public function testUnauthenticatedUserWithGuestAccessEnabledAndDeleteMethodIsAllowed(): void {
|
||||
$this->userSession->method('getUser')->willReturn(null);
|
||||
$this->config->method('getAppValueBool')
|
||||
->with('allow_guest_access', false, true)
|
||||
->willReturn(true);
|
||||
$this->request->method('getMethod')->willReturn('DELETE');
|
||||
|
||||
$this->expectException(OCSForbiddenException::class);
|
||||
$this->expectExceptionMessage('User not authenticated');
|
||||
// Guest access enabled allows all HTTP methods (permission checks handle authorization)
|
||||
$this->middleware->beforeController($this->controller, 'methodWithoutPermissions');
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function testGuestUserWithGlobalPermissionCheckUsesNullUserId(): void {
|
||||
|
||||
Reference in New Issue
Block a user