feat: allow guests to post/reply when given permissions

This commit is contained in:
2026-03-18 00:08:30 +02:00
parent 53d789d1c7
commit 477e9e3dfd
38 changed files with 1840 additions and 289 deletions

View File

@@ -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());

View 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);
}
}
}

View File

@@ -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);

View File

@@ -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());

View File

@@ -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
View 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(),
];
}
}

View 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;
}
}

View File

@@ -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

View 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;
}
}

View 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);
}
}

View File

@@ -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;
}

View File

@@ -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": {}
}
}
}
}
}
}
}
}
}

View File

@@ -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": {}
}
}
}
}
}
}
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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 || []"
>

View File

@@ -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()

View File

@@ -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() {

View File

@@ -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;
* {

View File

@@ -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'],
}),
)

View File

@@ -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() {

View File

@@ -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: {

View File

@@ -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', () => {

View File

@@ -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
},
},

View 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,
}
}

View File

@@ -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

View File

@@ -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`,
)

View File

@@ -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)

View File

@@ -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

View File

@@ -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),

View File

@@ -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
}>

View File

@@ -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 {

View 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')
})
})
})

View 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']);
}
}

View File

@@ -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
);

View File

@@ -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
);

View 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());
}
}

View File

@@ -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 {