This commit is contained in:
Daiki Mizukami 2024-12-11 07:53:17 +00:00 committed by GitHub
commit dda7928f22
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 173 additions and 35 deletions

View File

@ -1,7 +1,7 @@
## 2024.11.1 ## 2024.11.1
### General ### General
- - Enhance: リアクションの一覧の公開設定が連合されるように
### Client ### Client
- Fix: 画面サイズが変わった際にナビゲーションバーが自動で折りたたまれない問題を修正 - Fix: 画面サイズが変わった際にナビゲーションバーが自動で折りたたまれない問題を修正

View File

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

View File

@ -472,6 +472,13 @@ export class ApRendererService {
const hashtagTags = user.tags.map(tag => this.renderHashtag(tag)); 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 = [ const tag = [
...apemojis, ...apemojis,
...hashtagTags, ...hashtagTags,
@ -486,6 +493,7 @@ export class ApRendererService {
outbox: `${id}/outbox`, outbox: `${id}/outbox`,
followers: `${id}/followers`, followers: `${id}/followers`,
following: `${id}/following`, following: `${id}/following`,
liked,
featured: `${id}/collections/featured`, featured: `${id}/collections/featured`,
sharedInbox: `${this.config.url}/inbox`, sharedInbox: `${this.config.url}/inbox`,
endpoints: { sharedInbox: `${this.config.url}/inbox` }, endpoints: { sharedInbox: `${this.config.url}/inbox` },
@ -670,7 +678,7 @@ export class ApRendererService {
* @param orderedItems attached objects (optional) * @param orderedItems attached objects (optional)
*/ */
@bindThis @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 = { const page: any = {
id, id,
type: 'OrderedCollection', type: 'OrderedCollection',

View File

@ -321,17 +321,17 @@ export class ApPersonService implements OnModuleInit {
const isBot = getApType(object) === 'Service' || getApType(object) === 'Application'; 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.following, resolver),
this.isPublicCollection(person.followers, resolver), this.isPublicCollection(person.followers, resolver),
].map((p): Promise<'public' | 'private'> => p ].map((p): Promise<boolean> => p
.then(isPublic => isPublic ? 'public' : 'private')
.catch(err => { .catch(err => {
if (!(err instanceof StatusError) || err.isRetryable) { 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, followedMessage: person._misskey_followedMessage != null ? truncate(person._misskey_followedMessage, 256) : null,
url, url,
fields, fields,
followingVisibility, publicReactions,
followersVisibility, followingVisibility: followingIsPublic ? 'public' : 'private',
followersVisibility: followersIsPublic ? 'public' : 'private',
birthday: bday?.[0] ?? null, birthday: bday?.[0] ?? null,
location: person['vcard:Address'] ?? null, location: person['vcard:Address'] ?? null,
userHost: host, userHost: host,
@ -522,19 +523,19 @@ export class ApPersonService implements OnModuleInit {
const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32); 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.following, resolver),
this.isPublicCollection(person.followers, resolver), this.isPublicCollection(person.followers, resolver),
].map((p): Promise<'public' | 'private' | undefined> => p ].map((p): Promise<boolean | undefined> => p
.then(isPublic => isPublic ? 'public' : 'private')
.catch(err => { .catch(err => {
if (!(err instanceof StatusError) || err.isRetryable) { 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. // Do not update the visibiility on transient errors.
return undefined; return undefined;
} }
return 'private'; return false;
}), }),
), ),
); );
@ -618,8 +619,9 @@ export class ApPersonService implements OnModuleInit {
fields, fields,
description: _description, description: _description,
followedMessage: person._misskey_followedMessage != null ? truncate(person._misskey_followedMessage, 256) : null, followedMessage: person._misskey_followedMessage != null ? truncate(person._misskey_followedMessage, 256) : null,
followingVisibility, publicReactions,
followersVisibility, followingVisibility: followingIsPublic ? 'public' : followingIsPublic === false ? 'private' : undefined,
followersVisibility: followersIsPublic ? 'public' : followersIsPublic === false ? 'private' : undefined,
birthday: bday?.[0] ?? null, birthday: bday?.[0] ?? null,
location: person['vcard:Address'] ?? null, location: person['vcard:Address'] ?? null,
}); });

View File

@ -103,14 +103,14 @@ export interface IActivity extends IObject {
export interface ICollection extends IObject { export interface ICollection extends IObject {
type: 'Collection'; type: 'Collection';
totalItems: number; totalItems?: number;
first?: IObject | string; first?: IObject | string;
items?: ApObject; items?: ApObject;
} }
export interface IOrderedCollection extends IObject { export interface IOrderedCollection extends IObject {
type: 'OrderedCollection'; type: 'OrderedCollection';
totalItems: number; totalItems?: number;
first?: IObject | string; first?: IObject | string;
orderedItems?: ApObject; orderedItems?: ApObject;
} }
@ -190,6 +190,7 @@ export interface IActor extends IObject {
following?: string | ICollection | IOrderedCollection; following?: string | ICollection | IOrderedCollection;
featured?: string | IOrderedCollection; featured?: string | IOrderedCollection;
outbox: string | IOrderedCollection; outbox: string | IOrderedCollection;
liked?: string | ICollection | IOrderedCollection;
endpoints?: { endpoints?: {
sharedInbox?: string; sharedInbox?: string;
}; };

View File

@ -545,7 +545,7 @@ export class UserEntityService implements OnModuleInit {
}), }),
pinnedPageId: profile!.pinnedPageId, pinnedPageId: profile!.pinnedPageId,
pinnedPage: profile!.pinnedPageId ? this.pageEntityService.pack(profile!.pinnedPageId, me) : null, 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, followersVisibility: profile!.followersVisibility,
followingVisibility: profile!.followingVisibility, 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 => ({ roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({

View File

@ -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 @bindThis
private async featured(request: FastifyRequest<{ Params: { user: string; }; }>, reply: FastifyReply) { private async featured(request: FastifyRequest<{ Params: { user: string; }; }>, reply: FastifyReply) {
const userId = request.params.user; const userId = request.params.user;
@ -629,6 +721,12 @@ export class ActivityPubServerService {
Querystring: { cursor?: string; page?: string; }; Querystring: { cursor?: string; page?: string; };
}>('/users/:user/following', async (request, reply) => await this.following(request, reply)); }>('/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 // featured
fastify.get<{ Params: { user: string; }; }>('/users/:user/collections/featured', async (request, reply) => await this.featured(request, reply)); fastify.get<{ Params: { user: string; }; }>('/users/:user/collections/featured', async (request, reply) => await this.featured(request, reply));

View File

@ -10,7 +10,6 @@ import { QueryService } from '@/core/QueryService.js';
import { NoteReactionEntityService } from '@/core/entities/NoteReactionEntityService.js'; import { NoteReactionEntityService } from '@/core/entities/NoteReactionEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { isUserRelated } from '@/misc/is-user-related.js'; import { isUserRelated } from '@/misc/is-user-related.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
@ -38,11 +37,6 @@ export const meta = {
code: 'REACTIONS_NOT_PUBLIC', code: 'REACTIONS_NOT_PUBLIC',
id: '673a7dd2-6924-1093-e0c0-e68456ceae5c', 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; } as const;
@ -69,7 +63,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteReactionsRepository: NoteReactionsRepository, private noteReactionsRepository: NoteReactionsRepository,
private cacheService: CacheService, private cacheService: CacheService,
private userEntityService: UserEntityService,
private noteReactionEntityService: NoteReactionEntityService, private noteReactionEntityService: NoteReactionEntityService,
private queryService: QueryService, private queryService: QueryService,
private roleService: RoleService, private roleService: RoleService,
@ -78,11 +71,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const userIdsWhoBlockingMe = me ? await this.cacheService.userBlockedCache.fetch(me.id) : new Set<string>(); const userIdsWhoBlockingMe = me ? await this.cacheService.userBlockedCache.fetch(me.id) : new Set<string>();
const iAmModerator = me ? await this.roleService.isModerator(me) : false; // Moderators can see reactions of all users const iAmModerator = me ? await this.roleService.isModerator(me) : false; // Moderators can see reactions of all users
if (!iAmModerator) { 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 }); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: ps.userId });
if ((me == null || me.id !== ps.userId) && !profile.publicReactions) { if ((me == null || me.id !== ps.userId) && !profile.publicReactions) {
throw new ApiError(meta.errors.reactionsNotPublic); throw new ApiError(meta.errors.reactionsNotPublic);

View File

@ -43,12 +43,11 @@ describe('User', () => {
'uri', 'uri',
'createdAt', 'createdAt',
'lastFetchedAt', 'lastFetchedAt',
'publicReactions',
]); ]);
}); });
}); });
describe('ffVisibility is federated', () => { describe('ff/reactions visibility is federated', () => {
let alice: LoginUser, bob: LoginUser; let alice: LoginUser, bob: LoginUser;
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
@ -78,6 +77,7 @@ describe('User', () => {
])) { ])) {
strictEqual(user.followersVisibility, 'public'); strictEqual(user.followersVisibility, 'public');
strictEqual(user.followingVisibility, 'public'); strictEqual(user.followingVisibility, 'public');
strictEqual(user.publicReactions, true);
} }
}); });
@ -113,6 +113,22 @@ describe('User', () => {
strictEqual(user.followingVisibility, 'private'); 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', () => { describe('isCat is federated', () => {

View File

@ -213,7 +213,7 @@ describe('ActivityPub', () => {
}); });
describe('Collection visibility', () => { describe('Collection visibility', () => {
test('Public following/followers', async () => { test('Public following/followers/reactions', async () => {
const actor = createRandomActor(); const actor = createRandomActor();
actor.following = { actor.following = {
id: `${actor.id}/following`, id: `${actor.id}/following`,
@ -222,6 +222,12 @@ describe('ActivityPub', () => {
first: `${actor.id}/following?page=1`, first: `${actor.id}/following?page=1`,
}; };
actor.followers = `${actor.id}/followers`; actor.followers = `${actor.id}/followers`;
actor.liked = {
id: `${actor.id}/following`,
type: 'OrderedCollection',
totalItems: 0,
orderedItems: [],
};
resolver.register(actor.id, actor); resolver.register(actor.id, actor);
resolver.register(actor.followers, { resolver.register(actor.followers, {
@ -236,9 +242,10 @@ describe('ActivityPub', () => {
assert.deepStrictEqual(userProfile.followingVisibility, 'public'); assert.deepStrictEqual(userProfile.followingVisibility, 'public');
assert.deepStrictEqual(userProfile.followersVisibility, '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(); const actor = createRandomActor();
actor.following = { actor.following = {
id: `${actor.id}/following`, id: `${actor.id}/following`,
@ -247,6 +254,7 @@ describe('ActivityPub', () => {
// first: … // first: …
}; };
actor.followers = `${actor.id}/followers`; actor.followers = `${actor.id}/followers`;
// actor.liked = …;
resolver.register(actor.id, actor); resolver.register(actor.id, actor);
//resolver.register(actor.followers, { … }); //resolver.register(actor.followers, { … });
@ -256,6 +264,7 @@ describe('ActivityPub', () => {
assert.deepStrictEqual(userProfile.followingVisibility, 'private'); assert.deepStrictEqual(userProfile.followingVisibility, 'private');
assert.deepStrictEqual(userProfile.followersVisibility, 'private'); assert.deepStrictEqual(userProfile.followersVisibility, 'private');
assert.deepStrictEqual(userProfile.publicReactions, false);
}); });
}); });