diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b9ae480af..329e867244 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## 2024.11.1 ### General -- +- Enhance: リアクションの一覧の公開設定が連合されるように ### Client - Fix: 画面サイズが変わった際にナビゲーションバーが自動で折りたたまれない問題を修正 diff --git a/packages/backend/migration/1723208290742-remote-public-reactions-set-false.js b/packages/backend/migration/1723208290742-remote-public-reactions-set-false.js new file mode 100644 index 0000000000..f6114bcbe0 --- /dev/null +++ b/packages/backend/migration/1723208290742-remote-public-reactions-set-false.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class RemotePublicReactionsSetFalse1723208290742 { + name = 'RemotePublicReactionsSetFalse1723208290742' + + async up(queryRunner) { + await queryRunner.query(`UPDATE "user_profile" SET "publicReactions" = FALSE WHERE "userHost" IS NOT NULL`); + } + + async down(queryRunner) { + // no valid down migration + } +} diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 5617a29bab..b5d6bd3a2a 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -472,6 +472,13 @@ export class ApRendererService { const hashtagTags = user.tags.map(tag => this.renderHashtag(tag)); + const likedId = `${id}/liked`; + const liked = this.renderOrderedCollection( + likedId, + undefined, + profile.publicReactions ? `${likedId}?page=true` : undefined, + ); + const tag = [ ...apemojis, ...hashtagTags, @@ -486,6 +493,7 @@ export class ApRendererService { outbox: `${id}/outbox`, followers: `${id}/followers`, following: `${id}/following`, + liked, featured: `${id}/collections/featured`, sharedInbox: `${this.config.url}/inbox`, endpoints: { sharedInbox: `${this.config.url}/inbox` }, @@ -670,7 +678,7 @@ export class ApRendererService { * @param orderedItems attached objects (optional) */ @bindThis - public renderOrderedCollection(id: string, totalItems: number, first?: string, last?: string, orderedItems?: IObject[]) { + public renderOrderedCollection(id: string, totalItems?: number, first?: string, last?: string, orderedItems?: IObject[]) { const page: any = { id, type: 'OrderedCollection', diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 8590861ca0..f0dc7ab643 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -321,17 +321,17 @@ export class ApPersonService implements OnModuleInit { const isBot = getApType(object) === 'Service' || getApType(object) === 'Application'; - const [followingVisibility, followersVisibility] = await Promise.all( + const [publicReactions, followingIsPublic, followersIsPublic] = await Promise.all( [ + this.isPublicCollection(person.liked, resolver), this.isPublicCollection(person.following, resolver), this.isPublicCollection(person.followers, resolver), - ].map((p): Promise<'public' | 'private'> => p - .then(isPublic => isPublic ? 'public' : 'private') + ].map((p): Promise => p .catch(err => { if (!(err instanceof StatusError) || err.isRetryable) { - this.logger.error('error occurred while fetching following/followers collection', { stack: err }); + this.logger.error('error occurred while fetching actor collection', { stack: err }); } - return 'private'; + return false; }), ), ); @@ -411,8 +411,9 @@ export class ApPersonService implements OnModuleInit { followedMessage: person._misskey_followedMessage != null ? truncate(person._misskey_followedMessage, 256) : null, url, fields, - followingVisibility, - followersVisibility, + publicReactions, + followingVisibility: followingIsPublic ? 'public' : 'private', + followersVisibility: followersIsPublic ? 'public' : 'private', birthday: bday?.[0] ?? null, location: person['vcard:Address'] ?? null, userHost: host, @@ -522,19 +523,19 @@ export class ApPersonService implements OnModuleInit { const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32); - const [followingVisibility, followersVisibility] = await Promise.all( + const [publicReactions, followingIsPublic, followersIsPublic] = await Promise.all( [ + this.isPublicCollection(person.liked, resolver), this.isPublicCollection(person.following, resolver), this.isPublicCollection(person.followers, resolver), - ].map((p): Promise<'public' | 'private' | undefined> => p - .then(isPublic => isPublic ? 'public' : 'private') + ].map((p): Promise => p .catch(err => { if (!(err instanceof StatusError) || err.isRetryable) { - this.logger.error('error occurred while fetching following/followers collection', { stack: err }); + this.logger.error('error occurred while fetching actor collection', { stack: err }); // Do not update the visibiility on transient errors. return undefined; } - return 'private'; + return false; }), ), ); @@ -618,8 +619,9 @@ export class ApPersonService implements OnModuleInit { fields, description: _description, followedMessage: person._misskey_followedMessage != null ? truncate(person._misskey_followedMessage, 256) : null, - followingVisibility, - followersVisibility, + publicReactions, + followingVisibility: followingIsPublic ? 'public' : followingIsPublic === false ? 'private' : undefined, + followersVisibility: followersIsPublic ? 'public' : followersIsPublic === false ? 'private' : undefined, birthday: bday?.[0] ?? null, location: person['vcard:Address'] ?? null, }); diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 7496315f09..b5eb643e0e 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -103,14 +103,14 @@ export interface IActivity extends IObject { export interface ICollection extends IObject { type: 'Collection'; - totalItems: number; + totalItems?: number; first?: IObject | string; items?: ApObject; } export interface IOrderedCollection extends IObject { type: 'OrderedCollection'; - totalItems: number; + totalItems?: number; first?: IObject | string; orderedItems?: ApObject; } @@ -190,6 +190,7 @@ export interface IActor extends IObject { following?: string | ICollection | IOrderedCollection; featured?: string | IOrderedCollection; outbox: string | IOrderedCollection; + liked?: string | ICollection | IOrderedCollection; endpoints?: { sharedInbox?: string; }; diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index d3c087a153..1e19d6b976 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -545,7 +545,7 @@ export class UserEntityService implements OnModuleInit { }), pinnedPageId: profile!.pinnedPageId, pinnedPage: profile!.pinnedPageId ? this.pageEntityService.pack(profile!.pinnedPageId, me) : null, - publicReactions: this.isLocalUser(user) ? profile!.publicReactions : false, // https://github.com/misskey-dev/misskey/issues/12964 + publicReactions: profile!.publicReactions, followersVisibility: profile!.followersVisibility, followingVisibility: profile!.followingVisibility, roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({ diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index f34f6583d3..01a71073ef 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -352,6 +352,98 @@ export class ActivityPubServerService { } } + @bindThis + private async liked( + request: FastifyRequest<{ Params: { user: string; }; Querystring: { cursor?: string; page?: string; }; }>, + reply: FastifyReply, + ) { + const userId = request.params.user; + + const cursor = request.query.cursor; + if (cursor != null && typeof cursor !== 'string') { + reply.code(400); + return; + } + + const page = request.query.page === 'true'; + + const user = await this.usersRepository.findOneBy({ + id: userId, + host: IsNull(), + }); + + if (user == null) { + reply.code(404); + return; + } + + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + + if (!profile.publicReactions) { + reply.code(403); + reply.header('Cache-Control', 'public, max-age=30'); + return; + } + + const limit = 10; + const partOf = `${this.config.url}/users/${userId}/liked`; + + if (page) { + const query = this.noteReactionsRepository.createQueryBuilder('reaction') + .andWhere('reaction.userId = :userId', { userId: user.id }); + + // カーソルが指定されている場合 + if (cursor) { + query.andWhere('reaction.id < :id', { id: cursor }); + } + + const reactions = await query + .limit(limit + 1) + .orderBy('reaction.id', 'DESC') + .innerJoinAndSelect('reaction.note', 'note') + .leftJoinAndSelect('note.user', 'noteUser') + .andWhere(new Brackets(qb => { + qb + .where('note.visibility = \'public\'') + .orWhere('note.visibility = \'home\''); + })) + .andWhere('note.localOnly = FALSE') + .andWhere('noteUser.isSuspended = FALSE') + .getMany(); + + // 「次のページ」があるかどうか + const inStock = reactions.length === limit + 1; + if (inStock) reactions.pop(); + + const reactedNotes = await Promise.all(reactions.map(({ note }) => note!.uri || this.apRendererService.renderNote(note!, false))); + const rendered = this.apRendererService.renderOrderedCollectionPage( + `${partOf}?${url.query({ + page: 'true', + cursor, + })}`, + undefined, reactedNotes, partOf, + undefined, + inStock ? `${partOf}?${url.query({ + page: 'true', + cursor: reactions.at(-1)!.id, + })}` : undefined, + ); + + this.setResponseType(request, reply); + return (this.apRendererService.addContext(rendered)); + } else { + // index page + const rendered = this.apRendererService.renderOrderedCollection( + partOf, + undefined, + `${partOf}?page=true`, + ); + reply.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(request, reply); + return (this.apRendererService.addContext(rendered)); + } + } + @bindThis private async featured(request: FastifyRequest<{ Params: { user: string; }; }>, reply: FastifyReply) { const userId = request.params.user; @@ -629,6 +721,12 @@ export class ActivityPubServerService { Querystring: { cursor?: string; page?: string; }; }>('/users/:user/following', async (request, reply) => await this.following(request, reply)); + // liked + fastify.get<{ + Params: { user: string; }; + Querystring: { cursor?: string; page?: string; }; + }>('/users/:user/liked', async (request, reply) => await this.liked(request, reply)); + // featured fastify.get<{ Params: { user: string; }; }>('/users/:user/collections/featured', async (request, reply) => await this.featured(request, reply)); diff --git a/packages/backend/src/server/api/endpoints/users/reactions.ts b/packages/backend/src/server/api/endpoints/users/reactions.ts index 7805ae3288..25a436707f 100644 --- a/packages/backend/src/server/api/endpoints/users/reactions.ts +++ b/packages/backend/src/server/api/endpoints/users/reactions.ts @@ -10,7 +10,6 @@ import { QueryService } from '@/core/QueryService.js'; import { NoteReactionEntityService } from '@/core/entities/NoteReactionEntityService.js'; import { DI } from '@/di-symbols.js'; import { CacheService } from '@/core/CacheService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { RoleService } from '@/core/RoleService.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { ApiError } from '../../error.js'; @@ -38,11 +37,6 @@ export const meta = { code: 'REACTIONS_NOT_PUBLIC', id: '673a7dd2-6924-1093-e0c0-e68456ceae5c', }, - isRemoteUser: { - message: 'Currently unavailable to display reactions of remote users. See https://github.com/misskey-dev/misskey/issues/12964', - code: 'IS_REMOTE_USER', - id: '6b95fa98-8cf9-2350-e284-f0ffdb54a805', - }, }, } as const; @@ -69,7 +63,6 @@ export default class extends Endpoint { // eslint- private noteReactionsRepository: NoteReactionsRepository, private cacheService: CacheService, - private userEntityService: UserEntityService, private noteReactionEntityService: NoteReactionEntityService, private queryService: QueryService, private roleService: RoleService, @@ -78,11 +71,6 @@ export default class extends Endpoint { // eslint- const userIdsWhoBlockingMe = me ? await this.cacheService.userBlockedCache.fetch(me.id) : new Set(); const iAmModerator = me ? await this.roleService.isModerator(me) : false; // Moderators can see reactions of all users if (!iAmModerator) { - const user = await this.cacheService.findUserById(ps.userId); - if (this.userEntityService.isRemoteUser(user)) { - throw new ApiError(meta.errors.isRemoteUser); - } - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: ps.userId }); if ((me == null || me.id !== ps.userId) && !profile.publicReactions) { throw new ApiError(meta.errors.reactionsNotPublic); diff --git a/packages/backend/test-federation/test/user.test.ts b/packages/backend/test-federation/test/user.test.ts index 76605e61d4..af29b72f2a 100644 --- a/packages/backend/test-federation/test/user.test.ts +++ b/packages/backend/test-federation/test/user.test.ts @@ -43,12 +43,11 @@ describe('User', () => { 'uri', 'createdAt', 'lastFetchedAt', - 'publicReactions', ]); }); }); - describe('ffVisibility is federated', () => { + describe('ff/reactions visibility is federated', () => { let alice: LoginUser, bob: LoginUser; let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; @@ -78,6 +77,7 @@ describe('User', () => { ])) { strictEqual(user.followersVisibility, 'public'); strictEqual(user.followingVisibility, 'public'); + strictEqual(user.publicReactions, true); } }); @@ -113,6 +113,22 @@ describe('User', () => { strictEqual(user.followingVisibility, 'private'); } }); + + test.skip('Setting false for publicReactions is federated', async () => { + await Promise.all([ + alice.client.request('i/update', { publicReactions: false }), + bob.client.request('i/update', { publicReactions: false }), + ]); + await sleep(); + + for (const user of await Promise.all([ + alice.client.request('users/show', { userId: bobInA.id }), + bob.client.request('users/show', { userId: aliceInB.id }), + ])) { + strictEqual(user.publicReactions, false); + strictEqual(user.publicReactions, false); + } + }); }); describe('isCat is federated', () => { diff --git a/packages/backend/test/unit/activitypub.ts b/packages/backend/test/unit/activitypub.ts index 9df947982b..26f11a2086 100644 --- a/packages/backend/test/unit/activitypub.ts +++ b/packages/backend/test/unit/activitypub.ts @@ -213,7 +213,7 @@ describe('ActivityPub', () => { }); describe('Collection visibility', () => { - test('Public following/followers', async () => { + test('Public following/followers/reactions', async () => { const actor = createRandomActor(); actor.following = { id: `${actor.id}/following`, @@ -222,6 +222,12 @@ describe('ActivityPub', () => { first: `${actor.id}/following?page=1`, }; actor.followers = `${actor.id}/followers`; + actor.liked = { + id: `${actor.id}/following`, + type: 'OrderedCollection', + totalItems: 0, + orderedItems: [], + }; resolver.register(actor.id, actor); resolver.register(actor.followers, { @@ -236,9 +242,10 @@ describe('ActivityPub', () => { assert.deepStrictEqual(userProfile.followingVisibility, 'public'); assert.deepStrictEqual(userProfile.followersVisibility, 'public'); + assert.deepStrictEqual(userProfile.publicReactions, true); }); - test('Private following/followers', async () => { + test('Private following/followers/reactions', async () => { const actor = createRandomActor(); actor.following = { id: `${actor.id}/following`, @@ -247,6 +254,7 @@ describe('ActivityPub', () => { // first: … }; actor.followers = `${actor.id}/followers`; + // actor.liked = …; resolver.register(actor.id, actor); //resolver.register(actor.followers, { … }); @@ -256,6 +264,7 @@ describe('ActivityPub', () => { assert.deepStrictEqual(userProfile.followingVisibility, 'private'); assert.deepStrictEqual(userProfile.followersVisibility, 'private'); + assert.deepStrictEqual(userProfile.publicReactions, false); }); });