feat(permissions): publiclyVisible flag for note listing on explore

This field should allow the user to allow pages to be found
easily on the public explore page without exposing all pages directly.

Co-authored-by: Erik Michelson <github@erik.michelson.eu>
Signed-off-by: Erik Michelson <github@erik.michelson.eu>
Signed-off-by: Philip Molares <philip.molares@udo.edu>
This commit is contained in:
Philip Molares
2025-12-27 18:50:53 +01:00
parent 7426d8d885
commit a4c590230e
27 changed files with 517 additions and 86 deletions
@@ -23,6 +23,7 @@ import { ApiTags } from '@nestjs/swagger';
import { SessionGuard } from '../../../auth/session.guard';
import noteConfiguration, { NoteConfig } from '../../../config/note.config';
import { ChangeNoteOwnerDto } from '../../../dtos/change-note-owner.dto';
import { ChangeNoteVisibilityDto } from '../../../dtos/change-note-visibility.dto';
import { MediaUploadDto } from '../../../dtos/media-upload.dto';
import { NoteGroupPermissionEntryDto } from '../../../dtos/note-group-permission-entry.dto';
import { NoteGroupPermissionUpdateDto } from '../../../dtos/note-group-permission-update.dto';
@@ -267,11 +268,25 @@ export class NotesController {
async changeOwner(
@RequestNoteId() noteId: number,
@Body() changeNoteOwnerDto: ChangeNoteOwnerDto,
): Promise<NoteDto> {
): Promise<NotePermissionsDto> {
const newOwnerId = await this.userService.getUserIdByUsername(
changeNoteOwnerDto.owner,
);
await this.permissionService.changeOwner(noteId, newOwnerId);
return await this.noteService.toNoteDto(noteId);
return await this.permissionService.getPermissionsDtoForNote(noteId);
}
@UseInterceptors(GetNoteIdInterceptor)
@RequirePermission(PermissionLevel.FULL)
@Put(':noteAlias/metadata/permissions/visibility')
async changeVisibility(
@RequestNoteId() noteId: number,
@Body() changeNoteVisibilityDto: ChangeNoteVisibilityDto,
): Promise<NotePermissionsDto> {
await this.permissionService.changePubliclyVisible(
noteId,
changeNoteVisibilityDto.publiclyVisible,
);
return await this.permissionService.getPermissionsDtoForNote(noteId);
}
}
@@ -335,6 +335,30 @@ export class NotesController {
return await this.noteService.toNoteMetadataDto(noteId);
}
@UseInterceptors(GetNoteIdInterceptor)
@RequirePermission(PermissionLevel.FULL)
@Put(':noteAlias/metadata/permissions/visibility')
@OpenApi(
{
code: 200,
description: 'Changes the owner of the note',
schema: NoteSchema,
},
403,
404,
)
async change(
@RequestNoteId() noteId: number,
@Body('newPubliclyVisible') newPubliclyVisible: boolean,
): Promise<NoteMetadataDto> {
await this.permissionService.changePubliclyVisible(
noteId,
newPubliclyVisible,
);
return await this.noteService.toNoteMetadataDto(noteId);
}
@UseInterceptors(GetNoteIdInterceptor)
@RequirePermission(PermissionLevel.READ)
@Get(':noteAlias/revisions')
@@ -18,6 +18,7 @@ export function createDefaultMockNoteConfig(): NoteConfig {
default: {
everyone: PermissionLevel.READ,
loggedIn: PermissionLevel.WRITE,
publiclyVisible: false,
},
},
revisionRetentionDays: 0,
+14 -4
View File
@@ -20,6 +20,7 @@ describe('noteConfig', () => {
const wrongDefaultPermission = 'wrong';
const retentionDays = 30;
const persistInteval = 15;
const publiclyVisible = true;
describe('correctly parses config', () => {
it('when given correct and complete environment variables', () => {
@@ -32,6 +33,8 @@ describe('noteConfig', () => {
PermissionLevelNames[PermissionLevel.WRITE],
HD_NOTE_PERMISSIONS_DEFAULT_LOGGED_IN:
PermissionLevelNames[PermissionLevel.WRITE],
HD_NOTE_PERMISSIONS_DEFAULT_PUBLICLY_VISIBLE:
publiclyVisible.toString(),
HD_NOTE_REVISION_RETENTION_DAYS: retentionDays.toString(),
HD_NOTE_PERSIST_INTERVAL: persistInteval.toString(),
/* eslint-enable @typescript-eslint/naming-convention */
@@ -50,6 +53,9 @@ describe('noteConfig', () => {
expect(config.permissions.default.loggedIn).toEqual(
PermissionLevel.WRITE,
);
expect(config.permissions.default.publiclyVisible).toEqual(
publiclyVisible,
);
expect(config.permissions.maxGuestLevel).toEqual(guestAccess);
expect(config.revisionRetentionDays).toEqual(retentionDays);
expect(config.persistInterval).toEqual(persistInteval);
@@ -80,6 +86,7 @@ describe('noteConfig', () => {
expect(config.permissions.default.loggedIn).toEqual(
PermissionLevel.WRITE,
);
expect(config.permissions.default.publiclyVisible).toEqual(false);
expect(config.permissions.maxGuestLevel).toEqual(guestAccess);
restore();
});
@@ -110,7 +117,7 @@ describe('noteConfig', () => {
expect(config.permissions.default.loggedIn).toEqual(
PermissionLevel.WRITE,
);
expect(config.permissions.default.publiclyVisible).toEqual(false);
expect(config.permissions.maxGuestLevel).toEqual(guestAccess);
restore();
});
@@ -140,7 +147,7 @@ describe('noteConfig', () => {
expect(config.permissions.default.loggedIn).toEqual(
PermissionLevel.WRITE,
);
expect(config.permissions.default.publiclyVisible).toEqual(false);
expect(config.permissions.maxGuestLevel).toEqual(guestAccess);
restore();
});
@@ -167,7 +174,7 @@ describe('noteConfig', () => {
expect(config.maxLength).toEqual(maxLength);
expect(config.permissions.default.everyone).toEqual(PermissionLevel.READ);
expect(config.permissions.default.loggedIn).toEqual(PermissionLevel.READ);
expect(config.permissions.default.publiclyVisible).toEqual(false);
expect(config.permissions.maxGuestLevel).toEqual(PermissionLevel.READ);
restore();
});
@@ -196,7 +203,7 @@ describe('noteConfig', () => {
expect(config.permissions.default.loggedIn).toEqual(
PermissionLevel.WRITE,
);
expect(config.permissions.default.publiclyVisible).toEqual(false);
expect(config.permissions.maxGuestLevel).toEqual(guestAccess);
restore();
});
@@ -225,6 +232,7 @@ describe('noteConfig', () => {
expect(config.permissions.default.loggedIn).toEqual(
PermissionLevel.WRITE,
);
expect(config.permissions.default.publiclyVisible).toEqual(false);
expect(config.permissions.maxGuestLevel).toEqual(PermissionLevel.FULL);
restore();
});
@@ -255,6 +263,7 @@ describe('noteConfig', () => {
expect(config.permissions.default.loggedIn).toEqual(
PermissionLevel.WRITE,
);
expect(config.permissions.default.publiclyVisible).toEqual(false);
expect(config.permissions.maxGuestLevel).toEqual(guestAccess);
expect(config.revisionRetentionDays).toEqual(0);
restore();
@@ -286,6 +295,7 @@ describe('noteConfig', () => {
expect(config.permissions.default.loggedIn).toEqual(
PermissionLevel.WRITE,
);
expect(config.permissions.default.publiclyVisible).toEqual(false);
expect(config.persistInterval).toEqual(10);
restore();
});
+61 -53
View File
@@ -12,6 +12,7 @@ import { registerAs } from '@nestjs/config';
import z from 'zod';
import {
parseOptionalBoolean,
parseOptionalNumber,
printConfigErrorAndExit,
toArrayConfig,
@@ -21,64 +22,67 @@ import {
extractDescriptionFromZodIssue,
} from './zod-error-message';
const schema = z
.object({
forbiddenAliases: z
.array(z.string().min(1))
const schema = z.object({
forbiddenAliases: z
.array(z.string().min(1))
.optional()
.default([])
.describe('HD_NOTE_FORBIDDEN_ALIASES'),
maxLength: z
.number()
.int()
.positive()
.optional()
.default(100000)
.describe('HD_NOTE_MAX_LENGTH'),
permissions: z.object({
maxGuestLevel: z
.enum(PermissionLevelNames)
.optional()
.default([])
.describe('HD_NOTE_FORBIDDEN_ALIASES'),
maxLength: z
.number()
.int()
.positive()
.optional()
.default(100000)
.describe('HD_NOTE_MAX_LENGTH'),
permissions: z.object({
maxGuestLevel: z
.default(PermissionLevelNames[PermissionLevel.FULL])
.describe('HD_NOTE_PERMISSIONS_MAX_GUEST_LEVEL')
.transform((value) => PermissionLevelValues[value]),
default: z.object({
everyone: z
.enum(PermissionLevelNames)
.optional()
.default(PermissionLevelNames[PermissionLevel.FULL])
.describe('HD_NOTE_PERMISSIONS_MAX_GUEST_LEVEL')
.default(PermissionLevelNames[PermissionLevel.READ])
.describe('HD_NOTE_PERMISSIONS_DEFAULT_EVERYONE')
.refine((value) => {
// The PermissionLevel.FULL is reserved for owner permissions in the note context and is therefore forbidden
return value !== PermissionLevelNames[PermissionLevel.FULL];
})
.transform((value) => PermissionLevelValues[value]),
default: z.object({
everyone: z
.enum(PermissionLevelNames)
.optional()
.default(PermissionLevelNames[PermissionLevel.READ])
.describe('HD_NOTE_PERMISSIONS_DEFAULT_EVERYONE')
.refine((value) => {
// The PermissionLevel.FULL is reserved for owner permissions in the note context and is therefore forbidden
return value !== PermissionLevelNames[PermissionLevel.FULL];
})
.transform((value) => PermissionLevelValues[value]),
loggedIn: z
.enum(PermissionLevelNames)
.optional()
.default(PermissionLevelNames[PermissionLevel.WRITE])
.describe('HD_NOTE_PERMISSIONS_DEFAULT_LOGGED_IN')
.refine((value) => {
// The PermissionLevel.FULL is reserved for owner permissions in the note context and is therefore forbidden
return value !== PermissionLevelNames[PermissionLevel.FULL];
})
.transform((value) => PermissionLevelValues[value]),
}),
loggedIn: z
.enum(PermissionLevelNames)
.optional()
.default(PermissionLevelNames[PermissionLevel.WRITE])
.describe('HD_NOTE_PERMISSIONS_DEFAULT_LOGGED_IN')
.refine((value) => {
// The PermissionLevel.FULL is reserved for owner permissions in the note context and is therefore forbidden
return value !== PermissionLevelNames[PermissionLevel.FULL];
})
.transform((value) => PermissionLevelValues[value]),
publiclyVisible: z
.boolean()
.default(false)
.describe('HD_NOTE_PERMISSIONS_DEFAULT_PUBLICLY_VISIBLE'),
}),
revisionRetentionDays: z
.number()
.int()
.nonnegative()
.optional()
.default(0)
.describe('HD_NOTE_REVISION_RETENTION_DAYS'),
persistInterval: z.coerce
.number()
.int()
.min(0)
.default(10)
.describe('HD_NOTE_PERSIST_INTERVAL'),
})
}),
revisionRetentionDays: z
.number()
.int()
.nonnegative()
.optional()
.default(0)
.describe('HD_NOTE_REVISION_RETENTION_DAYS'),
persistInterval: z.coerce
.number()
.int()
.min(0)
.default(10)
.describe('HD_NOTE_PERSIST_INTERVAL'),
})
.superRefine((config, ctx) => {
const defaultEveryone = config.permissions.default.everyone;
const defaultLoggedIn = config.permissions.default.loggedIn;
@@ -120,6 +124,9 @@ export default registerAs('noteConfig', () => {
default: {
everyone: process.env.HD_NOTE_PERMISSIONS_DEFAULT_EVERYONE,
loggedIn: process.env.HD_NOTE_PERMISSIONS_DEFAULT_LOGGED_IN,
publiclyVisible: parseOptionalBoolean(
process.env.HD_NOTE_PERMISSIONS_DEFAULT_PUBLICLY_VISIBLE,
),
},
},
revisionRetentionDays: parseOptionalNumber(
@@ -128,6 +135,7 @@ export default registerAs('noteConfig', () => {
persistInterval: parseOptionalNumber(process.env.HD_NOTE_PERSIST_INTERVAL),
});
if (noteConfig.error) {
console.log(noteConfig.error);
const errorMessages = noteConfig.error.errors.map((issue) =>
extractDescriptionFromZodIssue(issue, 'HD_NOTE'),
);
@@ -92,6 +92,7 @@ const up = async function (knex) {
.references(FieldNameUser.id)
.inTable(TableUser)
.onDelete('CASCADE');
table.boolean(FieldNameNote.publiclyVisible).notNullable();
table.index([FieldNameNote.ownerId], 'idx_note_owner_id');
});
+3
View File
@@ -71,16 +71,19 @@ export async function seed(knex: Knex): Promise<void> {
[FieldNameNote.ownerId]: 1,
[FieldNameNote.version]: 2,
[FieldNameNote.createdAt]: dateTimeToDB(getCurrentDateTime()),
[FieldNameNote.publiclyVisible]: false,
},
{
[FieldNameNote.ownerId]: 2,
[FieldNameNote.version]: 2,
[FieldNameNote.createdAt]: dateTimeToDB(getCurrentDateTime()),
[FieldNameNote.publiclyVisible]: false,
},
{
[FieldNameNote.ownerId]: 2,
[FieldNameNote.version]: 2,
[FieldNameNote.createdAt]: dateTimeToDB(getCurrentDateTime()),
[FieldNameNote.publiclyVisible]: false,
},
]);
await knex(TableAlias).insert([
@@ -0,0 +1,11 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ChangeNoteVisibilitySchema } from '@hedgedoc/commons';
import { createZodDto } from 'nestjs-zod';
export class ChangeNoteVisibilityDto extends createZodDto(
ChangeNoteVisibilitySchema,
) {}
+15 -7
View File
@@ -263,35 +263,43 @@ describe('ExploreService', () => {
'',
'',
'',
/select "alias"."alias" as "primaryAlias", "revision"."title" as "title", "revision"."note_type" as "noteType", "user"."username" as "ownerUsername", "note"."created_at" as "createdAt", "revision"."created_at" as "lastChangedAt", "revision"."uuid" as "revisionUuid", "revision_tag"."tag" as "tag" from "note" inner join "note_group_permission" on "note"."id" = "note_group_permission"."note_id" inner join "alias" on "alias"."note_id" = "note"."id" inner join "user" on "user"."id" = "note"."owner_id" inner join \(select "uuid", "note_id" from \(select "uuid", "note_id", row_number\(\) over \(partition by "note_id" order by "created_at" desc\) as rn from "revision"\) where "rn" = \$1\) as "latest_revision" on "latest_revision"."note_id" = "note"."id" inner join "revision" on "revision"."note_id" = "note"."id" and "revision"."uuid" = "latest_revision"."uuid" left join "revision_tag" on "revision_tag"."revision_id" = "latest_revision"."uuid" where "alias"."is_primary" = \$2 and "note_group_permission"."group_id" = \$3 order by "revision"."created_at" desc limit \$4/,
[1, true, mockEveryoneGroupId, ENTRIES_PER_PAGE_LIMIT],
/select "alias"."alias" as "primaryAlias", "revision"."title" as "title", "revision"."note_type" as "noteType", "user"."username" as "ownerUsername", "note"."created_at" as "createdAt", "revision"."created_at" as "lastChangedAt", "revision"."uuid" as "revisionUuid", "revision_tag"."tag" as "tag" from "note" inner join "note_group_permission" on "note"."id" = "note_group_permission"."note_id" inner join "alias" on "alias"."note_id" = "note"."id" inner join "user" on "user"."id" = "note"."owner_id" inner join \(select "uuid", "note_id" from \(select "uuid", "note_id", row_number\(\) over \(partition by "note_id" order by "created_at" desc\) as rn from "revision"\) where "rn" = \$1\) as "latest_revision" on "latest_revision"."note_id" = "note"."id" inner join "revision" on "revision"."note_id" = "note"."id" and "revision"."uuid" = "latest_revision"."uuid" left join "revision_tag" on "revision_tag"."revision_id" = "latest_revision"."uuid" where "alias"."is_primary" = \$2 and "note_group_permission"."group_id" = \$3 and "note"."publicly_visible" = \$4 order by "revision"."created_at" desc limit \$5/,
[1, true, mockEveryoneGroupId, true, ENTRIES_PER_PAGE_LIMIT],
],
[
'type filter',
NoteType.SLIDE,
'',
'',
/select "alias"."alias" as "primaryAlias", "revision"."title" as "title", "revision"."note_type" as "noteType", "user"."username" as "ownerUsername", "note"."created_at" as "createdAt", "revision"."created_at" as "lastChangedAt", "revision"."uuid" as "revisionUuid", "revision_tag"."tag" as "tag" from "note" inner join "note_group_permission" on "note"."id" = "note_group_permission"."note_id" inner join "alias" on "alias"."note_id" = "note"."id" inner join "user" on "user"."id" = "note"."owner_id" inner join \(select "uuid", "note_id" from \(select "uuid", "note_id", row_number\(\) over \(partition by "note_id" order by "created_at" desc\) as rn from "revision"\) where "rn" = \$1\) as "latest_revision" on "latest_revision"."note_id" = "note"."id" inner join "revision" on "revision"."note_id" = "note"."id" and "revision"."uuid" = "latest_revision"."uuid" left join "revision_tag" on "revision_tag"."revision_id" = "latest_revision"."uuid" where "alias"."is_primary" = \$2 and "note_group_permission"."group_id" = \$3 and "revision"."note_type" = \$4 order by "revision"."created_at" desc limit \$5/,
[1, true, mockEveryoneGroupId, NoteType.SLIDE, ENTRIES_PER_PAGE_LIMIT],
/select "alias"."alias" as "primaryAlias", "revision"."title" as "title", "revision"."note_type" as "noteType", "user"."username" as "ownerUsername", "note"."created_at" as "createdAt", "revision"."created_at" as "lastChangedAt", "revision"."uuid" as "revisionUuid", "revision_tag"."tag" as "tag" from "note" inner join "note_group_permission" on "note"."id" = "note_group_permission"."note_id" inner join "alias" on "alias"."note_id" = "note"."id" inner join "user" on "user"."id" = "note"."owner_id" inner join \(select "uuid", "note_id" from \(select "uuid", "note_id", row_number\(\) over \(partition by "note_id" order by "created_at" desc\) as rn from "revision"\) where "rn" = \$1\) as "latest_revision" on "latest_revision"."note_id" = "note"."id" inner join "revision" on "revision"."note_id" = "note"."id" and "revision"."uuid" = "latest_revision"."uuid" left join "revision_tag" on "revision_tag"."revision_id" = "latest_revision"."uuid" where "alias"."is_primary" = \$2 and "note_group_permission"."group_id" = \$3 and "note"."publicly_visible" = \$4 and "revision"."note_type" = \$5 order by "revision"."created_at" desc limit \$6/,
[
1,
true,
mockEveryoneGroupId,
true,
NoteType.SLIDE,
ENTRIES_PER_PAGE_LIMIT,
],
],
[
'sorting',
'',
SortMode.TITLE_ASC,
'',
/select "alias"."alias" as "primaryAlias", "revision"."title" as "title", "revision"."note_type" as "noteType", "user"."username" as "ownerUsername", "note"."created_at" as "createdAt", "revision"."created_at" as "lastChangedAt", "revision"."uuid" as "revisionUuid", "revision_tag"."tag" as "tag" from "note" inner join "note_group_permission" on "note"."id" = "note_group_permission"."note_id" inner join "alias" on "alias"."note_id" = "note"."id" inner join "user" on "user"."id" = "note"."owner_id" inner join \(select "uuid", "note_id" from \(select "uuid", "note_id", row_number\(\) over \(partition by "note_id" order by "created_at" desc\) as rn from "revision"\) where "rn" = \$1\) as "latest_revision" on "latest_revision"."note_id" = "note"."id" inner join "revision" on "revision"."note_id" = "note"."id" and "revision"."uuid" = "latest_revision"."uuid" left join "revision_tag" on "revision_tag"."revision_id" = "latest_revision"."uuid" where "alias"."is_primary" = \$2 and "note_group_permission"."group_id" = \$3 order by "revision"."title" asc limit \$4/,
[1, true, mockEveryoneGroupId, ENTRIES_PER_PAGE_LIMIT],
/select "alias"."alias" as "primaryAlias", "revision"."title" as "title", "revision"."note_type" as "noteType", "user"."username" as "ownerUsername", "note"."created_at" as "createdAt", "revision"."created_at" as "lastChangedAt", "revision"."uuid" as "revisionUuid", "revision_tag"."tag" as "tag" from "note" inner join "note_group_permission" on "note"."id" = "note_group_permission"."note_id" inner join "alias" on "alias"."note_id" = "note"."id" inner join "user" on "user"."id" = "note"."owner_id" inner join \(select "uuid", "note_id" from \(select "uuid", "note_id", row_number\(\) over \(partition by "note_id" order by "created_at" desc\) as rn from "revision"\) where "rn" = \$1\) as "latest_revision" on "latest_revision"."note_id" = "note"."id" inner join "revision" on "revision"."note_id" = "note"."id" and "revision"."uuid" = "latest_revision"."uuid" left join "revision_tag" on "revision_tag"."revision_id" = "latest_revision"."uuid" where "alias"."is_primary" = \$2 and "note_group_permission"."group_id" = \$3 and "note"."publicly_visible" = \$4 order by "revision"."title" asc limit \$5/,
[1, true, mockEveryoneGroupId, true, ENTRIES_PER_PAGE_LIMIT],
],
[
'search filter',
'',
'',
'test',
/select "alias"."alias" as "primaryAlias", "revision"."title" as "title", "revision"."note_type" as "noteType", "user"."username" as "ownerUsername", "note"."created_at" as "createdAt", "revision"."created_at" as "lastChangedAt", "revision"."uuid" as "revisionUuid", "revision_tag"."tag" as "tag" from "note" inner join "note_group_permission" on "note"."id" = "note_group_permission"."note_id" inner join "alias" on "alias"."note_id" = "note"."id" inner join "user" on "user"."id" = "note"."owner_id" inner join \(select "uuid", "note_id" from \(select "uuid", "note_id", row_number\(\) over \(partition by "note_id" order by "created_at" desc\) as rn from "revision"\) where "rn" = \$1\) as "latest_revision" on "latest_revision"."note_id" = "note"."id" inner join "revision" on "revision"."note_id" = "note"."id" and "revision"."uuid" = "latest_revision"."uuid" left join "revision_tag" on "revision_tag"."revision_id" = "latest_revision"."uuid" where "alias"."is_primary" = \$2 and "note_group_permission"."group_id" = \$3 and \(LOWER\("revision"."title"\) LIKE \$4 OR LOWER\("revision_tag"."tag"\) LIKE \$5\) order by "revision"."created_at" desc limit \$6/,
/select "alias"."alias" as "primaryAlias", "revision"."title" as "title", "revision"."note_type" as "noteType", "user"."username" as "ownerUsername", "note"."created_at" as "createdAt", "revision"."created_at" as "lastChangedAt", "revision"."uuid" as "revisionUuid", "revision_tag"."tag" as "tag" from "note" inner join "note_group_permission" on "note"."id" = "note_group_permission"."note_id" inner join "alias" on "alias"."note_id" = "note"."id" inner join "user" on "user"."id" = "note"."owner_id" inner join \(select "uuid", "note_id" from \(select "uuid", "note_id", row_number\(\) over \(partition by "note_id" order by "created_at" desc\) as rn from "revision"\) where "rn" = \$1\) as "latest_revision" on "latest_revision"."note_id" = "note"."id" inner join "revision" on "revision"."note_id" = "note"."id" and "revision"."uuid" = "latest_revision"."uuid" left join "revision_tag" on "revision_tag"."revision_id" = "latest_revision"."uuid" where "alias"."is_primary" = \$2 and "note_group_permission"."group_id" = \$3 and "note"."publicly_visible" = \$4 and \(LOWER\("revision"."title"\) LIKE \$5 OR LOWER\("revision_tag"."tag"\) LIKE \$6\) order by "revision"."created_at" desc limit \$7/,
[
1,
true,
mockEveryoneGroupId,
true,
'%test%',
'%test%',
ENTRIES_PER_PAGE_LIMIT,
+6 -4
View File
@@ -162,10 +162,12 @@ export class ExploreService {
`${TableNoteGroupPermission}.${FieldNameNoteGroupPermission.noteId}`,
);
let query = this.applyCommonQuery(queryBase);
query = query.andWhere(
`${TableNoteGroupPermission}.${FieldNameNoteGroupPermission.groupId}`,
everyoneGroupId,
);
query = query
.andWhere(
`${TableNoteGroupPermission}.${FieldNameNoteGroupPermission.groupId}`,
everyoneGroupId,
)
.andWhere(`${TableNote}.${FieldNameNote.publiclyVisible}`, true);
if (
sortBy === SortMode.LAST_VISITED_ASC ||
sortBy === SortMode.LAST_VISITED_DESC
+20 -3
View File
@@ -83,6 +83,7 @@ describe('NoteService', () => {
const mockTags = ['tag1', 'tag2'];
const mockPermissions = {
owner: mockUsername,
publiclyVisible: false,
sharedToUsers: [],
sharedToGroups: [],
};
@@ -172,14 +173,24 @@ describe('NoteService', () => {
mockInsert(
tracker,
TableNote,
[FieldNameNote.createdAt, FieldNameNote.ownerId, FieldNameNote.version],
[
FieldNameNote.createdAt,
FieldNameNote.ownerId,
FieldNameNote.publiclyVisible,
FieldNameNote.version,
],
[],
);
await expect(
service.createNote(mockNoteContent, mockOwnerUserId, mockAliasCustom),
).rejects.toThrow(GenericDBError);
expectBindings(tracker, 'insert', [
[dateTimeToDB(now), mockOwnerUserId, 2],
[
dateTimeToDB(now),
mockOwnerUserId,
noteMockConfig.permissions.default.publiclyVisible,
2,
],
]);
});
@@ -246,6 +257,7 @@ describe('NoteService', () => {
[
FieldNameNote.createdAt,
FieldNameNote.ownerId,
FieldNameNote.publiclyVisible,
FieldNameNote.version,
],
[{ [FieldNameNote.id]: mockNoteId }],
@@ -260,7 +272,12 @@ describe('NoteService', () => {
);
expect(result).toBe(mockNoteId);
expectBindings(tracker, 'insert', [
[dateTimeToDB(now), mockOwnerUserId, 2],
[
dateTimeToDB(now),
mockOwnerUserId,
noteMockConfig.permissions.default.publiclyVisible,
2,
],
]);
});
+2
View File
@@ -107,6 +107,8 @@ export class NoteService {
[FieldNameNote.ownerId]: ownerUserId,
[FieldNameNote.version]: 2,
[FieldNameNote.createdAt]: createdAt,
[FieldNameNote.publiclyVisible]:
this.noteConfig.permissions.default.publiclyVisible,
},
[FieldNameNote.id],
);
+31 -6
View File
@@ -11,12 +11,14 @@ import {
FieldNameNoteGroupPermission,
FieldNameNoteUserPermission,
FieldNameUser,
Note,
TableGroup,
TableMediaUpload,
TableNote,
TableNoteGroupPermission,
TableNoteUserPermission,
TableUser,
User,
} from '@hedgedoc/database';
import { Inject, Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
@@ -399,6 +401,27 @@ export class PermissionService {
this.notifyOthers(noteId);
}
/**
* Updates if a note is publicly visible or not
*
* @param noteId the id of note to update
* @param newPublicVisible the new state of the note
* @throws NotInDBError if note does not exist
*/
public async changePubliclyVisible(
noteId: number,
newPublicVisible: boolean,
): Promise<void> {
const result = await this.knex(TableNote)
.update({
[FieldNameNote.publiclyVisible]: newPublicVisible,
})
.where(FieldNameNote.id, noteId);
if (result !== 1) {
throw new NotInDBError('The note does not exist');
}
}
/**
* Gets the permissions for a note
*
@@ -423,15 +446,16 @@ export class PermissionService {
noteId: number,
transaction: Knex,
): Promise<NotePermissionsDto> {
const owner = await transaction(TableNote)
const metadata = await transaction(TableNote)
.join(
TableUser,
`${TableUser}.${FieldNameUser.id}`,
`${TableNote}.${FieldNameNote.ownerId}`,
)
.select<{
[FieldNameUser.username]: string;
}>(`${TableUser}.${FieldNameUser.username}`)
.select<
Pick<User, FieldNameUser.username> &
Pick<Note, FieldNameNote.publiclyVisible>
>(`${TableUser}.${FieldNameUser.username}`, `${TableNote}.${FieldNameNote.publiclyVisible}`)
.where(`${TableNote}.${FieldNameNote.id}`, noteId)
.first();
@@ -475,7 +499,7 @@ export class PermissionService {
noteId,
);
if (owner === undefined) {
if (metadata === undefined) {
throw new GenericDBError(
'Invalid database state. This should not happen.',
this.logger.getContext(),
@@ -484,7 +508,8 @@ export class PermissionService {
}
return NotePermissionsDto.create({
owner: owner[FieldNameUser.username],
owner: metadata[FieldNameUser.username],
publiclyVisible: Boolean(metadata[FieldNameNote.publiclyVisible]),
sharedToUsers: userPermissions.map((userPermission) => ({
username: userPermission[FieldNameUser.username],
canEdit: Boolean(userPermission[FieldNameNoteUserPermission.canEdit]),
@@ -529,12 +529,40 @@ describe('PermissionsService', () => {
});
});
describe('changePubliclyVisibly', () => {
// eslint-disable-next-line func-style
const buildMockUpdate = (updatedEntries: number) => {
mockUpdate(
tracker,
TableNote,
[FieldNameNote.publiclyVisible],
FieldNameNote.id,
updatedEntries,
);
};
it('throws NotInDBError when the update does not succed', async () => {
buildMockUpdate(0);
await expect(
service.changePubliclyVisible(mockNoteId, true),
).rejects.toThrow(NotInDBError);
expectBindings(tracker, 'update', [[true, mockNoteId]]);
});
it('correctly notifies others', async () => {
buildMockUpdate(1);
await service.changePubliclyVisible(mockNoteId, true);
expectBindings(tracker, 'update', [[true, mockNoteId]]);
});
});
describe('getPermissionsDtoForNote', () => {
// eslint-disable-next-line func-style
const buildMockOwnerSelect = (returnValues: unknown) => {
mockSelect(
tracker,
[`${TableUser}"."${FieldNameUser.username}`],
[
`${TableUser}"."${FieldNameUser.username}`,
`${TableNote}"."${FieldNameNote.publiclyVisible}`,
],
TableNote,
`${TableNote}"."${FieldNameNote.id}`,
returnValues,
@@ -616,6 +644,8 @@ describe('PermissionsService', () => {
buildMockOwnerSelect([
{
[FieldNameUser.username]: mockUserName2,
[FieldNameNote.publiclyVisible]:
noteMockConfig.permissions.default.publiclyVisible,
},
]);
const results = await service.getPermissionsDtoForNote(mockNoteId);
@@ -415,6 +415,7 @@ describe('Notes', () => {
expect(typeof metadataBody.createdAt).toEqual('string');
expect(metadataBody.editedBy).toEqual([username1]);
expect(metadataBody.permissions.owner).toEqual(username1);
expect(metadataBody.permissions.publiclyVisible).toEqual(false);
expect(metadataBody.permissions.sharedToUsers).toEqual([]);
expect(metadataBody.permissions.sharedToGroups).toEqual([
{
@@ -507,7 +508,6 @@ describe('Notes', () => {
});
afterEach(() => {
expect(response.body).toHaveLength(2);
console.log(response.body);
expect(response.body[0].length).toEqual(content.length);
expect(response.body[1].length).toEqual(noteContent1.length);
});
@@ -756,6 +756,7 @@ describe('Notes', () => {
.expect(404);
});
});
describe(`DELETE ${PRIVATE_API_PREFIX}/notes/:noteAlias/metadata/permissions/users/:username`, () => {
beforeEach(async () => {
const noteId = testSetup.ownedNoteIds[0];
@@ -823,6 +824,7 @@ describe('Notes', () => {
.expect(404);
});
});
describe(`PUT ${PRIVATE_API_PREFIX}/notes/:noteAlias/metadata/permissions/groups/:groupName`, () => {
beforeEach(async () => {
const noteId: number = testSetup.ownedNoteIds[0];
@@ -923,6 +925,7 @@ describe('Notes', () => {
.expect(404);
});
});
describe(`DELETE ${PRIVATE_API_PREFIX}/notes/:noteAlias/metadata/permissions/groups/:groupName`, () => {
beforeEach(async () => {
const noteId = testSetup.ownedNoteIds[0];
@@ -990,6 +993,7 @@ describe('Notes', () => {
.expect(404);
});
});
describe(`PUT ${PRIVATE_API_PREFIX}/notes/:noteAlias/metadata/permissions/owner`, () => {
it('can change owner as the owner', async () => {
const response = await agentUser1
@@ -1001,7 +1005,7 @@ describe('Notes', () => {
})
.expect('Content-Type', /json/)
.expect(200);
expect(response.body.metadata.permissions.owner).toEqual(username2);
expect(response.body.owner).toEqual(username2);
});
describe("can't change owner as", () => {
it('another user', async () => {
@@ -1061,4 +1065,76 @@ describe('Notes', () => {
.expect(404);
});
});
describe(`PUT ${PRIVATE_API_PREFIX}/notes/:noteAlias/metadata/permissions/visibility`, () => {
it('can change visibility as the owner', async () => {
const response = await agentUser1
.put(
`${PRIVATE_API_PREFIX}/notes/${noteAlias1}/metadata/permissions/visibility`,
)
.send({
publiclyVisible: true,
})
.expect('Content-Type', /json/)
.expect(200);
expect(response.body.publiclyVisible).toEqual(true);
});
describe("can't change visibility as", () => {
it('another user', async () => {
await agentUser2
.put(
`${PRIVATE_API_PREFIX}/notes/${noteAlias1}/metadata/permissions/visibility`,
)
.send({
publiclyVisible: true,
})
.expect('Content-Type', /json/)
.expect(403);
});
it('guest user', async () => {
await agentGuestUser
.put(
`${PRIVATE_API_PREFIX}/notes/${noteAlias1}/metadata/permissions/visibility`,
)
.send({
publiclyVisible: true,
})
.expect('Content-Type', /json/)
.expect(403);
});
it('not logged-in user', async () => {
await agentNotLoggedIn
.put(
`${PRIVATE_API_PREFIX}/notes/${noteAlias1}/metadata/permissions/visibility`,
)
.send({
publiclyVisible: true,
})
.expect('Content-Type', /json/)
.expect(401);
});
});
it('throws an error if using a forbidden alias', async () => {
await agentUser1
.put(
`${PRIVATE_API_PREFIX}/notes/${forbiddenAlias}/metadata/permissions/visibility`,
)
.send({
publiclyVisible: true,
})
.expect('Content-Type', /json/)
.expect(403);
});
it('throws an error if using a non-existing alias', async () => {
await agentUser1
.put(
`${PRIVATE_API_PREFIX}/notes/i_do_not_exist/metadata/permissions/visibility`,
)
.send({
publiclyVisible: true,
})
.expect('Content-Type', /json/)
.expect(404);
});
});
});
@@ -415,7 +415,18 @@ describe('Notes', () => {
expect(typeof response.body.createdAt).toEqual('string');
expect(response.body.editedBy).toEqual([username1]);
expect(response.body.permissions.owner).toEqual(username1);
expect(response.body.permissions.publiclyVisible).toEqual(false);
expect(response.body.permissions.sharedToUsers).toEqual([]);
expect(response.body.permissions.sharedToGroups).toEqual([
{
groupName: SpecialGroup.EVERYONE,
canEdit: false,
},
{
groupName: SpecialGroup.LOGGED_IN,
canEdit: true,
},
]);
expect(response.body.tags).toEqual([]);
expect(typeof response.body.updatedAt).toEqual('string');
expect(typeof response.body.lastUpdatedBy).toEqual('string');
@@ -483,6 +494,7 @@ describe('Notes', () => {
.expect('Content-Type', /json/)
.expect(200);
expect(permissions.body.owner).toBe(username1);
expect(permissions.body.publiclyVisible).toBe(false);
expect(new Set(permissions.body.sharedToUsers)).toEqual(new Set([]));
expect(new Set(permissions.body.sharedToGroups)).toEqual(
new Set([
@@ -927,6 +939,80 @@ describe('Notes', () => {
});
});
describe(`PUT ${PUBLIC_API_PREFIX}/notes/{:noteAlias}/metadata/permissions/visibility`, () => {
it('changes visibility of a note', async () => {
const permissionsDtoBefore =
await testSetup.permissionsService.getPermissionsDtoForNote(
testSetup.ownedNoteIds[0],
);
expect(permissionsDtoBefore.publiclyVisible).toEqual(false);
await agent
.put(
`${PUBLIC_API_PREFIX}/notes/${noteAlias1}/metadata/permissions/visibility`,
)
.set('Authorization', `Bearer ${testSetup.authTokens[0].secret}`)
.send({
newPubliclyVisible: true,
})
.expect('Content-Type', /json/)
.expect(200);
const permissionsDtoAfter =
await testSetup.permissionsService.getPermissionsDtoForNote(
testSetup.ownedNoteIds[0],
);
expect(permissionsDtoAfter.publiclyVisible).toEqual(true);
});
it('errors with a forbidden alias', async () => {
await agent
.put(
`${PUBLIC_API_PREFIX}/notes/${forbiddenAlias}/metadata/permissions/visibility`,
)
.set('Authorization', `Bearer ${testSetup.authTokens[0].secret}`)
.send({
newPubliclyVisible: true,
})
.expect('Content-Type', /json/)
.expect(403);
});
it('errors with non-existing alias', async () => {
await agent
.put(
`${PUBLIC_API_PREFIX}/notes/i_dont_exist/metadata/permissions/visibility`,
)
.set('Authorization', `Bearer ${testSetup.authTokens[0].secret}`)
.send({
newPubliclyVisible: true,
})
.expect('Content-Type', /json/)
.expect(404);
});
it('errors if no token is provided', async () => {
await agent
.put(
`${PUBLIC_API_PREFIX}/notes/${noteAlias1}/metadata/permissions/visibility`,
)
.send({
newPubliclyVisible: true,
})
.expect('Content-Type', /json/)
.expect(403);
});
it("errors when user can't access note", async () => {
await agent
.put(
`${PUBLIC_API_PREFIX}/notes/${noteAlias1}/metadata/permissions/visibility`,
)
.set('Authorization', `Bearer ${testSetup.authTokens[1].secret}`)
.send({
newPubliclyVisible: true,
})
.expect('Content-Type', /json/)
.expect(403);
});
});
describe(`GET ${PUBLIC_API_PREFIX}/notes/{:noteAlias}/revisions`, () => {
it('works with existing alias', async () => {
const response = await agent
@@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { z } from 'zod'
export const ChangeNoteVisibilitySchema = z
.object({
publiclyVisible: z
.boolean()
.describe('Whether the note should be listed on the public explore page'),
})
.describe('DTO to change the visibility of a note.')
export type ChangeNoteVisibilityInterface = z.infer<
typeof ChangeNoteVisibilitySchema
>
+1
View File
@@ -5,6 +5,7 @@
*/
export * from './change-note-owner.dto.js'
export * from './change-note-visibility.dto.js'
export * from './note-group-permission-entry.dto.js'
export * from './note-group-permission-update.dto.js'
export * from './note-permissions-update.dto.js'
@@ -11,6 +11,7 @@ import { NoteGroupPermissionEntrySchema } from './note-group-permission-entry.dt
export const NotePermissionsSchema = z
.object({
owner: z.string().nullable().describe('Username of the owner of the note'),
publiclyVisible: z.boolean().describe('If a note is publicly visible'),
sharedToUsers: z
.array(NoteUserPermissionEntrySchema)
.describe('List of users the note is shared with'),
@@ -10,6 +10,7 @@ import { NotePermissionsInterface, SpecialGroup } from '../dtos/index.js'
describe('Permissions', () => {
const testPermissions: NotePermissionsInterface = {
owner: 'owner',
publiclyVisible: true,
sharedToUsers: [
{
username: 'logged_in',
+7 -1
View File
@@ -21,6 +21,9 @@ export interface Note {
/** Timestamp when the note was created */
[FieldNameNote.createdAt]: string
/** If a note should be visible in the public notes section */
[FieldNameNote.publiclyVisible]: boolean
}
export enum FieldNameNote {
@@ -28,9 +31,12 @@ export enum FieldNameNote {
ownerId = 'owner_id',
version = 'version',
createdAt = 'created_at',
publiclyVisible = 'publicly_visible',
}
export const TableNote = 'note'
export type TypeInsertNote = Omit<Note, FieldNameNote.id>
export type TypeUpdateNote = Pick<Note, FieldNameNote.ownerId>
export type TypeUpdateNote =
| Pick<Note, FieldNameNote.ownerId>
| Pick<Note, FieldNameNote.publiclyVisible>
+3 -2
View File
@@ -421,7 +421,6 @@
"owner": "Owner",
"sharedWithUsers": "Shared with users",
"sharedWithGroups": "Shared with groups",
"sharedWithElse": "Shared with else...",
"editUser": "Change {{name}}'s permissions to view and edit",
"viewOnlyUser": "Change {{name}}'s permissions to view only",
"removeUser": "Remove {{name}}'s permissions",
@@ -439,7 +438,9 @@
"placeholder": "Enter username of new note owner",
"button": "Change the owner of this note"
},
"inconsistent": "This permission is overridden by another permission"
"inconsistent": "This permission is overridden by another permission",
"visibility": "Visibility",
"publiclyVisible": "Show on the public explore page"
},
"shareLink": {
"title": "Share link",
+19
View File
@@ -7,6 +7,7 @@ import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-ap
import { PutApiRequestBuilder } from '../common/api-request-builder/put-api-request-builder'
import type {
ChangeNoteOwnerInterface,
ChangeNoteVisibilityInterface,
NoteGroupPermissionUpdateInterface,
NotePermissionsInterface,
NoteUserPermissionUpdateInterface
@@ -31,6 +32,24 @@ export const setNoteOwner = async (noteId: string, newOwner: string): Promise<No
return response.asParsedJsonObject()
}
/**
* Updates the visibility of a note by setting it as public or private.
*
* @param noteId - The unique identifier of the note to update.
* @param publiclyVisible - A flag indicating whether the note should be publicly visible.
* @returns A promise resolving to the updated permissions metadata for the note.
*/
export const setNotePublic = async (noteId: string, publiclyVisible: boolean): Promise<NotePermissionsInterface> => {
const response = await new PutApiRequestBuilder<NotePermissionsInterface, ChangeNoteVisibilityInterface>(
`notes/${noteId}/metadata/permissions/visibility`
)
.withJsonBody({
publiclyVisible: publiclyVisible
})
.sendRequest()
return response.asParsedJsonObject()
}
/**
* Sets a permission for one user of a note.
*
@@ -12,6 +12,7 @@ import { PermissionSectionUsers } from './permission-section-users'
import React from 'react'
import { Modal } from 'react-bootstrap'
import { cypressId } from '../../../../../../utils/cypress-attribute'
import { PermissionSectionVisibility } from './permission-section-visibility'
/**
* Modal for viewing and managing the permissions of the note.
@@ -32,6 +33,7 @@ export const PermissionModal: React.FC<ModalVisibilityProps> = ({ show, onHide }
<PermissionSectionOwner disabled={!isOwner} />
<PermissionSectionUsers disabled={!isOwner} />
<PermissionSectionSpecialGroups disabled={!isOwner} />
<PermissionSectionVisibility disabled={!isOwner} />
</Modal.Body>
</CommonModal>
)
@@ -40,7 +40,7 @@ export const PermissionSectionSpecialGroups: React.FC<PermissionDisabledProps> =
return (
<Fragment>
<h5 className={'my-3'}>
<Trans i18nKey={'editor.modal.permissions.sharedWithElse'} />
<Trans i18nKey={'editor.modal.permissions.sharedWithGroups'} />
</h5>
<ul className={'list-group'}>
<PermissionEntrySpecialGroup
@@ -0,0 +1,61 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { setNotePublic } from '../../../../../../api/permissions'
import { useApplicationState } from '../../../../../../hooks/common/use-application-state'
import { setNotePermissionsFromServer } from '../../../../../../redux/note-details/methods'
import { useUiNotifications } from '../../../../../notifications/ui-notification-boundary'
import type { PermissionDisabledProps } from './permission-disabled.prop'
import React, { type ChangeEvent, Fragment, useCallback } from 'react'
import { Trans } from 'react-i18next'
import { Form } from 'react-bootstrap'
/**
* Section in the permissions modal for managing whether the note should be visible on the explore page.
*
* @param disabled If the user is not the owner, functionality is disabled.
*/
export const PermissionSectionVisibility: React.FC<PermissionDisabledProps> = ({ disabled }) => {
const noteAlias = useApplicationState((state) => state.noteDetails?.primaryAlias)
const currentVisibility = useApplicationState((state) => state.noteDetails.permissions.publiclyVisible)
const { showErrorNotification } = useUiNotifications()
const onSetChangeVisibility = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
if (!noteAlias) {
return
}
const newValue = event.target.checked
setNotePublic(noteAlias, newValue)
.then((updatedPermissions) => {
setNotePermissionsFromServer(updatedPermissions)
})
.catch(showErrorNotification('editor.modal.permissions.error'))
},
[noteAlias, showErrorNotification]
)
return (
<Fragment>
<h5 className={'my-3'}>
<Trans i18nKey={'editor.modal.permissions.visibility'} />
</h5>
<ul className={'list-group'}>
<li className={'list-group-item'}>
<Form.Check
disabled={disabled}
reverse={true}
type={'switch'}
className={'d-flex flex-row align-items-center justify-content-between'}>
<Form.Check.Label>
<Trans i18nKey={'editor.modal.permissions.publiclyVisible'} />
</Form.Check.Label>
<Form.Check.Input disabled={disabled} onChange={onSetChangeVisibility} checked={currentVisibility} />
</Form.Check>
</li>
</ul>
</Fragment>
)
}
@@ -23,6 +23,7 @@ export const initialState: NoteDetails = {
primaryAlias: '',
permissions: {
owner: null,
publiclyVisible: false,
sharedToGroups: [],
sharedToUsers: []
},