diff --git a/DIFFERENCE.md b/DIFFERENCE.md index 00aab04f23..4883d62052 100755 --- a/DIFFERENCE.md +++ b/DIFFERENCE.md @@ -1,5 +1,6 @@ # DIFFRENCE <<<<<<< HEAD +<<<<<<< HEAD ## 2024.9.0-yami-1.3.1 ## Client - フォロー/フォロワー/アナウンス/みつける/Play/ギャラリー/チャンネル/TL/ユーザー/ノートのページをログイン必須に @@ -9,12 +10,17 @@ ## Feat - ロールで引用通知の設定を制限出来るように +======= +>>>>>>> 3c53bbbac6 (Merge pull request #40 from lqvp/master) ## 2024.9.0-yami-1.2.9 ## Feat - ノート数を隠せるように(連合しません) +<<<<<<< HEAD ======= >>>>>>> 00cf91cf30 (Merge pull request #39 from lqvp/master) +======= +>>>>>>> 3c53bbbac6 (Merge pull request #40 from lqvp/master) ## 2024.9.0-yami-1.2.8 ## Feat - Cherry-Pick アクティビティの非公開機能(hideki0403/kakurega.app) diff --git a/locales/index.d.ts b/locales/index.d.ts index 7d67454f51..57facc1d30 100755 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -3788,6 +3788,10 @@ export interface Locale extends ILocale { * スレッドのミュートを解除 */ "unmuteThread": string; + /** + * ノート数の公開範囲 + */ + "notesVisibility": string; /** * フォローの公開範囲 */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 538f93ad75..aaae9c65b0 100755 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -943,6 +943,7 @@ makeReactionsPublicDescription: "あなたがしたリアクション一覧を classic: "クラシック" muteThread: "スレッドをミュート" unmuteThread: "スレッドのミュートを解除" +notesVisibility: "ノート数の公開範囲" followingVisibility: "フォローの公開範囲" followersVisibility: "フォロワーの公開範囲" continueThread: "さらにスレッドを見る" diff --git a/packages/backend/migration/1702718871542-notesvisibility.js b/packages/backend/migration/1702718871542-notesvisibility.js new file mode 100644 index 0000000000..3c0b0ea680 --- /dev/null +++ b/packages/backend/migration/1702718871542-notesvisibility.js @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class notesvisibility1702718871542 { + constructor() { + this.name = 'notesvisibility1702718871542'; + } + + async up(queryRunner) { + await queryRunner.query(`CREATE TYPE "public"."user_profile_notesVisibility_enum" AS ENUM('public', 'followers', 'private')`); + await queryRunner.query(`ALTER TABLE "user_profile" ADD "notesVisibility" "public"."user_profile_notesVisibility_enum" NOT NULL DEFAULT 'public'`); + await queryRunner.query(`UPDATE "user_profile" SET "notesVisibility" = 'public'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "notesVisibility"`); + await queryRunner.query(`DROP TYPE "public"."user_profile_notesVisibility_enum"`); + } +} + diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts new file mode 100644 index 0000000000..99e3fc3c8f --- /dev/null +++ b/packages/backend/src/core/WebhookTestService.ts @@ -0,0 +1,436 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { MiAbuseUserReport, MiNote, MiUser, MiWebhook } from '@/models/_.js'; +import { bindThis } from '@/decorators.js'; +import { MiSystemWebhook, type SystemWebhookEventType } from '@/models/SystemWebhook.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.js'; +import { Packed } from '@/misc/json-schema.js'; +import { type WebhookEventTypes } from '@/models/Webhook.js'; +import { UserWebhookService } from '@/core/UserWebhookService.js'; +import { QueueService } from '@/core/QueueService.js'; + +const oneDayMillis = 24 * 60 * 60 * 1000; + +function generateAbuseReport(override?: Partial): MiAbuseUserReport { + return { + id: 'dummy-abuse-report1', + targetUserId: 'dummy-target-user', + targetUser: null, + reporterId: 'dummy-reporter-user', + reporter: null, + assigneeId: null, + assignee: null, + resolved: false, + forwarded: false, + comment: 'This is a dummy report for testing purposes.', + targetUserHost: null, + reporterHost: null, + ...override, + }; +} + +function generateDummyUser(override?: Partial): MiUser { + return { + id: 'dummy-user-1', + updatedAt: new Date(Date.now() - oneDayMillis * 7), + lastFetchedAt: new Date(Date.now() - oneDayMillis * 5), + lastActiveDate: new Date(Date.now() - oneDayMillis * 3), + hideOnlineStatus: false, + username: 'dummy1', + usernameLower: 'dummy1', + name: 'DummyUser1', + followersCount: 10, + followingCount: 5, + movedToUri: null, + movedAt: null, + alsoKnownAs: null, + notesCount: 30, + avatarId: null, + avatar: null, + bannerId: null, + banner: null, + avatarUrl: null, + bannerUrl: null, + avatarBlurhash: null, + bannerBlurhash: null, + avatarDecorations: [], + tags: [], + isSuspended: false, + isLocked: false, + isBot: false, + isCat: true, + isRoot: false, + isExplorable: true, + isHibernated: false, + isDeleted: false, + emojis: [], + score: 0, + host: null, + inbox: null, + sharedInbox: null, + featured: null, + uri: null, + followersUri: null, + token: null, + ...override, + }; +} + +function generateDummyNote(override?: Partial): MiNote { + return { + id: 'dummy-note-1', + replyId: null, + reply: null, + renoteId: null, + renote: null, + threadId: null, + text: 'This is a dummy note for testing purposes.', + name: null, + cw: null, + userId: 'dummy-user-1', + user: null, + localOnly: true, + reactionAcceptance: 'likeOnly', + renoteCount: 10, + repliesCount: 5, + clippedCount: 0, + reactions: {}, + visibility: 'public', + uri: null, + url: null, + fileIds: [], + attachedFileTypes: [], + visibleUserIds: [], + mentions: [], + mentionedRemoteUsers: '[]', + reactionAndUserPairCache: [], + emojis: [], + tags: [], + hasPoll: false, + channelId: null, + channel: null, + userHost: null, + replyUserId: null, + replyUserHost: null, + renoteUserId: null, + renoteUserHost: null, + ...override, + }; +} + +function toPackedNote(note: MiNote, detail = true, override?: Packed<'Note'>): Packed<'Note'> { + return { + id: note.id, + createdAt: new Date().toISOString(), + deletedAt: null, + text: note.text, + cw: note.cw, + userId: note.userId, + user: toPackedUserLite(note.user ?? generateDummyUser()), + replyId: note.replyId, + renoteId: note.renoteId, + isHidden: false, + visibility: note.visibility, + mentions: note.mentions, + visibleUserIds: note.visibleUserIds, + fileIds: note.fileIds, + files: [], + tags: note.tags, + poll: null, + emojis: note.emojis, + channelId: note.channelId, + channel: note.channel, + localOnly: note.localOnly, + reactionAcceptance: note.reactionAcceptance, + reactionEmojis: {}, + reactions: {}, + reactionCount: 0, + renoteCount: note.renoteCount, + repliesCount: note.repliesCount, + uri: note.uri ?? undefined, + url: note.url ?? undefined, + reactionAndUserPairCache: note.reactionAndUserPairCache, + ...(detail ? { + clippedCount: note.clippedCount, + reply: note.reply ? toPackedNote(note.reply, false) : null, + renote: note.renote ? toPackedNote(note.renote, true) : null, + myReaction: null, + } : {}), + ...override, + }; +} + +function toPackedUserLite(user: MiUser, override?: Packed<'UserLite'>): Packed<'UserLite'> { + return { + id: user.id, + name: user.name, + username: user.username, + host: user.host, + avatarUrl: user.avatarUrl, + avatarBlurhash: user.avatarBlurhash, + avatarDecorations: user.avatarDecorations.map(it => ({ + id: it.id, + angle: it.angle, + flipH: it.flipH, + url: 'https://example.com/dummy-image001.png', + offsetX: it.offsetX, + offsetY: it.offsetY, + })), + isBot: user.isBot, + isCat: user.isCat, + emojis: user.emojis, + onlineStatus: 'active', + badgeRoles: [], + ...override, + }; +} + +function toPackedUserDetailedNotMe(user: MiUser, override?: Packed<'UserDetailedNotMe'>): Packed<'UserDetailedNotMe'> { + return { + ...toPackedUserLite(user), + url: null, + uri: null, + movedTo: null, + alsoKnownAs: [], + createdAt: new Date().toISOString(), + updatedAt: user.updatedAt?.toISOString() ?? null, + lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null, + bannerUrl: user.bannerUrl, + bannerBlurhash: user.bannerBlurhash, + isLocked: user.isLocked, + isSilenced: false, + isSuspended: user.isSuspended, + description: null, + location: null, + birthday: null, + lang: null, + fields: [], + verifiedLinks: [], + followersCount: user.followersCount, + followingCount: user.followingCount, + notesCount: user.notesCount, + pinnedNoteIds: [], + pinnedNotes: [], + pinnedPageId: null, + pinnedPage: null, + publicReactions: true, + notesVisibility: 'public', + followersVisibility: 'public', + followingVisibility: 'public', + twoFactorEnabled: false, + usePasswordLessLogin: false, + securityKeys: false, + roles: [], + memo: null, + moderationNote: undefined, + isFollowing: false, + isFollowed: false, + hasPendingFollowRequestFromYou: false, + hasPendingFollowRequestToYou: false, + isBlocking: false, + isBlocked: false, + isMuted: false, + isRenoteMuted: false, + notify: 'none', + withReplies: true, + ...override, + }; +} + +const dummyUser1 = generateDummyUser(); +const dummyUser2 = generateDummyUser({ + id: 'dummy-user-2', + updatedAt: new Date(Date.now() - oneDayMillis * 30), + lastFetchedAt: new Date(Date.now() - oneDayMillis), + lastActiveDate: new Date(Date.now() - oneDayMillis), + username: 'dummy2', + usernameLower: 'dummy2', + name: 'DummyUser2', + followersCount: 40, + followingCount: 50, + notesCount: 900, +}); +const dummyUser3 = generateDummyUser({ + id: 'dummy-user-3', + updatedAt: new Date(Date.now() - oneDayMillis * 15), + lastFetchedAt: new Date(Date.now() - oneDayMillis * 2), + lastActiveDate: new Date(Date.now() - oneDayMillis * 2), + username: 'dummy3', + usernameLower: 'dummy3', + name: 'DummyUser3', + followersCount: 60, + followingCount: 70, + notesCount: 15900, +}); + +@Injectable() +export class WebhookTestService { + public static NoSuchWebhookError = class extends Error {}; + + constructor( + private userWebhookService: UserWebhookService, + private systemWebhookService: SystemWebhookService, + private queueService: QueueService, + ) { + } + + /** + * UserWebhookのテスト送信を行う. + * 送信されるペイロードはいずれもダミーの値で、実際にはデータベース上に存在しない. + * + * また、この関数経由で送信されるWebhookは以下の設定を無視する. + * - Webhookそのものの有効・無効設定(active) + * - 送信対象イベント(on)に関する設定 + */ + @bindThis + public async testUserWebhook( + params: { + webhookId: MiWebhook['id'], + type: WebhookEventTypes, + override?: Partial>, + }, + sender: MiUser | null, + ) { + const webhooks = await this.userWebhookService.fetchWebhooks({ ids: [params.webhookId] }) + .then(it => it.filter(it => it.userId === sender?.id)); + if (webhooks.length === 0) { + throw new WebhookTestService.NoSuchWebhookError(); + } + + const webhook = webhooks[0]; + const send = (contents: unknown) => { + const merged = { + ...webhook, + ...params.override, + }; + + // テスト目的なのでUserWebhookServiceの機能を経由せず直接キューに追加する(チェック処理などをスキップする意図). + // また、Jobの試行回数も1回だけ. + this.queueService.userWebhookDeliver(merged, params.type, contents, { attempts: 1 }); + }; + + const dummyNote1 = generateDummyNote({ + userId: dummyUser1.id, + user: dummyUser1, + }); + const dummyReply1 = generateDummyNote({ + id: 'dummy-reply-1', + replyId: dummyNote1.id, + reply: dummyNote1, + userId: dummyUser1.id, + user: dummyUser1, + }); + const dummyRenote1 = generateDummyNote({ + id: 'dummy-renote-1', + renoteId: dummyNote1.id, + renote: dummyNote1, + userId: dummyUser2.id, + user: dummyUser2, + text: null, + }); + const dummyMention1 = generateDummyNote({ + id: 'dummy-mention-1', + userId: dummyUser1.id, + user: dummyUser1, + text: `@${dummyUser2.username} This is a mention to you.`, + mentions: [dummyUser2.id], + }); + + switch (params.type) { + case 'note': { + send(toPackedNote(dummyNote1)); + break; + } + case 'reply': { + send(toPackedNote(dummyReply1)); + break; + } + case 'renote': { + send(toPackedNote(dummyRenote1)); + break; + } + case 'mention': { + send(toPackedNote(dummyMention1)); + break; + } + case 'follow': { + send(toPackedUserDetailedNotMe(dummyUser1)); + break; + } + case 'followed': { + send(toPackedUserLite(dummyUser2)); + break; + } + case 'unfollow': { + send(toPackedUserDetailedNotMe(dummyUser3)); + break; + } + } + } + + /** + * SystemWebhookのテスト送信を行う. + * 送信されるペイロードはいずれもダミーの値で、実際にはデータベース上に存在しない. + * + * また、この関数経由で送信されるWebhookは以下の設定を無視する. + * - Webhookそのものの有効・無効設定(isActive) + * - 送信対象イベント(on)に関する設定 + */ + @bindThis + public async testSystemWebhook( + params: { + webhookId: MiSystemWebhook['id'], + type: SystemWebhookEventType, + override?: Partial>, + }, + ) { + const webhooks = await this.systemWebhookService.fetchSystemWebhooks({ ids: [params.webhookId] }); + if (webhooks.length === 0) { + throw new WebhookTestService.NoSuchWebhookError(); + } + + const webhook = webhooks[0]; + const send = (contents: unknown) => { + const merged = { + ...webhook, + ...params.override, + }; + + // テスト目的なのでSystemWebhookServiceの機能を経由せず直接キューに追加する(チェック処理などをスキップする意図). + // また、Jobの試行回数も1回だけ. + this.queueService.systemWebhookDeliver(merged, params.type, contents, { attempts: 1 }); + }; + + switch (params.type) { + case 'abuseReport': { + send(generateAbuseReport({ + targetUserId: dummyUser1.id, + targetUser: dummyUser1, + reporterId: dummyUser2.id, + reporter: dummyUser2, + })); + break; + } + case 'abuseReportResolved': { + send(generateAbuseReport({ + targetUserId: dummyUser1.id, + targetUser: dummyUser1, + reporterId: dummyUser2.id, + reporter: dummyUser2, + assigneeId: dummyUser3.id, + assignee: dummyUser3, + resolved: true, + })); + break; + } + case 'userCreated': { + send(toPackedUserLite(dummyUser1)); + break; + } + } + } +} diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 10b1fc0bf4..3483e9d81a 100755 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -49,6 +49,7 @@ import type { ApLoggerService } from '../ApLoggerService.js'; // eslint-disable-next-line @typescript-eslint/consistent-type-imports import type { ApImageService } from './ApImageService.js'; import type { IActor, ICollection, IObject, IOrderedCollection } from '../type.js'; +import unfavorite from '@/server/api/endpoints/channels/unfavorite.js'; const nameLength = 128; const summaryLength = 2048; @@ -308,15 +309,16 @@ export class ApPersonService implements OnModuleInit { const isBot = getApType(object) === 'Service' || getApType(object) === 'Application'; - const [followingVisibility, followersVisibility] = await Promise.all( + const [notesVisibility, followingVisibility, followersVisibility] = await Promise.all( [ + this.isPublicCollection(person.notes, resolver), this.isPublicCollection(person.following, resolver), this.isPublicCollection(person.followers, resolver), ].map((p): Promise<'public' | 'private'> => p .then(isPublic => isPublic ? 'public' : 'private') .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 notes/following/followers collection', { stack: err }); } return 'private'; }) @@ -397,6 +399,7 @@ export class ApPersonService implements OnModuleInit { description: _description, url, fields, + notesVisibility, followingVisibility, followersVisibility, birthday: bday?.[0] ?? null, @@ -507,15 +510,16 @@ export class ApPersonService implements OnModuleInit { const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32); - const [followingVisibility, followersVisibility] = await Promise.all( + const [notesVisibility, followingVisibility, followersVisibility] = await Promise.all( [ + this.isPublicCollection(person.notes, resolver), this.isPublicCollection(person.following, resolver), this.isPublicCollection(person.followers, resolver), ].map((p): Promise<'public' | 'private' | undefined> => p .then(isPublic => isPublic ? 'public' : 'private') .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 notes/following/followers collection', { stack: err }); // Do not update the visibiility on transient errors. return undefined; } @@ -595,6 +599,7 @@ export class ApPersonService implements OnModuleInit { url, fields, description: _description, + notesVisibility, followingVisibility, followersVisibility, birthday: bday?.[0] ?? null, diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 2f58825de1..7a3e3cd964 100755 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -185,6 +185,7 @@ export interface IActor extends IObject { id: string; publicKeyPem: string; }; + notes?: string | ICollection | IOrderedCollection; followers?: string | ICollection | IOrderedCollection; following?: string | ICollection | IOrderedCollection; featured?: string | IOrderedCollection; diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index a16b2b7d41..ada891e7d1 100755 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -489,6 +489,10 @@ export class UserEntityService implements OnModuleInit { } const mastoapi = !isDetailed ? opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }) : null; + const notesCount = profile == null ? null : + (profile.notesVisibility === 'public') || isMe || iAmModerator ? user.notesCount : + (profile.notesVisibility === 'followers') && (relation && relation.isFollowing) ? user.notesCount : + null; const followingCount = profile == null ? null : (profile.followingVisibility === 'public') || isMe || iAmModerator ? user.followingCount : @@ -544,7 +548,7 @@ export class UserEntityService implements OnModuleInit { } : undefined) : undefined, followersCount: followersCount ?? 0, followingCount: followingCount ?? 0, - notesCount: user.notesCount, + notesCount: notesCount ?? 0, emojis: this.customEmojiService.populateEmojis(user.emojis, checkHost), onlineStatus: this.getOnlineStatus(user), // パフォーマンス上の理由でローカルユーザーのみ @@ -588,6 +592,7 @@ export class UserEntityService implements OnModuleInit { 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 hideActivity: this.isLocalUser(user) ? profile!.hideActivity : false, // + notesVisibility: profile!.notesVisibility, followersVisibility: profile!.followersVisibility, followingVisibility: profile!.followingVisibility, twoFactorEnabled: profile!.twoFactorEnabled, diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts index 34cc86d49f..c8ec48297c 100755 --- a/packages/backend/src/models/UserProfile.ts +++ b/packages/backend/src/models/UserProfile.ts @@ -4,7 +4,7 @@ */ import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; -import { obsoleteNotificationTypes, followingVisibilities, followersVisibilities, notificationTypes } from '@/types.js'; +import { obsoleteNotificationTypes, notesVisibilities, followingVisibilities, followersVisibilities, notificationTypes } from '@/types.js'; import { id } from './util/id.js'; import { MiUser } from './User.js'; import { MiPage } from './Page.js'; @@ -100,6 +100,12 @@ export class MiUserProfile { }) public publicReactions: boolean; + @Column('enum', { + enum: notesVisibilities, + default: 'public', + }) + public notesVisibility: typeof notesVisibilities[number] + @Column('enum', { enum: followingVisibilities, default: 'public', diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 1806c3dace..6b8d74df8d 100755 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -373,6 +373,11 @@ export const packedUserDetailedNotMeOnlySchema = { type: 'boolean', nullable: false, optional: false, }, + notesVisibility: { + type: 'string', + nullable: false, optional: false, + enum: ['public', 'followers', 'private'], + }, followingVisibility: { type: 'string', nullable: false, optional: false, diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 2e940894a5..74d0981542 100755 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -195,6 +195,7 @@ export const paramDef = { receiveAnnouncementEmail: { type: 'boolean' }, alwaysMarkNsfw: { type: 'boolean' }, autoSensitive: { type: 'boolean' }, + notesVisibility: { type: 'string', enum: ['public', 'followers', 'private'] }, followingVisibility: { type: 'string', enum: ['public', 'followers', 'private'] }, followersVisibility: { type: 'string', enum: ['public', 'followers', 'private'] }, pinnedPageId: { type: 'string', format: 'misskey:id', nullable: true }, @@ -289,6 +290,7 @@ export default class extends Endpoint { // eslint- if (ps.location !== undefined) profileUpdates.location = ps.location; if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday; if (ps.listenbrainz !== undefined) profileUpdates.listenbrainz = ps.listenbrainz; + if (ps.notesVisibility !== undefined) profileUpdates.notesVisibility = ps.notesVisibility; if (ps.followingVisibility !== undefined) profileUpdates.followingVisibility = ps.followingVisibility; if (ps.followersVisibility !== undefined) profileUpdates.followersVisibility = ps.followersVisibility; diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index fc0174d692..26d6346271 100755 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -5,7 +5,7 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/_.js'; +import type { MiMeta, UsersRepository, NotesRepository, UserProfilesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; @@ -16,6 +16,9 @@ import { MetaService } from '@/core/MetaService.js'; import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { FanoutTimelineName } from '@/core/FanoutTimelineService.js'; +import { IsNull } from 'typeorm'; +import { UtilityService } from '@/core/UtilityService.js'; +import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '@/server/api/error.js'; export const meta = { @@ -40,6 +43,12 @@ export const meta = { id: '27e494ba-2ac2-48e8-893b-10d4d8c2387b', }, + forbidden: { + message: 'Forbidden.', + code: 'FORBIDDEN', + id: 'f6cdb0df-c19f-ec5c-7dbb-0ba84a1f92ba', + }, + bothWithRepliesAndWithFiles: { message: 'Specifying both withReplies and withFiles is not supported', code: 'BOTH_WITH_REPLIES_AND_WITH_FILES', @@ -62,22 +71,45 @@ export const paramDef = { untilDate: { type: 'integer' }, allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default withFiles: { type: 'boolean', default: false }, + + username: { type: 'string' }, + host: { + type: 'string', + nullable: true, + description: 'The local host is represented with `null`.', + }, }, + anyOf: [ + { required: ['userId'] }, + { required: ['username', 'host'] }, + ], required: ['userId'], } as const; @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + + @Inject(DI.meta) + private serverSettings: MiMeta, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private utilityService: UtilityService, private noteEntityService: NoteEntityService, private queryService: QueryService, private cacheService: CacheService, private idService: IdService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private metaService: MetaService, + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -88,6 +120,38 @@ export default class extends Endpoint { // eslint- if (ps.withReplies && ps.withFiles) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles); + const user = await this.usersRepository.findOneBy(ps.userId != null + ? { id: ps.userId } + : { usernameLower: ps.username!.toLowerCase(), host: this.utilityService.toPunyNullable(ps.host) ?? IsNull() }); + + if (user == null) { + throw new ApiError(meta.errors.noSuchUser); + } + + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + + if (profile.notesVisibility !== 'public' && !await this.roleService.isModerator(me)) { + if (profile.notesVisibility === 'private') { + if (me == null || (me.id !== user.id)) { + throw new ApiError(meta.errors.forbidden); + } + } else if (profile.notesVisibility === 'followers') { + if (me == null) { + throw new ApiError(meta.errors.forbidden); + } else if (me.id !== user.id) { + const isFollowing = await this.notesRepository.exists({ + where: { + followeeId: user.id, + followerId: me.id, + }, + }); + if (!isFollowing) { + throw new ApiError(meta.errors.forbidden); + } + } + } + } + // early return if me is blocked by requesting user if (me != null) { const userIdsWhoBlockingMe = await this.cacheService.userBlockedCache.fetch(me.id); diff --git a/packages/backend/src/server/web/FeedService.ts b/packages/backend/src/server/web/FeedService.ts index 8c880baa85..3c2a1cc4aa 100755 --- a/packages/backend/src/server/web/FeedService.ts +++ b/packages/backend/src/server/web/FeedService.ts @@ -58,21 +58,21 @@ export class FeedService { take: 20, }); - const feed = new Feed({ - id: author.link, - title: `${author.name} (@${user.username}@${this.config.host})`, - updated: notes.length !== 0 ? this.idService.parse(notes[0].id).date : undefined, - generator: 'Sharkey', - description: `${user.notesCount} Notes, ${profile.followingVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.followersVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`, - link: author.link, - image: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user), - feedLinks: { - json: `${author.link}.json`, - atom: `${author.link}.atom`, - }, - author, - copyright: user.name ?? user.username, - }); + const feed = new Feed({ + id: author.link, + title: `${author.name} (@${user.username}@${this.config.host})`, + updated: notes.length !== 0 ? this.idService.parse(notes[0].id).date : undefined, + generator: 'Misskey', + description: `${(profile.notesVisibility === 'public') ? user.notesCount : '?'} Notes ${profile.followingVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.followersVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`, + link: author.link, + image: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user), + feedLinks: { + json: `${author.link}.json`, + atom: `${author.link}.atom`, + }, + author, + copyright: user.name ?? user.username, + }); for (const note of notes) { const files = note.fileIds.length > 0 ? await this.driveFilesRepository.findBy({ diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index d83d414096..c3d73c2edb 100755 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -49,6 +49,7 @@ export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const; +export const notesVisibilities = ['public', 'followers', 'private'] as const; export const followingVisibilities = ['public', 'followers', 'private'] as const; export const followersVisibilities = ['public', 'followers', 'private'] as const; diff --git a/packages/frontend/src/components/MkUserInfo.vue b/packages/frontend/src/components/MkUserInfo.vue index e528f04dfc..7af4df5d3f 100755 --- a/packages/frontend/src/components/MkUserInfo.vue +++ b/packages/frontend/src/components/MkUserInfo.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.noAccountDescription }}
-
+

