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 8fb7c306c..7eed50a8d 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 8d00d39e2..26d57039c 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 000000000..8829aeb88 --- /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 2a60ae1f4..5e0c259ca 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 1f450f223..8a8cacfd5 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 9598e8719..fc0860970 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 8b6a066bf..baf2d2346 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 5fc5d68a9..b9eb806c3 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 3b8b1579e..d0f201ab6 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 e54b6078e..6d042a286 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 f2ef7f47e..1033117ef 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 4b6de517b..1d4c47daf 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 d8eb44281..20f6f8bb8 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 000000000..295ab1933 --- /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); + } + })); + }); +});