feat: edit history visibility settings

This commit is contained in:
2026-03-24 18:04:27 +02:00
parent b8a5ae5e04
commit 78052cda97
22 changed files with 608 additions and 31 deletions

View File

@@ -13,5 +13,5 @@ module.exports = {
}
return commands
},
'*Controller.php': [() => 'make openapi', () => 'git add openapi.json'],
'*Controller.php': [() => 'make openapi', () => 'git add openapi-*.json'],
}

View File

@@ -23,6 +23,8 @@ class ConfigLexicon implements ILexicon {
new Entry('subtitle', ValueType::STRING, 'Welcome to the forum!', 'Forum subtitle displayed below the title', lazy: true),
new Entry('allow_guest_access', ValueType::BOOL, false, 'Whether unauthenticated users can access the forum', lazy: true),
new Entry('is_initialized', ValueType::BOOL, false, 'Whether the forum has been initialized with seed data', lazy: true),
new Entry('public_edit_history', ValueType::BOOL, true, 'Whether all users can view edit history of posts', lazy: true),
new Entry('allow_edit_history_user_override', ValueType::BOOL, false, 'Whether users can hide their own edit history from others', lazy: true),
];
}
@@ -31,6 +33,7 @@ class ConfigLexicon implements ILexicon {
new Entry('auto_subscribe_created_threads', ValueType::BOOL, true, 'Automatically subscribe to threads the user creates'),
new Entry('auto_subscribe_replied_threads', ValueType::BOOL, false, 'Automatically subscribe to threads the user replies to'),
new Entry('upload_directory', ValueType::STRING, 'Forum', 'Directory in user storage for forum file uploads'),
new Entry('hide_edit_history', ValueType::BOOL, false, 'Whether to hide edit history from other users'),
];
}
}

View File