{{ i18n.ts.notes }}

{{ number(user.notesCount) }}
@@ -40,7 +40,7 @@ import number from '@/filters/number.js'; import { userPage } from '@/filters/user.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; -import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js'; +import { isNotesVisibilityForMe, isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js'; import { getStaticImageUrl } from '@/scripts/media-proxy.js'; import { defaultStore } from '@/store.js'; diff --git a/packages/frontend/src/components/MkUserPopup.vue b/packages/frontend/src/components/MkUserPopup.vue index c6f4699b3e..12cc186442 100755 --- a/packages/frontend/src/components/MkUserPopup.vue +++ b/packages/frontend/src/components/MkUserPopup.vue @@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-
+
{{ i18n.ts.notes }}
{{ number(user.notesCount) }}
@@ -78,7 +78,7 @@ import number from '@/filters/number.js'; import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; import { $i } from '@/account.js'; -import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js'; +import { isNotesVisibilityForMe, isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js'; import { getStaticImageUrl } from '@/scripts/media-proxy.js'; const props = defineProps<{ diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue index eca135cf30..c749c8c337 100755 --- a/packages/frontend/src/pages/settings/privacy.vue +++ b/packages/frontend/src/pages/settings/privacy.vue @@ -22,6 +22,13 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + + @@ -103,6 +110,7 @@ const isExplorable = ref($i.isExplorable); const hideOnlineStatus = ref($i.hideOnlineStatus); const publicReactions = ref($i.publicReactions); const hideActivity = ref($i.hideActivity); +const notesVisibility = ref($i.notesVisibility); const followingVisibility = ref($i.followingVisibility); const followersVisibility = ref($i.followersVisibility); @@ -122,6 +130,7 @@ function save() { hideOnlineStatus: !!hideOnlineStatus.value, publicReactions: !!publicReactions.value, hideActivity: !!hideActivity.value, + notesVisibility: notesVisibility.value, followingVisibility: followingVisibility.value, followersVisibility: followersVisibility.value, }); diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index e5c428897b..842c63ba20 100755 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -103,7 +103,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- + {{ number(user.notesCount) }} {{ i18n.ts.notes }} @@ -186,7 +186,7 @@ import { $i, iAmModerator } from '@/account.js'; import { dateString } from '@/filters/date.js'; import { confetti } from '@/scripts/confetti.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; -import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js'; +import { isNotesVisibilityForMe, isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js'; import { useRouter } from '@/router/supplier.js'; import { getStaticImageUrl } from '@/scripts/media-proxy.js'; diff --git a/packages/frontend/src/scripts/isFfVisibleForMe.ts b/packages/frontend/src/scripts/isFfVisibleForMe.ts index e28e5725bc..b4a7224cd0 100755 --- a/packages/frontend/src/scripts/isFfVisibleForMe.ts +++ b/packages/frontend/src/scripts/isFfVisibleForMe.ts @@ -6,6 +6,14 @@ import * as Misskey from 'misskey-js'; import { $i } from '@/account.js'; +export function isNotesVisibilityForMe(user: Misskey.entities.UserDetailed): boolean { + if ($i && ($i.id === user.id || $i.isAdmin || $i.isModerator)) return true; + + if (user.notesVisibility === 'private') return false; + if (user.notesVisibility === 'followers' && !user.isFollowing) return false; + + return true; +} export function isFollowingVisibleForMe(user: Misskey.entities.UserDetailed): boolean { if ($i && ($i.id === user.id || $i.isAdmin || $i.isModerator)) return true; diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 87fda5568e..7000ab79df 100755 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -2030,6 +2030,9 @@ type FollowingUpdateRequest = operations['following___update']['requestBody']['c // @public (undocumented) type FollowingUpdateResponse = operations['following___update']['responses']['200']['content']['application/json']; +// @public (undocumented) +export const notesVisibilities: readonly ["public", "followers", "private"]; + // @public (undocumented) export const followingVisibilities: readonly ["public", "followers", "private"]; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 67c04cc2c2..a26cd4a4f5 100755 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -3889,6 +3889,8 @@ export type components = { pinnedPage: components['schemas']['Page'] | null; publicReactions: boolean; hideActivity: boolean; + /** @enum {string} */ + notesVisibility: 'public' | 'followers' | 'private'; /** @enum {string} */ followingVisibility: 'public' | 'followers' | 'private'; /** @enum {string} */ diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts index 890b43639d..dcb5d26f00 100755 --- a/packages/misskey-js/src/consts.ts +++ b/packages/misskey-js/src/consts.ts @@ -22,6 +22,8 @@ export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const; +export const notesVisibilities = ['public', 'followers', 'private'] as const; + export const followingVisibilities = ['public', 'followers', 'private'] as const; export const followersVisibilities = ['public', 'followers', 'private'] as const; diff --git a/packages/misskey-js/src/index.ts b/packages/misskey-js/src/index.ts index ace9738e6a..6c569ce381 100755 --- a/packages/misskey-js/src/index.ts +++ b/packages/misskey-js/src/index.ts @@ -19,6 +19,7 @@ export const permissions = consts.permissions; export const notificationTypes = consts.notificationTypes; export const noteVisibilities = consts.noteVisibilities; export const mutedNoteReasons = consts.mutedNoteReasons; +export const notesVisibilities = consts.notesVisibilities; export const followingVisibilities = consts.followingVisibilities; export const followersVisibilities = consts.followersVisibilities; export const moderationLogTypes = consts.moderationLogTypes;