diff --git a/CHANGELOG.md b/CHANGELOG.md index 2492b187ac..18c5d5f558 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - Feat: 二要素認証のバックアップコードが生成されるようになりました - ref. https://github.com/MisskeyIO/misskey/pull/121 - Feat: 二要素認証でパスキーをサポートするようになりました +- Feat: プロフィールでのリンク検証 - Feat: 通知をテストできるようになりました - Feat: PWAのアイコンが設定できるようになりました - Enhance: manifest.jsonをオーバーライド可能に diff --git a/locales/index.d.ts b/locales/index.d.ts index 649b0be44a..bd1f10d86e 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1116,6 +1116,7 @@ export interface Locale { "loadConversation": string; "pinnedList": string; "keepScreenOn": string; + "verifiedLink": string; "_announcement": { "forExistingUsers": string; "forExistingUsersDescription": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 69228ed17e..8e684111fd 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1113,6 +1113,7 @@ loadReplies: "返信を見る" loadConversation: "会話を見る" pinnedList: "ピン留めされたリスト" keepScreenOn: "デバイスの画面を常にオンにする" +verifiedLink: "このリンク先の所有者であることが確認されました" _announcement: forExistingUsers: "既存ユーザーのみ" diff --git a/packages/backend/migration/1695260774117-verified-links.js b/packages/backend/migration/1695260774117-verified-links.js new file mode 100644 index 0000000000..18e0571d81 --- /dev/null +++ b/packages/backend/migration/1695260774117-verified-links.js @@ -0,0 +1,11 @@ +export class VerifiedLinks1695260774117 { + name = 'VerifiedLinks1695260774117' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" ADD "verifiedLinks" character varying array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "verifiedLinks"`); + } +} diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index c0909a663d..7bef410bf9 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -384,6 +384,7 @@ export class UserEntityService implements OnModuleInit { birthday: profile!.birthday, lang: profile!.lang, fields: profile!.fields, + verifiedLinks: profile!.verifiedLinks, followersCount: followersCount ?? 0, followingCount: followingCount ?? 0, notesCount: user.notesCount, diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts index 6c7ffe4c39..e4405c9da7 100644 --- a/packages/backend/src/models/UserProfile.ts +++ b/packages/backend/src/models/UserProfile.ts @@ -48,6 +48,12 @@ export class MiUserProfile { value: string; }[]; + @Column('varchar', { + array: true, + default: '{}', + }) + public verifiedLinks: string[]; + @Column('varchar', { length: 32, nullable: true, }) diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 3314464c31..8d0e4e72ed 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -169,6 +169,15 @@ export const packedUserDetailedNotMeOnlySchema = { }, }, }, + verifiedLinks: { + type: 'array', + nullable: false, optional: false, + items: { + type: 'string', + nullable: false, optional: false, + format: 'url', + }, + }, followersCount: { type: 'number', 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 3953b19002..b11e091957 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -6,11 +6,13 @@ import RE2 from 're2'; import * as mfm from 'mfm-js'; import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { JSDOM } from 'jsdom'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractHashtags } from '@/misc/extract-hashtags.js'; import * as Acct from '@/misc/acct.js'; import type { UsersRepository, DriveFilesRepository, UserProfilesRepository, PagesRepository } from '@/models/_.js'; -import type { MiUser } from '@/models/User.js'; +import type { MiLocalUser, MiUser } from '@/models/User.js'; import { birthdaySchema, descriptionSchema, locationSchema, nameSchema } from '@/models/User.js'; import type { MiUserProfile } from '@/models/UserProfile.js'; import { notificationTypes } from '@/types.js'; @@ -27,6 +29,9 @@ import { RoleService } from '@/core/RoleService.js'; import { CacheService } from '@/core/CacheService.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import type { Config } from '@/config.js'; +import { safeForSql } from '@/misc/safe-for-sql.js'; import { ApiLoggerService } from '../../ApiLoggerService.js'; import { ApiError } from '../../error.js'; @@ -37,6 +42,11 @@ export const meta = { kind: 'write:account', + limit: { + duration: ms('1hour'), + max: 10, + }, + errors: { noSuchAvatar: { message: 'No such avatar file.', @@ -173,6 +183,9 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -195,9 +208,10 @@ export default class extends Endpoint { // eslint- private hashtagService: HashtagService, private roleService: RoleService, private cacheService: CacheService, + private httpRequestService: HttpRequestService, ) { super(meta, paramDef, async (ps, _user, token) => { - const user = await this.usersRepository.findOneByOrFail({ id: _user.id }); + const user = await this.usersRepository.findOneByOrFail({ id: _user.id }) as MiLocalUser; const isSecure = token == null; const updates = {} as Partial; @@ -296,9 +310,9 @@ export default class extends Endpoint { // eslint- if (ps.fields) { profileUpdates.fields = ps.fields - .filter(x => typeof x.name === 'string' && x.name !== '' && typeof x.value === 'string' && x.value !== '') + .filter(x => typeof x.name === 'string' && x.name.trim() !== '' && typeof x.value === 'string' && x.value.trim() !== '') .map(x => { - return { name: x.name, value: x.value }; + return { name: x.name.trim(), value: x.value.trim() }; }); } @@ -364,7 +378,11 @@ export default class extends Endpoint { // eslint- if (Object.keys(updates).includes('alsoKnownAs')) { this.cacheService.uriPersonCache.set(this.userEntityService.genLocalUserUri(user.id), { ...user, ...updates }); } - if (Object.keys(profileUpdates).length > 0) await this.userProfilesRepository.update(user.id, profileUpdates); + + await this.userProfilesRepository.update(user.id, { + ...profileUpdates, + verifiedLinks: [], + }); const iObj = await this.userEntityService.pack(user.id, user, { detail: true, @@ -386,7 +404,34 @@ export default class extends Endpoint { // eslint- // フォロワーにUpdateを配信 this.accountUpdateService.publishToFollowers(user.id); + const urls = updatedProfile.fields.filter(x => x.value.startsWith('https://')); + for (const url of urls) { + this.verifyLink(url.value, user); + } + return iObj; }); } + + private async verifyLink(url: string, user: MiLocalUser) { + if (!safeForSql(url)) return; + + const html = await this.httpRequestService.getHtml(url); + + const { window } = new JSDOM(html); + const doc = window.document; + + const myLink = `${this.config.url}/@${user.username}`; + + const includesMyLink = Array.from(doc.getElementsByTagName('a')).some(a => a.href === myLink); + + if (includesMyLink) { + await this.userProfilesRepository.createQueryBuilder('profile').update() + .where('userId = :userId', { userId: user.id }) + .set({ + verifiedLinks: () => `array_append("verifiedLinks", '${url}')`, // ここでSQLインジェクションされそうなのでとりあえず safeForSql で弾いている + }) + .execute(); + } + } } diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 2c396813ff..13cea0cfc2 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -102,6 +102,7 @@ describe('ユーザー', () => { birthday: user.birthday, lang: user.lang, fields: user.fields, + verifiedLinks: user.verifiedLinks, followersCount: user.followersCount, followingCount: user.followingCount, notesCount: user.notesCount, @@ -369,6 +370,7 @@ describe('ユーザー', () => { assert.strictEqual(response.birthday, null); assert.strictEqual(response.lang, null); assert.deepStrictEqual(response.fields, []); + assert.deepStrictEqual(response.verifiedLinks, []); assert.strictEqual(response.followersCount, 0); assert.strictEqual(response.followingCount, 0); assert.strictEqual(response.notesCount, 0); diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts index 14481deeea..2bda89196a 100644 --- a/packages/frontend/.storybook/fakes.ts +++ b/packages/frontend/.storybook/fakes.ts @@ -89,6 +89,7 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi value: 'https://misskey-hub.net', }, ], + verifiedLinks: [], followersCount: 1024, followingCount: 16, hasPendingFollowRequestFromYou: false, diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 8195c40bf9..385c81a97f 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -101,6 +101,7 @@ SPDX-License-Identifier: AGPL-3.0-only
+
@@ -671,7 +672,12 @@ onUnmounted(() => { diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 99b3852b02..fd2d0ced02 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -2778,6 +2778,7 @@ type UserDetailed = UserLite & { name: string; value: string; }[]; + verifiedLinks: string[]; followersCount: number; followingCount: number; hasPendingFollowRequestFromYou: boolean; diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index 2876339102..018210c96b 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -38,6 +38,7 @@ export type UserDetailed = UserLite & { description: string | null; ffVisibility: 'public' | 'followers' | 'private'; fields: {name: string; value: string}[]; + verifiedLinks: string[]; followersCount: number; followingCount: number; hasPendingFollowRequestFromYou: boolean;