@@ -201,6 +201,8 @@ class AdminController extends OCSController {
* @param string|null $title Forum title
* @param string|null $subtitle Forum subtitle
* @param bool|null $allow_guest_access Allow unauthenticated users to view forum content
* @param bool|null $public_edit_history Whether all users can view edit history of posts
* @param bool|null $allow_edit_history_user_override Whether users can hide their own edit history from others
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>
*
* 200: Settings updated
@@ -208,7 +210,7 @@ class AdminController extends OCSController {
#[NoAdminRequired]
#[RequirePermission('canAccessAdminTools')]
#[ApiRoute(verb: 'PUT', url: '/api/admin/settings')]
public function updateSettings(?string $title = null, ?string $subtitle = null, ?bool $allow_guest_access = null): DataResponse {
public function updateSettings(?string $title = null, ?string $subtitle = null, ?bool $allow_guest_access = null, ?bool $public_edit_history = null, ?bool $allow_edit_history_user_override = null): DataResponse {
try {
// Build settings array with only non-null values
$settingsToUpdate = [];
@@ -221,6 +223,12 @@ class AdminController extends OCSController {
if ($allow_guest_access !== null) {
$settingsToUpdate[AdminSettingsService::SETTING_ALLOW_GUEST_ACCESS] = $allow_guest_access;
}
if ($public_edit_history !== null) {
$settingsToUpdate[AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY] = $public_edit_history;
}
if ($allow_edit_history_user_override !== null) {
$settingsToUpdate[AdminSettingsService::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE] = $allow_edit_history_user_override;
}
// Update settings and return all settings
$settings = $this->settingsService->updateSettings($settingsToUpdate);

View File

@@ -106,10 +106,13 @@ class PostController extends OCSController {
// Batch fetch author data (includes roles)
$authors = $this->userService->enrichMultipleUsers($authorIds);
// Get category ID for permission checks
$categoryId = $this->permissionService->getCategoryIdFromThread($threadId);
// Enrich posts with content, reactions, and pre-fetched author data
return new DataResponse(array_map(function ($p) use ($bbcodes, $reactionsByPostId, $currentUserId, $authors) {
return new DataResponse(array_map(function ($p) use ($bbcodes, $reactionsByPostId, $currentUserId, $authors, $categoryId) {
$postReactions = $reactionsByPostId[$p->getId()] ?? [];
return $this->postEnrichmentService->enrichPost($p, $bbcodes, $postReactions, $currentUserId, $authors[$p->getAuthorId()]);
return $this->postEnrichmentService->enrichPost($p, $bbcodes, $postReactions, $currentUserId, $authors[$p->getAuthorId()], $categoryId);
}, $posts));
} catch (\Exception $e) {
$this->logger->error('Error fetching posts by thread: ' . $e->getMessage());
@@ -206,6 +209,9 @@ class PostController extends OCSController {
// Batch fetch author data (includes roles)
$authors = $this->userService->enrichMultipleUsers($authorIds);
// Get category ID for permission checks
$categoryId = $this->permissionService->getCategoryIdFromThread($threadId);
// Enrich first post
$enrichedFirstPost = null;
if ($firstPost !== null) {
@@ -215,14 +221,15 @@ class PostController extends OCSController {
$bbcodes,
$firstPostReactions,
$currentUserId,
$authors[$firstPost->getAuthorId()] ?? null
$authors[$firstPost->getAuthorId()] ?? null,
$categoryId,
);
}
// Enrich replies
$enrichedReplies = array_map(function ($p) use ($bbcodes, $reactionsByPostId, $currentUserId, $authors) {
$enrichedReplies = array_map(function ($p) use ($bbcodes, $reactionsByPostId, $currentUserId, $authors, $categoryId) {
$postReactions = $reactionsByPostId[$p->getId()] ?? [];
return $this->postEnrichmentService->enrichPost($p, $bbcodes, $postReactions, $currentUserId, $authors[$p->getAuthorId()] ?? null);
return $this->postEnrichmentService->enrichPost($p, $bbcodes, $postReactions, $currentUserId, $authors[$p->getAuthorId()] ?? null, $categoryId);
}, $replies);
return new DataResponse([
@@ -283,11 +290,23 @@ class PostController extends OCSController {
// For posts by a single author, we can optimize by fetching author data once
$author = $this->userService->enrichUserData($authorId);
// Resolve category IDs for each post's thread (for edit history visibility)
$threadIds = array_unique(array_map(fn ($p) => $p->getThreadId(), $posts));
$categoryByThread = [];
foreach ($threadIds as $tid) {
try {
$categoryByThread[$tid] = $this->permissionService->getCategoryIdFromThread($tid);
} catch (\Exception $e) {
// Skip if thread not found
}
}
// Enrich posts with content, reactions, pre-fetched author data, and page number
$perPage = 20;
return new DataResponse(array_map(function ($p) use ($bbcodes, $reactionsByPostId, $currentUserId, $author, $perPage) {
return new DataResponse(array_map(function ($p) use ($bbcodes, $reactionsByPostId, $currentUserId, $author, $perPage, $categoryByThread) {
$postReactions = $reactionsByPostId[$p->getId()] ?? [];
$enriched = $this->postEnrichmentService->enrichPost($p, $bbcodes, $postReactions, $currentUserId, $author);
$categoryId = $categoryByThread[$p->getThreadId()] ?? null;
$enriched = $this->postEnrichmentService->enrichPost($p, $bbcodes, $postReactions, $currentUserId, $author, $categoryId);
// Calculate the page number for direct linking
if (!$p->getIsFirstPost()) {
@@ -321,7 +340,9 @@ class PostController extends OCSController {
public function show(int $id): DataResponse {
try {
$post = $this->postMapper->find($id);
return new DataResponse($this->postEnrichmentService->enrichPost($post));
$currentUserId = $this->userSession->getUser()?->getUID();
$categoryId = $this->permissionService->getCategoryIdFromPost($id);
return new DataResponse($this->postEnrichmentService->enrichPost($post, [], [], $currentUserId, null, $categoryId));
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Post not found'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
@@ -445,7 +466,9 @@ class PostController extends OCSController {
$this->logger->warning('Failed to send mention notifications: ' . $e->getMessage());
}
return new DataResponse($this->postEnrichmentService->enrichPost($createdPost), Http::STATUS_CREATED);
$currentUserId = $user?->getUID();
$categoryId = $this->permissionService->getCategoryIdFromThread($threadId);
return new DataResponse($this->postEnrichmentService->enrichPost($createdPost, [], [], $currentUserId, null, $categoryId), Http::STATUS_CREATED);
} catch (\Exception $e) {
$this->logger->error('Error creating post: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to create post'], Http::STATUS_INTERNAL_SERVER_ERROR);
@@ -517,7 +540,7 @@ class PostController extends OCSController {
}
}
return new DataResponse($this->postEnrichmentService->enrichPost($updatedPost));
return new DataResponse($this->postEnrichmentService->enrichPost($updatedPost, [], [], $user->getUID(), null, $categoryId));
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Post not found'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {

View File

@@ -8,6 +8,9 @@ declare(strict_types=1);
namespace OCA\Forum\Controller;
use OCA\Forum\Attribute\RequirePermission;
use OCA\Forum\Db\PostMapper;
use OCA\Forum\Service\EditHistoryVisibilityService;
use OCA\Forum\Service\PermissionService;
use OCA\Forum\Service\PostHistoryService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
@@ -23,7 +26,11 @@ class PostHistoryController extends OCSController {
string $appName,
IRequest $request,
private PostHistoryService $postHistoryService,
private PostMapper $postMapper,
private PermissionService $permissionService,
private EditHistoryVisibilityService $editHistoryVisibilityService,
private LoggerInterface $logger,
private ?string $userId,
) {
parent::__construct($appName, $request);
}
@@ -44,6 +51,13 @@ class PostHistoryController extends OCSController {
#[ApiRoute(verb: 'GET', url: '/api/posts/{postId}/history')]
public function getHistory(int $postId): DataResponse {
try {
$post = $this->postMapper->find($postId);
$categoryId = $this->permissionService->getCategoryIdFromPost($postId);
if (!$this->editHistoryVisibilityService->canViewEditHistory($this->userId, $post->getAuthorId(), $categoryId)) {
return new DataResponse(['error' => 'Insufficient permissions to view edit history'], Http::STATUS_FORBIDDEN);
}
$history = $this->postHistoryService->getPostHistory($postId);
return new DataResponse($history);
} catch (DoesNotExistException $e) {

View File

@@ -24,12 +24,20 @@ class AdminSettingsService {
/** Setting key for initialization status */
public const SETTING_IS_INITIALIZED = 'is_initialized';
/** Setting key for public edit history */
public const SETTING_PUBLIC_EDIT_HISTORY = 'public_edit_history';
/** Setting key for allowing user override of edit history visibility */
public const SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE = 'allow_edit_history_user_override';
/** @var array<string> List of valid setting keys */
private const VALID_KEYS = [
self::SETTING_TITLE,
self::SETTING_SUBTITLE,
self::SETTING_ALLOW_GUEST_ACCESS,
self::SETTING_IS_INITIALIZED,
self::SETTING_PUBLIC_EDIT_HISTORY,
self::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE,
];
public function __construct(
@@ -51,6 +59,8 @@ class AdminSettingsService {
self::SETTING_SUBTITLE => $this->l10n->t('Welcome to the forum!'),
self::SETTING_ALLOW_GUEST_ACCESS => false,
self::SETTING_IS_INITIALIZED => false,
self::SETTING_PUBLIC_EDIT_HISTORY => true,
self::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE => false,
default => null,
};
}
@@ -86,7 +96,9 @@ class AdminSettingsService {
return match ($key) {
self::SETTING_ALLOW_GUEST_ACCESS,
self::SETTING_IS_INITIALIZED => $this->config->getAppValueBool($key, $default, true),
self::SETTING_IS_INITIALIZED,
self::SETTING_PUBLIC_EDIT_HISTORY,
self::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE => $this->config->getAppValueBool($key, $default, true),
default => $this->config->getAppValueString($key, $default, true),
};
}
@@ -127,7 +139,8 @@ class AdminSettingsService {
throw new \InvalidArgumentException("Invalid setting key: $key");
}
if ($key === self::SETTING_ALLOW_GUEST_ACCESS || $key === self::SETTING_IS_INITIALIZED) {
if ($key === self::SETTING_ALLOW_GUEST_ACCESS || $key === self::SETTING_IS_INITIALIZED
|| $key === self::SETTING_PUBLIC_EDIT_HISTORY || $key === self::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE) {
$this->config->setAppValueBool($key, (bool)$value, true);
} else {
$this->config->setAppValueString($key, (string)$value, true);

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Service;
/**
* Centralized logic for determining whether a user can view edit history of a post
*/
class EditHistoryVisibilityService {
public function __construct(
private AdminSettingsService $adminSettingsService,
private UserPreferencesService $userPreferencesService,
private PermissionService $permissionService,
) {
}
/**
* Determine whether a viewer can see the edit history of a post
*
* @param string|null $viewerId The user viewing (null for guests)
* @param string $postAuthorId The author of the post
* @param int $categoryId The category the post belongs to
* @return bool Whether the viewer can see the edit history
*/
public function canViewEditHistory(?string $viewerId, string $postAuthorId, int $categoryId): bool {
// Owners always see their own history
if ($viewerId !== null && $viewerId === $postAuthorId) {
return true;
}
// Global admins/moderators always see history
if ($viewerId !== null && $this->permissionService->hasAdminOrModeratorRole($viewerId)) {
return true;
}
// Users with canModerate on the specific category always see history
if ($viewerId !== null && $this->permissionService->hasCategoryPermission($viewerId, $categoryId, 'canModerate')) {
return true;
}
// Check admin setting: public edit history
$publicEditHistory = (bool)$this->adminSettingsService->getSetting(
AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY
);
if (!$publicEditHistory) {
return false;
}
// Check user override: post author can hide their edit history
$allowOverride = (bool)$this->adminSettingsService->getSetting(
AdminSettingsService::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE
);
if ($allowOverride) {
$hideEditHistory = (bool)$this->userPreferencesService->getPreference(
$postAuthorId,
UserPreferencesService::PREF_HIDE_EDIT_HISTORY
);
if ($hideEditHistory) {
return false;
}
}
return true;
}
}

View File

@@ -17,6 +17,7 @@ class PostEnrichmentService {
private BBCodeService $bbCodeService,
private BBCodeMapper $bbCodeMapper,
private UserService $userService,
private EditHistoryVisibilityService $editHistoryVisibilityService,
) {
}
@@ -28,6 +29,7 @@ class PostEnrichmentService {
* @param array $reactions Post reactions
* @param string|null $currentUserId Current user ID for reaction status
* @param array|null $author Optional pre-loaded author data
* @param int|null $categoryId Category ID for permission checks
* @return array Enriched post data
*/
public function enrichPost(
@@ -36,6 +38,7 @@ class PostEnrichmentService {
array $reactions = [],
?string $currentUserId = null,
?array $author = null,
?int $categoryId = null,
): array {
if (!is_array($post)) {
$post = $post->jsonSerialize();
@@ -57,6 +60,17 @@ class PostEnrichmentService {
// Add reactions (grouped by emoji)
$post['reactions'] = $this->groupReactions($reactions, $currentUserId);
// Add canViewHistory flag
if ($categoryId !== null && !empty($post['isEdited'])) {
$post['canViewHistory'] = $this->editHistoryVisibilityService->canViewEditHistory(
$currentUserId,
$post['authorId'],
$categoryId,
);
} else {
$post['canViewHistory'] = false;
}
return $post;
}

View File

@@ -26,12 +26,16 @@ class UserPreferencesService {
/** Preference key for user signature (stored in forum_users table) */
public const PREF_SIGNATURE = 'signature';
/** Preference key for hiding edit history from others */
public const PREF_HIDE_EDIT_HISTORY = 'hide_edit_history';
/** @var array<string, mixed> Default preference values */
private const DEFAULTS = [
self::PREF_AUTO_SUBSCRIBE_CREATED_THREADS => true,
self::PREF_AUTO_SUBSCRIBE_REPLIED_THREADS => false,
self::PREF_UPLOAD_DIRECTORY => 'Forum',
self::PREF_SIGNATURE => '',
self::PREF_HIDE_EDIT_HISTORY => false,
];
/** @var array<string> List of valid preference keys */
@@ -40,6 +44,7 @@ class UserPreferencesService {
self::PREF_AUTO_SUBSCRIBE_REPLIED_THREADS,
self::PREF_UPLOAD_DIRECTORY,
self::PREF_SIGNATURE,
self::PREF_HIDE_EDIT_HISTORY,
];
/** @var array<string> Keys stored in forum_users table instead of config */

View File

@@ -371,6 +371,18 @@
"nullable": true,
"default": null,
"description": "Allow unauthenticated users to view forum content"
},
"public_edit_history": {
"type": "boolean",
"nullable": true,
"default": null,
"description": "Whether all users can view edit history of posts"
},
"allow_edit_history_user_override": {
"type": "boolean",
"nullable": true,
"default": null,
"description": "Whether users can hide their own edit history from others"
}
}
}

View File

@@ -371,6 +371,18 @@
"nullable": true,
"default": null,
"description": "Allow unauthenticated users to view forum content"
},
"public_edit_history": {
"type": "boolean",
"nullable": true,
"default": null,
"description": "Whether all users can view edit history of posts"
},
"allow_edit_history_user_override": {
"type": "boolean",
"nullable": true,
"default": null,
"description": "Whether users can hide their own edit history from others"
}
}
}

View File

@@ -223,8 +223,8 @@ describe('PostCard', () => {
expect(buttons.some((b) => b.text().includes('Edit'))).toBe(false)
})
it('should show view history button when post is edited', () => {
const post = createMockPost({ isEdited: true })
it('should show view history button when canViewHistory is true', () => {
const post = createMockPost({ isEdited: true, canViewHistory: true })
const wrapper = mount(PostCard, {
props: { post },
})
@@ -232,8 +232,17 @@ describe('PostCard', () => {
expect(buttons.some((b) => b.text().includes('View edit history'))).toBe(true)
})
it('should not show view history button when canViewHistory is false', () => {
const post = createMockPost({ isEdited: true, canViewHistory: false })
const wrapper = mount(PostCard, {
props: { post },
})
const buttons = wrapper.findAll('.nc-action-button')
expect(buttons.some((b) => b.text().includes('View edit history'))).toBe(false)
})
it('should not show view history button when post is not edited', () => {
const post = createMockPost({ isEdited: false })
const post = createMockPost({ isEdited: false, canViewHistory: false })
const wrapper = mount(PostCard, {
props: { post },
})

View File

@@ -42,7 +42,7 @@
</template>
{{ strings.delete }}
</NcActionButton>
<NcActionButton v-if="post.isEdited" @click="handleViewHistory">
<NcActionButton v-if="post.canViewHistory" @click="handleViewHistory">
<template #icon>
<HistoryIcon :size="20" />
</template>

View File

@@ -13,6 +13,10 @@ export interface PublicSettings {
allow_guest_access: boolean
/** Whether the forum has been initialized */
is_initialized: boolean
/** Whether all users can view edit history of posts */
public_edit_history: boolean
/** Whether users can hide their own edit history from others */
allow_edit_history_user_override: boolean
}
const settings = ref<PublicSettings | null>(null)

View File

@@ -90,6 +90,7 @@ export interface Post {
// Thread context (added by SearchController for search results)
threadTitle?: string
threadSlug?: string
canViewHistory?: boolean
// Client-side enrichment
reactions?: Array<{
emoji: string

View File

@@ -97,6 +97,19 @@
</div>
</div>
<!-- Privacy Section -->
<div v-if="showPrivacySection" class="form-section">
<h3>{{ strings.privacyTitle }}</h3>
<p class="section-description muted">{{ strings.privacyDesc }}</p>
<div class="preference-item">
<NcCheckboxRadioSwitch v-model="formData.hide_edit_history">
{{ strings.hideEditHistory }}
</NcCheckboxRadioSwitch>
<p class="preference-hint">{{ strings.hideEditHistoryHint }}</p>
</div>
</div>
<!-- Actions -->
<div class="form-actions">
<NcButton :disabled="saving || !hasChanges" @click="resetForm">
@@ -138,12 +151,14 @@ import FolderIcon from '@icons/Folder.vue'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
import { getFilePickerBuilder, FilePickerType } from '@nextcloud/dialogs'
import { usePublicSettings } from '@/composables/usePublicSettings'
interface UserPreferences {
auto_subscribe_created_threads: boolean
auto_subscribe_replied_threads: boolean
upload_directory: string
signature: string
hide_edit_history: boolean
}
export default defineComponent({
@@ -162,6 +177,14 @@ export default defineComponent({
CheckIcon,
FolderIcon,
},
setup() {
const { settings: publicSettings, fetchPublicSettings } = usePublicSettings()
fetchPublicSettings()
return {
publicSettings,
}
},
data() {
return {
loading: false,
@@ -173,12 +196,14 @@ export default defineComponent({
auto_subscribe_replied_threads: false,
upload_directory: 'Forum',
signature: '',
hide_edit_history: false,
} as UserPreferences,
formData: {
auto_subscribe_created_threads: true,
auto_subscribe_replied_threads: false,
upload_directory: 'Forum',
signature: '',
hide_edit_history: false,
} as UserPreferences,
strings: {
@@ -219,6 +244,13 @@ export default defineComponent({
signatureLabel: t('forum', 'Signature'),
signatureHint: t('forum', 'You can use BBCode formatting in your signature'),
signaturePlaceholder: t('forum', 'Enter your signature …'),
privacyTitle: t('forum', 'Privacy'),
privacyDesc: t('forum', 'Control the visibility of your activity'),
hideEditHistory: t('forum', 'Hide my edit history from other accounts'),
hideEditHistoryHint: t(
'forum',
'When enabled, other accounts cannot view the edit history of your posts. Administration and moderators can always view edit history.',
),
},
}
},
@@ -230,7 +262,14 @@ export default defineComponent({
this.formData.auto_subscribe_replied_threads !==
this.originalData.auto_subscribe_replied_threads ||
this.formData.upload_directory !== this.originalData.upload_directory ||
this.formData.signature !== this.originalData.signature
this.formData.signature !== this.originalData.signature ||
this.formData.hide_edit_history !== this.originalData.hide_edit_history
)
},
showPrivacySection(): boolean {
return (
!!this.publicSettings?.public_edit_history &&
!!this.publicSettings?.allow_edit_history_user_override
)
},
},

View File

@@ -57,6 +57,25 @@
</div>
</FormSection>
<FormSection :title="strings.editHistoryTitle" :subtitle="strings.editHistoryDesc">
<div class="form-group">
<NcCheckboxRadioSwitch v-model="formData.public_edit_history" type="switch">
{{ strings.publicEditHistory }}
</NcCheckboxRadioSwitch>
<p class="hint">{{ strings.publicEditHistoryHint }}</p>
</div>
<div v-if="formData.public_edit_history" class="form-group">
<NcCheckboxRadioSwitch
v-model="formData.allow_edit_history_user_override"
type="switch"
>
{{ strings.allowEditHistoryUserOverride }}
</NcCheckboxRadioSwitch>
<p class="hint">{{ strings.allowEditHistoryUserOverrideHint }}</p>
</div>
</FormSection>
<!-- Actions -->
<div class="form-actions">
<NcButton :disabled="saving || !hasChanges" @click="resetForm">
@@ -101,6 +120,8 @@ interface Settings {
title: string
subtitle: string
allow_guest_access: boolean
public_edit_history: boolean
allow_edit_history_user_override: boolean
}
export default defineComponent({
@@ -134,11 +155,15 @@ export default defineComponent({
title: '',
subtitle: '',
allow_guest_access: false,
public_edit_history: true,
allow_edit_history_user_override: false,
} as Settings,
formData: {
title: '',
subtitle: '',
allow_guest_access: false,
public_edit_history: true,
allow_edit_history_user_override: false,
} as Settings,
strings: {
@@ -162,6 +187,18 @@ export default defineComponent({
'forum',
'When enabled, unauthenticated users can view forum content in read-only mode',
),
editHistoryTitle: t('forum', 'Edit history'),
editHistoryDesc: t('forum', 'Control who can view the edit history of posts'),
publicEditHistory: t('forum', 'Allow all accounts to view edit history'),
publicEditHistoryHint: t(
'forum',
'When enabled, any account can view the edit history of any post. When disabled, only post owners can view their own edit history. Administration and moderators can always view edit history.',
),
allowEditHistoryUserOverride: t('forum', 'Allow accounts to hide their own edit history'),
allowEditHistoryUserOverrideHint: t(
'forum',
'When enabled, accounts can choose to hide their edit history from other accounts in their preferences.',
),
save: t('forum', 'Save'),
cancel: t('forum', 'Cancel'),
saveSuccess: t('forum', 'Settings saved'),
@@ -173,7 +210,10 @@ export default defineComponent({
return (
this.formData.title !== this.originalData.title ||
this.formData.subtitle !== this.originalData.subtitle ||
this.formData.allow_guest_access !== this.originalData.allow_guest_access
this.formData.allow_guest_access !== this.originalData.allow_guest_access ||
this.formData.public_edit_history !== this.originalData.public_edit_history ||
this.formData.allow_edit_history_user_override !==
this.originalData.allow_edit_history_user_override
)
},
},

View File

@@ -81,6 +81,93 @@ class AdminControllerTest extends TestCase {
);
}
// ── Settings tests ───────────────────────────────────────────────
public function testGetSettingsReturnsAllSettings(): void {
$allSettings = [
'title' => 'My Forum',
'subtitle' => 'Welcome!',
'allow_guest_access' => false,
'is_initialized' => true,
'public_edit_history' => true,
'allow_edit_history_user_override' => false,
];
$this->settingsService->expects($this->once())
->method('getAllSettings')
->willReturn($allSettings);
$response = $this->controller->getSettings();
$this->assertEquals(200, $response->getStatus());
$this->assertEquals($allSettings, $response->getData());
}
public function testUpdateSettingsPassesAllFieldsToService(): void {
$expectedUpdate = [
AdminSettingsService::SETTING_TITLE => 'New Title',
AdminSettingsService::SETTING_SUBTITLE => 'New Subtitle',
AdminSettingsService::SETTING_ALLOW_GUEST_ACCESS => true,
AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY => false,
AdminSettingsService::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE => true,
];
$this->settingsService->expects($this->once())
->method('updateSettings')
->with($expectedUpdate)
->willReturn(array_merge($expectedUpdate, ['is_initialized' => true]));
$response = $this->controller->updateSettings(
'New Title',
'New Subtitle',
true,
false,
true,
);
$this->assertEquals(200, $response->getStatus());
}
public function testUpdateSettingsOmitsNullValues(): void {
$this->settingsService->expects($this->once())
->method('updateSettings')
->with($this->callback(function (array $settings) {
// Only public_edit_history and allow_edit_history_user_override should be present
return count($settings) === 2
&& array_key_exists(AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY, $settings)
&& array_key_exists(AdminSettingsService::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE, $settings)
&& $settings[AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY] === true
&& $settings[AdminSettingsService::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE] === true;
}))
->willReturn([]);
$response = $this->controller->updateSettings(
null,
null,
null,
true,
true,
);
$this->assertEquals(200, $response->getStatus());
}
public function testUpdateSettingsHandlesException(): void {
$this->settingsService->expects($this->once())
->method('updateSettings')
->willThrowException(new \Exception('DB error'));
$this->logger->expects($this->once())
->method('error')
->with($this->stringContains('Error updating settings'));
$response = $this->controller->updateSettings('Title');
$this->assertEquals(500, $response->getStatus());
}
// ── Dashboard tests ─────────────────────────────────────────────
public function testDashboardEnrichesContributorsWithDisplayNames(): void {
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn('admin');

View File

@@ -6,6 +6,10 @@ namespace OCA\Forum\Tests\Controller;
use OCA\Forum\AppInfo\Application;
use OCA\Forum\Controller\PostHistoryController;
use OCA\Forum\Db\Post;
use OCA\Forum\Db\PostMapper;
use OCA\Forum\Service\EditHistoryVisibilityService;
use OCA\Forum\Service\PermissionService;
use OCA\Forum\Service\PostHistoryService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
@@ -18,6 +22,12 @@ class PostHistoryControllerTest extends TestCase {
private PostHistoryController $controller;
/** @var PostHistoryService&MockObject */
private PostHistoryService $postHistoryService;
/** @var PostMapper&MockObject */
private PostMapper $postMapper;
/** @var PermissionService&MockObject */
private PermissionService $permissionService;
/** @var EditHistoryVisibilityService&MockObject */
private EditHistoryVisibilityService $editHistoryVisibilityService;
/** @var LoggerInterface&MockObject */
private LoggerInterface $logger;
/** @var IRequest&MockObject */
@@ -26,18 +36,37 @@ class PostHistoryControllerTest extends TestCase {
protected function setUp(): void {
$this->request = $this->createMock(IRequest::class);
$this->postHistoryService = $this->createMock(PostHistoryService::class);
$this->postMapper = $this->createMock(PostMapper::class);
$this->permissionService = $this->createMock(PermissionService::class);
$this->editHistoryVisibilityService = $this->createMock(EditHistoryVisibilityService::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->controller = new PostHistoryController(
Application::APP_ID,
$this->request,
$this->postHistoryService,
$this->logger
$this->postMapper,
$this->permissionService,
$this->editHistoryVisibilityService,
$this->logger,
'user1',
);
}
private function setUpPermissionCheck(int $postId, string $authorId, int $categoryId, bool $canView): void {
$post = new Post();
$post->setId($postId);
$post->setAuthorId($authorId);
$this->postMapper->method('find')->with($postId)->willReturn($post);
$this->permissionService->method('getCategoryIdFromPost')->with($postId)->willReturn($categoryId);
$this->editHistoryVisibilityService->method('canViewEditHistory')
->with('user1', $authorId, $categoryId)
->willReturn($canView);
}
public function testGetHistoryReturnsHistorySuccessfully(): void {
$postId = 1;
$this->setUpPermissionCheck($postId, 'user1', 10, true);
$historyData = [
'current' => [
@@ -83,11 +112,19 @@ class PostHistoryControllerTest extends TestCase {
$this->assertCount(2, $data['history']);
}
public function testGetHistoryReturnsForbiddenWhenNotAllowed(): void {
$postId = 1;
$this->setUpPermissionCheck($postId, 'other_user', 10, false);
$response = $this->controller->getHistory($postId);
$this->assertEquals(Http::STATUS_FORBIDDEN, $response->getStatus());
}
public function testGetHistoryReturnsNotFoundWhenPostDoesNotExist(): void {
$postId = 999;
$this->postHistoryService->expects($this->once())
->method('getPostHistory')
$this->postMapper->method('find')
->with($postId)
->willThrowException(new DoesNotExistException('Post not found'));
@@ -99,6 +136,7 @@ class PostHistoryControllerTest extends TestCase {
public function testGetHistoryReturnsEmptyHistoryForNeverEditedPost(): void {
$postId = 1;
$this->setUpPermissionCheck($postId, 'user1', 10, true);
$historyData = [
'current' => [
@@ -127,6 +165,7 @@ class PostHistoryControllerTest extends TestCase {
public function testGetHistoryHandlesException(): void {
$postId = 1;
$this->setUpPermissionCheck($postId, 'user1', 10, true);
$this->postHistoryService->expects($this->once())
->method('getPostHistory')
@@ -145,6 +184,7 @@ class PostHistoryControllerTest extends TestCase {
public function testGetHistoryShowsModeratorEdits(): void {
$postId = 1;
$this->setUpPermissionCheck($postId, 'user1', 10, true);
$historyData = [
'current' => [

View File

@@ -49,12 +49,14 @@ class AdminSettingsServiceTest extends TestCase {
};
});
$this->config->expects($this->exactly(2))
$this->config->expects($this->exactly(4))
->method('getAppValueBool')
->willReturnCallback(function ($key, $default, $lazy) {
return match ($key) {
AdminSettingsService::SETTING_ALLOW_GUEST_ACCESS => true,
AdminSettingsService::SETTING_IS_INITIALIZED => false,
AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY => true,
AdminSettingsService::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE => false,
default => $default,
};
});
@@ -62,11 +64,13 @@ class AdminSettingsServiceTest extends TestCase {
$result = $this->service->getAllSettings();
$this->assertIsArray($result);
$this->assertCount(4, $result);
$this->assertCount(6, $result);
$this->assertEquals('My Forum', $result[AdminSettingsService::SETTING_TITLE]);
$this->assertEquals('Welcome!', $result[AdminSettingsService::SETTING_SUBTITLE]);
$this->assertTrue($result[AdminSettingsService::SETTING_ALLOW_GUEST_ACCESS]);
$this->assertFalse($result[AdminSettingsService::SETTING_IS_INITIALIZED]);
$this->assertTrue($result[AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY]);
$this->assertFalse($result[AdminSettingsService::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE]);
}
public function testGetSettingReturnsCorrectStringValue(): void {
@@ -183,12 +187,14 @@ class AdminSettingsServiceTest extends TestCase {
};
});
$this->config->expects($this->exactly(2))
$this->config->expects($this->exactly(4))
->method('getAppValueBool')
->willReturnCallback(function ($key, $default, $lazy) {
return match ($key) {
AdminSettingsService::SETTING_ALLOW_GUEST_ACCESS => true,
AdminSettingsService::SETTING_IS_INITIALIZED => false,
AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY => true,
AdminSettingsService::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE => false,
default => $default,
};
});
@@ -232,12 +238,14 @@ class AdminSettingsServiceTest extends TestCase {
};
});
$this->config->expects($this->exactly(2))
$this->config->expects($this->exactly(4))
->method('getAppValueBool')
->willReturnCallback(function ($key, $default, $lazy) {
return match ($key) {
AdminSettingsService::SETTING_ALLOW_GUEST_ACCESS => false,
AdminSettingsService::SETTING_IS_INITIALIZED => false,
AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY => true,
AdminSettingsService::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE => false,
default => $default,
};
});

View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace OCA\Forum\Tests\Service;
use OCA\Forum\Service\AdminSettingsService;
use OCA\Forum\Service\EditHistoryVisibilityService;
use OCA\Forum\Service\PermissionService;
use OCA\Forum\Service\UserPreferencesService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class EditHistoryVisibilityServiceTest extends TestCase {
private EditHistoryVisibilityService $service;
/** @var AdminSettingsService&MockObject */
private AdminSettingsService $adminSettingsService;
/** @var UserPreferencesService&MockObject */
private UserPreferencesService $userPreferencesService;
/** @var PermissionService&MockObject */
private PermissionService $permissionService;
private const CATEGORY_ID = 10;
protected function setUp(): void {
$this->adminSettingsService = $this->createMock(AdminSettingsService::class);
$this->userPreferencesService = $this->createMock(UserPreferencesService::class);
$this->permissionService = $this->createMock(PermissionService::class);
$this->service = new EditHistoryVisibilityService(
$this->adminSettingsService,
$this->userPreferencesService,
$this->permissionService,
);
}
public function testOwnerCanAlwaysSeeOwnHistory(): void {
// No admin settings or permissions needed — owner always sees own history
$this->assertTrue(
$this->service->canViewEditHistory('user1', 'user1', self::CATEGORY_ID)
);
}
public function testModeratorCanAlwaysSeeHistory(): void {
$this->permissionService->method('hasCategoryPermission')
->with('mod1', self::CATEGORY_ID, 'canModerate')
->willReturn(true);
$this->permissionService->method('hasAdminOrModeratorRole')
->willReturn(false);
$this->assertTrue(
$this->service->canViewEditHistory('mod1', 'user1', self::CATEGORY_ID)
);
}
public function testAdminRoleCanAlwaysSeeHistory(): void {
$this->permissionService->method('hasCategoryPermission')
->willReturn(false);
$this->permissionService->method('hasAdminOrModeratorRole')
->with('admin1')
->willReturn(true);
$this->assertTrue(
$this->service->canViewEditHistory('admin1', 'user1', self::CATEGORY_ID)
);
}
public function testPublicEditHistoryOffBlocksOtherUsers(): void {
$this->permissionService->method('hasCategoryPermission')->willReturn(false);
$this->permissionService->method('hasAdminOrModeratorRole')->willReturn(false);
$this->adminSettingsService->method('getSetting')
->with(AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY)
->willReturn(false);
$this->assertFalse(
$this->service->canViewEditHistory('viewer1', 'author1', self::CATEGORY_ID)
);
}
public function testPublicEditHistoryOnAllowsOtherUsers(): void {
$this->permissionService->method('hasCategoryPermission')->willReturn(false);
$this->permissionService->method('hasAdminOrModeratorRole')->willReturn(false);
$this->adminSettingsService->method('getSetting')
->willReturnMap([
[AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY, true],
[AdminSettingsService::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE, false],
]);
$this->assertTrue(
$this->service->canViewEditHistory('viewer1', 'author1', self::CATEGORY_ID)
);
}
public function testUserOverrideHidesHistoryFromOthers(): void {
$this->permissionService->method('hasCategoryPermission')->willReturn(false);
$this->permissionService->method('hasAdminOrModeratorRole')->willReturn(false);
$this->adminSettingsService->method('getSetting')
->willReturnMap([
[AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY, true],
[AdminSettingsService::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE, true],
]);
$this->userPreferencesService->method('getPreference')
->with('author1', UserPreferencesService::PREF_HIDE_EDIT_HISTORY)
->willReturn(true);
$this->assertFalse(
$this->service->canViewEditHistory('viewer1', 'author1', self::CATEGORY_ID)
);
}
public function testUserOverrideDoesNotAffectOwner(): void {
// Owner always sees own history regardless of override setting
$this->assertTrue(
$this->service->canViewEditHistory('author1', 'author1', self::CATEGORY_ID)
);
}
public function testUserOverrideDoesNotAffectModerator(): void {
$this->permissionService->method('hasCategoryPermission')
->with('mod1', self::CATEGORY_ID, 'canModerate')
->willReturn(true);
$this->permissionService->method('hasAdminOrModeratorRole')
->willReturn(false);
// Moderator can see history even when author has hidden it
$this->assertTrue(
$this->service->canViewEditHistory('mod1', 'author1', self::CATEGORY_ID)
);
}
public function testGuestCannotSeeHistoryWhenPublicOff(): void {
$this->adminSettingsService->method('getSetting')
->with(AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY)
->willReturn(false);
$this->assertFalse(
$this->service->canViewEditHistory(null, 'author1', self::CATEGORY_ID)
);
}
public function testGuestCanSeeHistoryWhenPublicOn(): void {
$this->adminSettingsService->method('getSetting')
->willReturnMap([
[AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY, true],
[AdminSettingsService::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE, false],
]);
$this->assertTrue(
$this->service->canViewEditHistory(null, 'author1', self::CATEGORY_ID)
);
}
public function testUserOverrideDisabledDoesNotHideHistory(): void {
$this->permissionService->method('hasCategoryPermission')->willReturn(false);
$this->permissionService->method('hasAdminOrModeratorRole')->willReturn(false);
$this->adminSettingsService->method('getSetting')
->willReturnMap([
[AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY, true],
[AdminSettingsService::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE, false],
]);
// Even if user has hide_edit_history pref set, when override is disabled, history is visible
$this->assertTrue(
$this->service->canViewEditHistory('viewer1', 'author1', self::CATEGORY_ID)
);
}
}

View File

@@ -42,7 +42,7 @@ class UserPreferencesServiceTest extends TestCase {
$userId = 'user1';
// Only config-based preferences (signature is from forum_users)
$this->config->expects($this->exactly(3))
$this->config->expects($this->exactly(4))
->method('getUserValue')
->willReturnCallback(function ($uid, $appId, $key, $default) use ($userId) {
$this->assertEquals($userId, $uid);
@@ -52,6 +52,7 @@ class UserPreferencesServiceTest extends TestCase {
UserPreferencesService::PREF_AUTO_SUBSCRIBE_CREATED_THREADS => 'true',
UserPreferencesService::PREF_AUTO_SUBSCRIBE_REPLIED_THREADS => 'false',
UserPreferencesService::PREF_UPLOAD_DIRECTORY => 'Forum',
UserPreferencesService::PREF_HIDE_EDIT_HISTORY => 'false',
default => $default,
};
});
@@ -59,11 +60,12 @@ class UserPreferencesServiceTest extends TestCase {
$result = $this->service->getAllPreferences($userId);
$this->assertIsArray($result);
$this->assertCount(4, $result);
$this->assertCount(5, $result);
$this->assertTrue($result[UserPreferencesService::PREF_AUTO_SUBSCRIBE_CREATED_THREADS]);
$this->assertFalse($result[UserPreferencesService::PREF_AUTO_SUBSCRIBE_REPLIED_THREADS]);
$this->assertEquals('Forum', $result[UserPreferencesService::PREF_UPLOAD_DIRECTORY]);
$this->assertEquals('', $result[UserPreferencesService::PREF_SIGNATURE]);
$this->assertFalse($result[UserPreferencesService::PREF_HIDE_EDIT_HISTORY]);
}
public function testGetPreferenceReturnsCorrectValue(): void {
@@ -146,7 +148,7 @@ class UserPreferencesServiceTest extends TestCase {
}
});
$this->config->expects($this->exactly(3))
$this->config->expects($this->exactly(4))
->method('getUserValue')
->willReturnCallback(function ($uid, $appId, $key, $default) use ($userId) {
$this->assertEquals($userId, $uid);
@@ -156,6 +158,7 @@ class UserPreferencesServiceTest extends TestCase {
UserPreferencesService::PREF_AUTO_SUBSCRIBE_CREATED_THREADS => 'false',
UserPreferencesService::PREF_AUTO_SUBSCRIBE_REPLIED_THREADS => 'false',
UserPreferencesService::PREF_UPLOAD_DIRECTORY => 'Documents',
UserPreferencesService::PREF_HIDE_EDIT_HISTORY => 'false',
default => $default,
};
});
@@ -163,11 +166,12 @@ class UserPreferencesServiceTest extends TestCase {
$result = $this->service->updatePreferences($userId, $preferences);
$this->assertIsArray($result);
$this->assertCount(4, $result);
$this->assertCount(5, $result);
$this->assertFalse($result[UserPreferencesService::PREF_AUTO_SUBSCRIBE_CREATED_THREADS]);
$this->assertFalse($result[UserPreferencesService::PREF_AUTO_SUBSCRIBE_REPLIED_THREADS]);
$this->assertEquals('Documents', $result[UserPreferencesService::PREF_UPLOAD_DIRECTORY]);
$this->assertEquals('', $result[UserPreferencesService::PREF_SIGNATURE]);
$this->assertFalse($result[UserPreferencesService::PREF_HIDE_EDIT_HISTORY]);
}
public function testUpdatePreferencesThrowsExceptionForInvalidKey(): void {