parent
1250309a69
commit
e41619775f
|
@ -26,6 +26,7 @@
|
||||||
- Feat: 二要素認証のバックアップコードが生成されるようになりました
|
- Feat: 二要素認証のバックアップコードが生成されるようになりました
|
||||||
- ref. https://github.com/MisskeyIO/misskey/pull/121
|
- ref. https://github.com/MisskeyIO/misskey/pull/121
|
||||||
- Feat: 二要素認証でパスキーをサポートするようになりました
|
- Feat: 二要素認証でパスキーをサポートするようになりました
|
||||||
|
- Feat: プロフィールでのリンク検証
|
||||||
- Feat: 通知をテストできるようになりました
|
- Feat: 通知をテストできるようになりました
|
||||||
- Feat: PWAのアイコンが設定できるようになりました
|
- Feat: PWAのアイコンが設定できるようになりました
|
||||||
- Enhance: manifest.jsonをオーバーライド可能に
|
- Enhance: manifest.jsonをオーバーライド可能に
|
||||||
|
|
1
locales/index.d.ts
vendored
1
locales/index.d.ts
vendored
|
@ -1116,6 +1116,7 @@ export interface Locale {
|
||||||
"loadConversation": string;
|
"loadConversation": string;
|
||||||
"pinnedList": string;
|
"pinnedList": string;
|
||||||
"keepScreenOn": string;
|
"keepScreenOn": string;
|
||||||
|
"verifiedLink": string;
|
||||||
"_announcement": {
|
"_announcement": {
|
||||||
"forExistingUsers": string;
|
"forExistingUsers": string;
|
||||||
"forExistingUsersDescription": string;
|
"forExistingUsersDescription": string;
|
||||||
|
|
|
@ -1113,6 +1113,7 @@ loadReplies: "返信を見る"
|
||||||
loadConversation: "会話を見る"
|
loadConversation: "会話を見る"
|
||||||
pinnedList: "ピン留めされたリスト"
|
pinnedList: "ピン留めされたリスト"
|
||||||
keepScreenOn: "デバイスの画面を常にオンにする"
|
keepScreenOn: "デバイスの画面を常にオンにする"
|
||||||
|
verifiedLink: "このリンク先の所有者であることが確認されました"
|
||||||
|
|
||||||
_announcement:
|
_announcement:
|
||||||
forExistingUsers: "既存ユーザーのみ"
|
forExistingUsers: "既存ユーザーのみ"
|
||||||
|
|
11
packages/backend/migration/1695260774117-verified-links.js
Normal file
11
packages/backend/migration/1695260774117-verified-links.js
Normal file
|
@ -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"`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -384,6 +384,7 @@ export class UserEntityService implements OnModuleInit {
|
||||||
birthday: profile!.birthday,
|
birthday: profile!.birthday,
|
||||||
lang: profile!.lang,
|
lang: profile!.lang,
|
||||||
fields: profile!.fields,
|
fields: profile!.fields,
|
||||||
|
verifiedLinks: profile!.verifiedLinks,
|
||||||
followersCount: followersCount ?? 0,
|
followersCount: followersCount ?? 0,
|
||||||
followingCount: followingCount ?? 0,
|
followingCount: followingCount ?? 0,
|
||||||
notesCount: user.notesCount,
|
notesCount: user.notesCount,
|
||||||
|
|
|
@ -48,6 +48,12 @@ export class MiUserProfile {
|
||||||
value: string;
|
value: string;
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
|
@Column('varchar', {
|
||||||
|
array: true,
|
||||||
|
default: '{}',
|
||||||
|
})
|
||||||
|
public verifiedLinks: string[];
|
||||||
|
|
||||||
@Column('varchar', {
|
@Column('varchar', {
|
||||||
length: 32, nullable: true,
|
length: 32, nullable: true,
|
||||||
})
|
})
|
||||||
|
|
|
@ -169,6 +169,15 @@ export const packedUserDetailedNotMeOnlySchema = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
verifiedLinks: {
|
||||||
|
type: 'array',
|
||||||
|
nullable: false, optional: false,
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
nullable: false, optional: false,
|
||||||
|
format: 'url',
|
||||||
|
},
|
||||||
|
},
|
||||||
followersCount: {
|
followersCount: {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
nullable: false, optional: false,
|
nullable: false, optional: false,
|
||||||
|
|
|
@ -6,11 +6,13 @@
|
||||||
import RE2 from 're2';
|
import RE2 from 're2';
|
||||||
import * as mfm from 'mfm-js';
|
import * as mfm from 'mfm-js';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
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 { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
|
||||||
import { extractHashtags } from '@/misc/extract-hashtags.js';
|
import { extractHashtags } from '@/misc/extract-hashtags.js';
|
||||||
import * as Acct from '@/misc/acct.js';
|
import * as Acct from '@/misc/acct.js';
|
||||||
import type { UsersRepository, DriveFilesRepository, UserProfilesRepository, PagesRepository } from '@/models/_.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 { birthdaySchema, descriptionSchema, locationSchema, nameSchema } from '@/models/User.js';
|
||||||
import type { MiUserProfile } from '@/models/UserProfile.js';
|
import type { MiUserProfile } from '@/models/UserProfile.js';
|
||||||
import { notificationTypes } from '@/types.js';
|
import { notificationTypes } from '@/types.js';
|
||||||
|
@ -27,6 +29,9 @@ import { RoleService } from '@/core/RoleService.js';
|
||||||
import { CacheService } from '@/core/CacheService.js';
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
|
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
|
||||||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.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 { ApiLoggerService } from '../../ApiLoggerService.js';
|
||||||
import { ApiError } from '../../error.js';
|
import { ApiError } from '../../error.js';
|
||||||
|
|
||||||
|
@ -37,6 +42,11 @@ export const meta = {
|
||||||
|
|
||||||
kind: 'write:account',
|
kind: 'write:account',
|
||||||
|
|
||||||
|
limit: {
|
||||||
|
duration: ms('1hour'),
|
||||||
|
max: 10,
|
||||||
|
},
|
||||||
|
|
||||||
errors: {
|
errors: {
|
||||||
noSuchAvatar: {
|
noSuchAvatar: {
|
||||||
message: 'No such avatar file.',
|
message: 'No such avatar file.',
|
||||||
|
@ -173,6 +183,9 @@ export const paramDef = {
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||||
constructor(
|
constructor(
|
||||||
|
@Inject(DI.config)
|
||||||
|
private config: Config,
|
||||||
|
|
||||||
@Inject(DI.usersRepository)
|
@Inject(DI.usersRepository)
|
||||||
private usersRepository: UsersRepository,
|
private usersRepository: UsersRepository,
|
||||||
|
|
||||||
|
@ -195,9 +208,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
private hashtagService: HashtagService,
|
private hashtagService: HashtagService,
|
||||||
private roleService: RoleService,
|
private roleService: RoleService,
|
||||||
private cacheService: CacheService,
|
private cacheService: CacheService,
|
||||||
|
private httpRequestService: HttpRequestService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, _user, token) => {
|
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 isSecure = token == null;
|
||||||
|
|
||||||
const updates = {} as Partial<MiUser>;
|
const updates = {} as Partial<MiUser>;
|
||||||
|
@ -296,9 +310,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
|
|
||||||
if (ps.fields) {
|
if (ps.fields) {
|
||||||
profileUpdates.fields = 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 => {
|
.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<typeof meta, typeof paramDef> { // eslint-
|
||||||
if (Object.keys(updates).includes('alsoKnownAs')) {
|
if (Object.keys(updates).includes('alsoKnownAs')) {
|
||||||
this.cacheService.uriPersonCache.set(this.userEntityService.genLocalUserUri(user.id), { ...user, ...updates });
|
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<true, true>(user.id, user, {
|
const iObj = await this.userEntityService.pack<true, true>(user.id, user, {
|
||||||
detail: true,
|
detail: true,
|
||||||
|
@ -386,7 +404,34 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
// フォロワーにUpdateを配信
|
// フォロワーにUpdateを配信
|
||||||
this.accountUpdateService.publishToFollowers(user.id);
|
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;
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -102,6 +102,7 @@ describe('ユーザー', () => {
|
||||||
birthday: user.birthday,
|
birthday: user.birthday,
|
||||||
lang: user.lang,
|
lang: user.lang,
|
||||||
fields: user.fields,
|
fields: user.fields,
|
||||||
|
verifiedLinks: user.verifiedLinks,
|
||||||
followersCount: user.followersCount,
|
followersCount: user.followersCount,
|
||||||
followingCount: user.followingCount,
|
followingCount: user.followingCount,
|
||||||
notesCount: user.notesCount,
|
notesCount: user.notesCount,
|
||||||
|
@ -369,6 +370,7 @@ describe('ユーザー', () => {
|
||||||
assert.strictEqual(response.birthday, null);
|
assert.strictEqual(response.birthday, null);
|
||||||
assert.strictEqual(response.lang, null);
|
assert.strictEqual(response.lang, null);
|
||||||
assert.deepStrictEqual(response.fields, []);
|
assert.deepStrictEqual(response.fields, []);
|
||||||
|
assert.deepStrictEqual(response.verifiedLinks, []);
|
||||||
assert.strictEqual(response.followersCount, 0);
|
assert.strictEqual(response.followersCount, 0);
|
||||||
assert.strictEqual(response.followingCount, 0);
|
assert.strictEqual(response.followingCount, 0);
|
||||||
assert.strictEqual(response.notesCount, 0);
|
assert.strictEqual(response.notesCount, 0);
|
||||||
|
|
|
@ -89,6 +89,7 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi
|
||||||
value: 'https://misskey-hub.net',
|
value: 'https://misskey-hub.net',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
verifiedLinks: [],
|
||||||
followersCount: 1024,
|
followersCount: 1024,
|
||||||
followingCount: 16,
|
followingCount: 16,
|
||||||
hasPendingFollowRequestFromYou: false,
|
hasPendingFollowRequestFromYou: false,
|
||||||
|
|
|
@ -101,6 +101,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</dt>
|
</dt>
|
||||||
<dd class="value">
|
<dd class="value">
|
||||||
<Mfm :text="field.value" :author="user" :i="$i" :colored="false"/>
|
<Mfm :text="field.value" :author="user" :i="$i" :colored="false"/>
|
||||||
|
<i v-if="user.verifiedLinks.includes(field.value)" v-tooltip:dialog="i18n.ts.verifiedLink" class="ti ti-circle-check" :class="$style.verifiedLink"></i>
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
@ -671,7 +672,12 @@ onUnmounted(() => {
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.tl {
|
.tl {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
overflow: clip;
|
overflow: clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verifiedLink {
|
||||||
|
margin-left: 4px;
|
||||||
|
color: var(--success);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -2778,6 +2778,7 @@ type UserDetailed = UserLite & {
|
||||||
name: string;
|
name: string;
|
||||||
value: string;
|
value: string;
|
||||||
}[];
|
}[];
|
||||||
|
verifiedLinks: string[];
|
||||||
followersCount: number;
|
followersCount: number;
|
||||||
followingCount: number;
|
followingCount: number;
|
||||||
hasPendingFollowRequestFromYou: boolean;
|
hasPendingFollowRequestFromYou: boolean;
|
||||||
|
|
|
@ -38,6 +38,7 @@ export type UserDetailed = UserLite & {
|
||||||
description: string | null;
|
description: string | null;
|
||||||
ffVisibility: 'public' | 'followers' | 'private';
|
ffVisibility: 'public' | 'followers' | 'private';
|
||||||
fields: {name: string; value: string}[];
|
fields: {name: string; value: string}[];
|
||||||
|
verifiedLinks: string[];
|
||||||
followersCount: number;
|
followersCount: number;
|
||||||
followingCount: number;
|
followingCount: number;
|
||||||
hasPendingFollowRequestFromYou: boolean;
|
hasPendingFollowRequestFromYou: boolean;
|
||||||
|
|
Loading…
Reference in a new issue