diff --git a/packages/backend/migration/1787277329377-normalize-channel-collaborator-ids.js b/packages/backend/migration/1787277329377-normalize-channel-collaborator-ids.js new file mode 100644 index 00000000000..fc0be16f697 --- /dev/null +++ b/packages/backend/migration/1787277329377-normalize-channel-collaborator-ids.js @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class NormalizeChannelCollaboratorIds1787277329377 { + name = 'NormalizeChannelCollaboratorIds1787277329377' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel" ADD "collaboratorIdsNormalized" character varying(32) array NOT NULL DEFAULT '{}'`); + await queryRunner.query(` + DO $$ + DECLARE + collaborator_ids_type text; + BEGIN + SELECT data_type + INTO collaborator_ids_type + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'channel' + AND column_name = 'collaboratorIds'; + + IF collaborator_ids_type = 'ARRAY' THEN + UPDATE "channel" + SET "collaboratorIdsNormalized" = CASE + WHEN "collaboratorIds" IS NULL OR EXISTS ( + SELECT 1 + FROM unnest("collaboratorIds"::text[]) AS element + WHERE element IS NULL OR length(element) > 32 + ) THEN '{}' + ELSE "collaboratorIds"::text[] + END; + ELSIF collaborator_ids_type IN ('json', 'jsonb') THEN + UPDATE "channel" + SET "collaboratorIdsNormalized" = CASE + WHEN "collaboratorIds" IS NULL OR jsonb_typeof("collaboratorIds"::jsonb) <> 'array' THEN '{}' + WHEN EXISTS ( + SELECT 1 + FROM jsonb_array_elements("collaboratorIds"::jsonb) AS element + WHERE jsonb_typeof(element) <> 'string' + OR length(element #>> '{}') > 32 + ) THEN '{}' + ELSE ARRAY(SELECT jsonb_array_elements_text("collaboratorIds"::jsonb)) + END; + END IF; + END $$; + `); + await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "collaboratorIds"`); + await queryRunner.query(`ALTER TABLE "channel" RENAME COLUMN "collaboratorIdsNormalized" TO "collaboratorIds"`); + await queryRunner.query(`COMMENT ON COLUMN "channel"."collaboratorIds" IS 'Collaborator user IDs.'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel" ADD "collaboratorIdsBeforeNormalization" character varying(64) array DEFAULT '{}'`); + await queryRunner.query(`UPDATE "channel" SET "collaboratorIdsBeforeNormalization" = "collaboratorIds"::text[]`); + await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "collaboratorIds"`); + await queryRunner.query(`ALTER TABLE "channel" RENAME COLUMN "collaboratorIdsBeforeNormalization" TO "collaboratorIds"`); + } +} diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 7ac201796cd..76c819bdba9 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -467,7 +467,7 @@ export class NoteCreateService implements OnApplicationShutdown { if (data.localOnly == null) data.localOnly = false; if (data.channel != null) data.visibility = 'public'; if (data.channel != null) data.visibleUsers = []; - if (data.channel != null) data.localOnly = data.channel.isLocalOnly; + if (data.channel != null) data.localOnly ||= data.channel.isLocalOnly; if (data.visibility === 'public' && data.channel == null) { const sensitiveWords = this.meta.sensitiveWords; diff --git a/packages/backend/src/core/entities/NoteDraftEntityService.ts b/packages/backend/src/core/entities/NoteDraftEntityService.ts index f241d709bd4..872265901a4 100644 --- a/packages/backend/src/core/entities/NoteDraftEntityService.ts +++ b/packages/backend/src/core/entities/NoteDraftEntityService.ts @@ -127,6 +127,7 @@ export class NoteDraftEntityService implements OnModuleInit { color: channel.color, isSensitive: channel.isSensitive, allowRenoteToExternal: channel.allowRenoteToExternal, + isLocalOnly: channel.isLocalOnly, userId: channel.userId, } : undefined, poll: noteDraft.hasPoll ? { diff --git a/packages/backend/src/models/json-schema/note-draft.ts b/packages/backend/src/models/json-schema/note-draft.ts index 8144ac7b3bd..d4d69a6d853 100644 --- a/packages/backend/src/models/json-schema/note-draft.ts +++ b/packages/backend/src/models/json-schema/note-draft.ts @@ -147,6 +147,10 @@ export const packedNoteDraftSchema = { type: 'boolean', optional: false, nullable: false, }, + isLocalOnly: { + type: 'boolean', + optional: false, nullable: false, + }, userId: { type: 'string', optional: false, nullable: true, diff --git a/packages/backend/src/server/api/endpoints/channels/update.ts b/packages/backend/src/server/api/endpoints/channels/update.ts index 88469f95aad..494f488b51d 100644 --- a/packages/backend/src/server/api/endpoints/channels/update.ts +++ b/packages/backend/src/server/api/endpoints/channels/update.ts @@ -125,20 +125,27 @@ export default class extends Endpoint { // eslint- } if (ps.collaboratorIds !== undefined) { - if (channel.userId !== me.id && !iAmModerator) { - throw new ApiError(meta.errors.accessDenied); - } - const users = await this.usersRepository.findBy({ - id: In(ps.collaboratorIds), - }); - if (users.length !== ps.collaboratorIds.length) { - throw new ApiError({ - message: 'One or more collaborator user IDs are invalid.', - code: 'INVALID_COLLABORATOR_USER_IDS', - id: '3e7c9a2b-4f8c-4d1e-9b7a-3f6e8c7d9a1b', + const requestedCollaboratorIds = ps.collaboratorIds; + const currentCollaboratorIds = Array.isArray(channel.collaboratorIds) ? channel.collaboratorIds : []; + const collaboratorsChanged = currentCollaboratorIds.length !== requestedCollaboratorIds.length || + currentCollaboratorIds.some(id => !requestedCollaboratorIds.includes(id)); + + if (collaboratorsChanged) { + if (channel.userId !== me.id && !iAmModerator) { + throw new ApiError(meta.errors.accessDenied); + } + const users = requestedCollaboratorIds.length === 0 ? [] : await this.usersRepository.findBy({ + id: In(requestedCollaboratorIds), }); + if (users.length !== requestedCollaboratorIds.length) { + throw new ApiError({ + message: 'One or more collaborator user IDs are invalid.', + code: 'INVALID_COLLABORATOR_USER_IDS', + id: '3e7c9a2b-4f8c-4d1e-9b7a-3f6e8c7d9a1b', + }); + } + await this.channelService.setCollaborators(channel, requestedCollaboratorIds); } - await this.channelService.setCollaborators(channel, ps.collaboratorIds); } if (ps.isLocalOnly !== undefined) channel.isLocalOnly = ps.isLocalOnly; @@ -152,14 +159,16 @@ export default class extends Endpoint { // eslint- ...(ps.pinnedNoteIds ? { pinnedNoteIds: ps.pinnedNoteIds } : {}), ...(ps.color ? { color: ps.color } : {}), ...(typeof ps.isArchived === 'boolean' ? { isArchived: ps.isArchived } : {}), - ...(banner ? { bannerId: banner.id } : {}), + ...(banner !== undefined ? { bannerId: banner?.id ?? null } : {}), ...(typeof ps.isSensitive === 'boolean' ? { isSensitive: ps.isSensitive } : {}), ...(typeof ps.allowRenoteToExternal === 'boolean' ? { allowRenoteToExternal: ps.allowRenoteToExternal } : {}), ...(ps.isLocalOnly !== undefined ? { isLocalOnly: ps.isLocalOnly } : {}), ...(ps.isUnlisted !== undefined ? { isUnlisted: ps.isUnlisted } : {}), ...(ps.transferAdminUserId !== undefined && channel.userId === ps.transferAdminUserId ? { userId: ps.transferAdminUserId } : {}), }; - if (Object.keys(updates).length > 0) await this.channelsRepository.update(channel.id, updates); + if (Object.keys(updates).length > 0) { + await this.channelsRepository.update(channel.id, updates); + } if (ps.isFollowApprovalRequired !== undefined) { await this.channelFollowingService.setFollowApprovalRequired(channel, ps.isFollowApprovalRequired); } diff --git a/packages/backend/test/e2e/channel.ts b/packages/backend/test/e2e/channel.ts index 34a8eca225f..7d7f1b6c57a 100644 --- a/packages/backend/test/e2e/channel.ts +++ b/packages/backend/test/e2e/channel.ts @@ -12,8 +12,93 @@ import type * as misskey from 'misskey-js'; describe('Channel', () => { let alice: misskey.entities.SignupResponse; + let bob: misskey.entities.SignupResponse; beforeAll(async () => { alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + }); + + describe('Update', () => { + let channel: misskey.entities.ChannelsCreateResponse; + + beforeAll(async () => { + channel = (await api('channels/create', { name: 'update-test-channel' }, alice)).body; + }); + + test('所有者が共同管理者を追加できる', async () => { + const res = await api('channels/update', { + channelId: channel.id, + collaboratorIds: [bob.id], + }, alice); + + assert.strictEqual(res.status, 200, JSON.stringify(res.body)); + assert.deepStrictEqual(res.body.collaboratorIds, [bob.id]); + }); + + test('共同管理者が未変更の共同管理者一覧を含めてチャンネル名を変更できる', async () => { + const res = await api('channels/update', { + channelId: channel.id, + name: 'updated-by-collaborator', + collaboratorIds: [bob.id], + }, bob); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.name, 'updated-by-collaborator'); + }); + + test('共同管理者は共同管理者一覧を変更できない', async () => { + const res = await api('channels/update', { + channelId: channel.id, + collaboratorIds: [], + }, bob); + + assert.strictEqual(res.status, 400); + assert.strictEqual(castAsError(res.body).error.code, 'ACCESS_DENIED'); + }); + + test('所有者が共同管理者を全員削除できる', async () => { + const res = await api('channels/update', { + channelId: channel.id, + collaboratorIds: [], + }, alice); + + assert.strictEqual(res.status, 200, JSON.stringify(res.body)); + assert.deepStrictEqual(res.body.collaboratorIds, []); + }); + }); + + describe('Federation', () => { + test('連合可能なチャンネルで投稿単位の連合なし設定を保持する', async () => { + const channel = (await api('channels/create', { + name: 'federated-channel', + isLocalOnly: false, + }, alice)).body; + + const res = await api('notes/create', { + text: 'local-only channel note', + channelId: channel.id, + localOnly: true, + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.createdNote.localOnly, true); + }); + + test('連合なしチャンネルでは投稿単位の設定にかかわらず連合なしにする', async () => { + const channel = (await api('channels/create', { + name: 'local-only-channel', + isLocalOnly: true, + }, alice)).body; + + const res = await api('notes/create', { + text: 'forced local-only channel note', + channelId: channel.id, + localOnly: false, + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.createdNote.localOnly, true); + }); }); describe('Follow', () => { diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index f61b2a9def8..555c8eba17c 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -223,12 +223,11 @@ const justEndedComposition = ref(false); const renoteTargetNote: ShallowRef = shallowRef(props.renote); const replyTargetNote: ShallowRef = shallowRef(props.reply); const targetChannel = shallowRef(props.channel); -const isChannelLocalOnly = ref(targetChannel.value?.isLocalOnly ?? false); +const isChannelLocalOnly = computed(() => targetChannel.value?.isLocalOnly ?? false); function applyChannelPostDefaults() { if (targetChannel.value) { visibility.value = 'public'; - console.log(isChannelLocalOnly.value); if (isChannelLocalOnly.value) localOnly.value = true; } } @@ -1339,7 +1338,10 @@ async function openAccountMenu(ev: PointerEvent) { replyTargetNote.value = draft.reply; reactionAcceptance.value = draft.reactionAcceptance; scheduledAt.value = draft.scheduledAt ?? null; - if (draft.channel) targetChannel.value = draft.channel as unknown as Misskey.entities.Channel; + if (draft.channel) { + targetChannel.value = draft.channel as unknown as Misskey.entities.Channel; + applyChannelPostDefaults(); + } visibleUsers.value = []; draft.visibleUserIds?.forEach(uid => { diff --git a/packages/frontend/src/pages/channel-editor.vue b/packages/frontend/src/pages/channel-editor.vue index 12418d19bf1..2c671af65f8 100644 --- a/packages/frontend/src/pages/channel-editor.vue +++ b/packages/frontend/src/pages/channel-editor.vue @@ -269,7 +269,7 @@ function save() { ...params, channelId: props.channelId, pinnedNoteIds: pinnedNoteIds.value, - collaboratorIds: collaboratorUsers.value.map(x => x.id), + ...(isRoot.value ? { collaboratorIds: collaboratorUsers.value.map(x => x.id) } : {}), }); } else { os.apiWithDialog('channels/create', params).then(created => { diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index c0eb7e0b8a6..3c8fbe6abb9 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -4969,6 +4969,7 @@ export type components = { color: string; isSensitive: boolean; allowRenoteToExternal: boolean; + isLocalOnly: boolean; userId: string | null; } | null; localOnly: boolean;