From a28c515ef63a6f9c188cf0a7f544db1afa8e1331 Mon Sep 17 00:00:00 2001 From: syuilo Date: Sun, 7 Nov 2021 18:04:32 +0900 Subject: [PATCH] feat: make possible to configure following/followers visibility (#7959) * feat: make possible to configure following/followers visibility * add test * ap * add ap test * set Cache-Control * hide following/followers count --- CHANGELOG.md | 1 + locales/ja-JP.yml | 7 + migration/1636197624383-ff-visibility.ts | 16 ++ src/client/pages/settings/privacy.vue | 12 ++ src/models/entities/user-profile.ts | 8 +- src/models/repositories/user.ts | 15 +- src/server/activitypub/followers.ts | 16 +- src/server/activitypub/following.ts | 16 +- src/server/api/endpoints/i/update.ts | 5 + src/server/api/endpoints/users/followers.ts | 30 +++- src/server/api/endpoints/users/following.ts | 30 +++- src/server/web/feed.ts | 2 +- src/types.ts | 2 + test/ff-visibility.ts | 167 ++++++++++++++++++++ 14 files changed, 317 insertions(+), 10 deletions(-) create mode 100644 migration/1636197624383-ff-visibility.ts create mode 100644 test/ff-visibility.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fb7c306c5..7eed50a8d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ## 12.x.x (unreleased) ### Improvements +- フォロー/フォロワーを非公開にできるように ### Bugfixes - クライアント: 長いメニューが画面からはみ出す問題を修正 diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 8d00d39e27..26d57039ce 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -804,6 +804,13 @@ makeReactionsPublicDescription: "あなたがしたリアクション一覧を classic: "クラシック" muteThread: "スレッドをミュート" unmuteThread: "スレッドのミュートを解除" +ffVisibility: "つながりの公開範囲" +ffVisibilityDescription: "自分のフォロー/フォロワー情報の公開範囲を設定できます。" + +_ffVisibility: + public: "公開" + followers: "フォロワーだけに公開" + private: "非公開" _signup: almostThere: "ほとんど完了です" diff --git a/migration/1636197624383-ff-visibility.ts b/migration/1636197624383-ff-visibility.ts new file mode 100644 index 0000000000..8829aeb884 --- /dev/null +++ b/migration/1636197624383-ff-visibility.ts @@ -0,0 +1,16 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class ffVisibility1636197624383 implements MigrationInterface { + name = 'ffVisibility1636197624383' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "public"."user_profile_ffvisibility_enum" AS ENUM('public', 'followers', 'private')`); + await queryRunner.query(`ALTER TABLE "user_profile" ADD "ffVisibility" "public"."user_profile_ffvisibility_enum" NOT NULL DEFAULT 'public'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "ffVisibility"`); + await queryRunner.query(`DROP TYPE "public"."user_profile_ffvisibility_enum"`); + } + +} diff --git a/src/client/pages/settings/privacy.vue b/src/client/pages/settings/privacy.vue index 2a60ae1f46..5e0c259ca3 100644 --- a/src/client/pages/settings/privacy.vue +++ b/src/client/pages/settings/privacy.vue @@ -9,6 +9,15 @@ {{ $ts.makeReactionsPublic }} + + + + + + + + + {{ $ts.hideOnlineStatus }} @@ -69,6 +78,7 @@ export default defineComponent({ isExplorable: false, hideOnlineStatus: false, publicReactions: false, + ffVisibility: 'public', } }, @@ -86,6 +96,7 @@ export default defineComponent({ this.isExplorable = this.$i.isExplorable; this.hideOnlineStatus = this.$i.hideOnlineStatus; this.publicReactions = this.$i.publicReactions; + this.ffVisibility = this.$i.ffVisibility; }, mounted() { @@ -101,6 +112,7 @@ export default defineComponent({ isExplorable: !!this.isExplorable, hideOnlineStatus: !!this.hideOnlineStatus, publicReactions: !!this.publicReactions, + ffVisibility: this.ffVisibility, }); } } diff --git a/src/models/entities/user-profile.ts b/src/models/entities/user-profile.ts index 1f450f223d..8a8cacfd52 100644 --- a/src/models/entities/user-profile.ts +++ b/src/models/entities/user-profile.ts @@ -2,7 +2,7 @@ import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'type import { id } from '../id'; import { User } from './user'; import { Page } from './page'; -import { notificationTypes } from '@/types'; +import { ffVisibility, notificationTypes } from '@/types'; // TODO: このテーブルで管理している情報すべてレジストリで管理するようにしても良いかも // ただ、「emailVerified が true なユーザーを find する」のようなクエリは書けなくなるからウーン @@ -80,6 +80,12 @@ export class UserProfile { }) public publicReactions: boolean; + @Column('enum', { + enum: ffVisibility, + default: 'public', + }) + public ffVisibility: typeof ffVisibility[number]; + @Column('varchar', { length: 128, nullable: true, }) diff --git a/src/models/repositories/user.ts b/src/models/repositories/user.ts index 9598e87191..fc0860970c 100644 --- a/src/models/repositories/user.ts +++ b/src/models/repositories/user.ts @@ -187,6 +187,16 @@ export class UserRepository extends Repository { .getMany() : []; const profile = opts.detail ? await UserProfiles.findOneOrFail(user.id) : null; + const followingCount = profile == null ? null : + (profile.ffVisibility === 'public') || (meId === user.id) ? user.followingCount : + (profile.ffVisibility === 'followers') && (relation!.isFollowing) ? user.followingCount : + null; + + const followersCount = profile == null ? null : + (profile.ffVisibility === 'public') || (meId === user.id) ? user.followersCount : + (profile.ffVisibility === 'followers') && (relation!.isFollowing) ? user.followersCount : + null; + const falsy = opts.detail ? false : undefined; const packed = { @@ -230,8 +240,8 @@ export class UserRepository extends Repository { birthday: profile!.birthday, lang: profile!.lang, fields: profile!.fields, - followersCount: user.followersCount, - followingCount: user.followingCount, + followersCount: followersCount || 0, + followingCount: followingCount || 0, notesCount: user.notesCount, pinnedNoteIds: pins.map(pin => pin.noteId), pinnedNotes: Notes.packMany(pins.map(pin => pin.note!), me, { @@ -240,6 +250,7 @@ export class UserRepository extends Repository { pinnedPageId: profile!.pinnedPageId, pinnedPage: profile!.pinnedPageId ? Pages.pack(profile!.pinnedPageId, me) : null, publicReactions: profile!.publicReactions, + ffVisibility: profile!.ffVisibility, twoFactorEnabled: profile!.twoFactorEnabled, usePasswordLessLogin: profile!.usePasswordLessLogin, securityKeys: profile!.twoFactorEnabled diff --git a/src/server/activitypub/followers.ts b/src/server/activitypub/followers.ts index 8b6a066bf0..baf2d23460 100644 --- a/src/server/activitypub/followers.ts +++ b/src/server/activitypub/followers.ts @@ -8,7 +8,7 @@ import renderOrderedCollection from '@/remote/activitypub/renderer/ordered-colle import renderOrderedCollectionPage from '@/remote/activitypub/renderer/ordered-collection-page'; import renderFollowUser from '@/remote/activitypub/renderer/follow-user'; import { setResponseType } from '../activitypub'; -import { Users, Followings } from '@/models/index'; +import { Users, Followings, UserProfiles } from '@/models/index'; import { LessThan } from 'typeorm'; export default async (ctx: Router.RouterContext) => { @@ -38,6 +38,20 @@ export default async (ctx: Router.RouterContext) => { return; } + //#region Check ff visibility + const profile = await UserProfiles.findOneOrFail(user.id); + + if (profile.ffVisibility === 'private') { + ctx.status = 403; + ctx.set('Cache-Control', 'public, max-age=30'); + return; + } else if (profile.ffVisibility === 'followers') { + ctx.status = 403; + ctx.set('Cache-Control', 'public, max-age=30'); + return; + } + //#endregion + const limit = 10; const partOf = `${config.url}/users/${userId}/followers`; diff --git a/src/server/activitypub/following.ts b/src/server/activitypub/following.ts index 5fc5d68a9c..b9eb806c3c 100644 --- a/src/server/activitypub/following.ts +++ b/src/server/activitypub/following.ts @@ -8,7 +8,7 @@ import renderOrderedCollection from '@/remote/activitypub/renderer/ordered-colle import renderOrderedCollectionPage from '@/remote/activitypub/renderer/ordered-collection-page'; import renderFollowUser from '@/remote/activitypub/renderer/follow-user'; import { setResponseType } from '../activitypub'; -import { Users, Followings } from '@/models/index'; +import { Users, Followings, UserProfiles } from '@/models/index'; import { LessThan, FindConditions } from 'typeorm'; import { Following } from '@/models/entities/following'; @@ -39,6 +39,20 @@ export default async (ctx: Router.RouterContext) => { return; } + //#region Check ff visibility + const profile = await UserProfiles.findOneOrFail(user.id); + + if (profile.ffVisibility === 'private') { + ctx.status = 403; + ctx.set('Cache-Control', 'public, max-age=30'); + return; + } else if (profile.ffVisibility === 'followers') { + ctx.status = 403; + ctx.set('Cache-Control', 'public, max-age=30'); + return; + } + //#endregion + const limit = 10; const partOf = `${config.url}/users/${userId}/following`; diff --git a/src/server/api/endpoints/i/update.ts b/src/server/api/endpoints/i/update.ts index 3b8b1579ea..d0f201ab60 100644 --- a/src/server/api/endpoints/i/update.ts +++ b/src/server/api/endpoints/i/update.ts @@ -72,6 +72,10 @@ export const meta = { validator: $.optional.bool, }, + ffVisibility: { + validator: $.optional.str, + }, + carefulBot: { validator: $.optional.bool, }, @@ -174,6 +178,7 @@ export default define(meta, async (ps, _user, token) => { if (ps.lang !== undefined) profileUpdates.lang = ps.lang; if (ps.location !== undefined) profileUpdates.location = ps.location; if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday; + if (ps.ffVisibility !== undefined) profileUpdates.ffVisibility = ps.ffVisibility; if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId; if (ps.bannerId !== undefined) updates.bannerId = ps.bannerId; if (ps.mutedWords !== undefined) { diff --git a/src/server/api/endpoints/users/followers.ts b/src/server/api/endpoints/users/followers.ts index e54b6078ee..6d042a2861 100644 --- a/src/server/api/endpoints/users/followers.ts +++ b/src/server/api/endpoints/users/followers.ts @@ -2,7 +2,7 @@ import $ from 'cafy'; import { ID } from '@/misc/cafy-id'; import define from '../../define'; import { ApiError } from '../../error'; -import { Users, Followings } from '@/models/index'; +import { Users, Followings, UserProfiles } from '@/models/index'; import { makePaginationQuery } from '../../common/make-pagination-query'; import { toPunyNullable } from '@/misc/convert-host'; @@ -53,7 +53,13 @@ export const meta = { message: 'No such user.', code: 'NO_SUCH_USER', id: '27fa5435-88ab-43de-9360-387de88727cd' - } + }, + + forbidden: { + message: 'Forbidden.', + code: 'FORBIDDEN', + id: '3c6a84db-d619-26af-ca14-06232a21df8a' + }, } }; @@ -66,6 +72,26 @@ export default define(meta, async (ps, me) => { throw new ApiError(meta.errors.noSuchUser); } + const profile = await UserProfiles.findOneOrFail(user.id); + + if (profile.ffVisibility === 'private') { + if (me == null || (me.id !== user.id)) { + throw new ApiError(meta.errors.forbidden); + } + } else if (profile.ffVisibility === 'followers') { + if (me == null) { + throw new ApiError(meta.errors.forbidden); + } else if (me.id !== user.id) { + const following = await Followings.findOne({ + followeeId: user.id, + followerId: me.id, + }); + if (following == null) { + throw new ApiError(meta.errors.forbidden); + } + } + } + const query = makePaginationQuery(Followings.createQueryBuilder('following'), ps.sinceId, ps.untilId) .andWhere(`following.followeeId = :userId`, { userId: user.id }) .innerJoinAndSelect('following.follower', 'follower'); diff --git a/src/server/api/endpoints/users/following.ts b/src/server/api/endpoints/users/following.ts index f2ef7f47e1..1033117ef8 100644 --- a/src/server/api/endpoints/users/following.ts +++ b/src/server/api/endpoints/users/following.ts @@ -2,7 +2,7 @@ import $ from 'cafy'; import { ID } from '@/misc/cafy-id'; import define from '../../define'; import { ApiError } from '../../error'; -import { Users, Followings } from '@/models/index'; +import { Users, Followings, UserProfiles } from '@/models/index'; import { makePaginationQuery } from '../../common/make-pagination-query'; import { toPunyNullable } from '@/misc/convert-host'; @@ -53,7 +53,13 @@ export const meta = { message: 'No such user.', code: 'NO_SUCH_USER', id: '63e4aba4-4156-4e53-be25-c9559e42d71b' - } + }, + + forbidden: { + message: 'Forbidden.', + code: 'FORBIDDEN', + id: 'f6cdb0df-c19f-ec5c-7dbb-0ba84a1f92ba' + }, } }; @@ -66,6 +72,26 @@ export default define(meta, async (ps, me) => { throw new ApiError(meta.errors.noSuchUser); } + const profile = await UserProfiles.findOneOrFail(user.id); + + if (profile.ffVisibility === 'private') { + if (me == null || (me.id !== user.id)) { + throw new ApiError(meta.errors.forbidden); + } + } else if (profile.ffVisibility === 'followers') { + if (me == null) { + throw new ApiError(meta.errors.forbidden); + } else if (me.id !== user.id) { + const following = await Followings.findOne({ + followeeId: user.id, + followerId: me.id, + }); + if (following == null) { + throw new ApiError(meta.errors.forbidden); + } + } + } + const query = makePaginationQuery(Followings.createQueryBuilder('following'), ps.sinceId, ps.untilId) .andWhere(`following.followerId = :userId`, { userId: user.id }) .innerJoinAndSelect('following.followee', 'followee'); diff --git a/src/server/web/feed.ts b/src/server/web/feed.ts index 4b6de517b7..1d4c47dafb 100644 --- a/src/server/web/feed.ts +++ b/src/server/web/feed.ts @@ -27,7 +27,7 @@ export default async function(user: User) { title: `${author.name} (@${user.username}@${config.host})`, updated: notes[0].createdAt, generator: 'Misskey', - description: `${user.notesCount} Notes, ${user.followingCount} Following, ${user.followersCount} Followers${profile.description ? ` · ${profile.description}` : ''}`, + description: `${user.notesCount} Notes, ${profile.ffVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.ffVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`, link: author.link, image: user.avatarUrl ? user.avatarUrl : undefined, feedLinks: { diff --git a/src/types.ts b/src/types.ts index d8eb442810..20f6f8bb88 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,3 +3,5 @@ export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const; export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const; + +export const ffVisibility = ['public', 'followers', 'private'] as const; diff --git a/test/ff-visibility.ts b/test/ff-visibility.ts new file mode 100644 index 0000000000..295ab19335 --- /dev/null +++ b/test/ff-visibility.ts @@ -0,0 +1,167 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import * as childProcess from 'child_process'; +import { async, signup, request, post, react, connectStream, startServer, shutdownServer, simpleGet } from './utils'; + +describe('FF visibility', () => { + let p: childProcess.ChildProcess; + + let alice: any; + let bob: any; + let carol: any; + + before(async () => { + p = await startServer(); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + carol = await signup({ username: 'carol' }); + }); + + after(async () => { + await shutdownServer(p); + }); + + it('ffVisibility が public なユーザーのフォロー/フォロワーを誰でも見れる', async(async () => { + await request('/i/update', { + ffVisibility: 'public', + }, alice); + + const followingRes = await request('/users/following', { + userId: alice.id, + }, bob); + const followersRes = await request('/users/followers', { + userId: alice.id, + }, bob); + + assert.strictEqual(followingRes.status, 200); + assert.strictEqual(Array.isArray(followingRes.body), true); + assert.strictEqual(followersRes.status, 200); + assert.strictEqual(Array.isArray(followersRes.body), true); + })); + + it('ffVisibility が followers なユーザーのフォロー/フォロワーを自分で見れる', async(async () => { + await request('/i/update', { + ffVisibility: 'followers', + }, alice); + + const followingRes = await request('/users/following', { + userId: alice.id, + }, alice); + const followersRes = await request('/users/followers', { + userId: alice.id, + }, alice); + + assert.strictEqual(followingRes.status, 200); + assert.strictEqual(Array.isArray(followingRes.body), true); + assert.strictEqual(followersRes.status, 200); + assert.strictEqual(Array.isArray(followersRes.body), true); + })); + + it('ffVisibility が followers なユーザーのフォロー/フォロワーを非フォロワーが見れない', async(async () => { + await request('/i/update', { + ffVisibility: 'followers', + }, alice); + + const followingRes = await request('/users/following', { + userId: alice.id, + }, bob); + const followersRes = await request('/users/followers', { + userId: alice.id, + }, bob); + + assert.strictEqual(followingRes.status, 400); + assert.strictEqual(followersRes.status, 400); + })); + + it('ffVisibility が followers なユーザーのフォロー/フォロワーをフォロワーが見れる', async(async () => { + await request('/i/update', { + ffVisibility: 'followers', + }, alice); + + await request('/following/create', { + userId: alice.id, + }, bob); + + const followingRes = await request('/users/following', { + userId: alice.id, + }, bob); + const followersRes = await request('/users/followers', { + userId: alice.id, + }, bob); + + assert.strictEqual(followingRes.status, 200); + assert.strictEqual(Array.isArray(followingRes.body), true); + assert.strictEqual(followersRes.status, 200); + assert.strictEqual(Array.isArray(followersRes.body), true); + })); + + it('ffVisibility が private なユーザーのフォロー/フォロワーを自分で見れる', async(async () => { + await request('/i/update', { + ffVisibility: 'private', + }, alice); + + const followingRes = await request('/users/following', { + userId: alice.id, + }, alice); + const followersRes = await request('/users/followers', { + userId: alice.id, + }, alice); + + assert.strictEqual(followingRes.status, 200); + assert.strictEqual(Array.isArray(followingRes.body), true); + assert.strictEqual(followersRes.status, 200); + assert.strictEqual(Array.isArray(followersRes.body), true); + })); + + it('ffVisibility が private なユーザーのフォロー/フォロワーを他人が見れない', async(async () => { + await request('/i/update', { + ffVisibility: 'private', + }, alice); + + const followingRes = await request('/users/following', { + userId: alice.id, + }, bob); + const followersRes = await request('/users/followers', { + userId: alice.id, + }, bob); + + assert.strictEqual(followingRes.status, 400); + assert.strictEqual(followersRes.status, 400); + })); + + describe('AP', () => { + it('ffVisibility が public 以外ならばAPからは取得できない', async(async () => { + { + await request('/i/update', { + ffVisibility: 'public', + }, alice); + + const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json'); + const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json'); + assert.strictEqual(followingRes.status, 200); + assert.strictEqual(followersRes.status, 200); + } + { + await request('/i/update', { + ffVisibility: 'followers', + }, alice); + + const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json').catch(res => ({ status: res.statusCode })); + const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json').catch(res => ({ status: res.statusCode })); + assert.strictEqual(followingRes.status, 403); + assert.strictEqual(followersRes.status, 403); + } + { + await request('/i/update', { + ffVisibility: 'private', + }, alice); + + const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json').catch(res => ({ status: res.statusCode })); + const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json').catch(res => ({ status: res.statusCode })); + assert.strictEqual(followingRes.status, 403); + assert.strictEqual(followersRes.status, 403); + } + })); + }); +});