diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 5bb194313d..555eea7812 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -29,6 +29,7 @@ import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-d import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js'; import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js'; import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js'; +import * as ep___admin_updateProxyAccount from './endpoints/admin/update-proxy-account.js'; import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js'; import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js'; import * as ep___admin_drive_cleanRemoteFiles from './endpoints/admin/drive/clean-remote-files.js'; @@ -417,6 +418,7 @@ const $admin_avatarDecorations_delete: Provider = { provide: 'ep:admin/avatar-de const $admin_avatarDecorations_list: Provider = { provide: 'ep:admin/avatar-decorations/list', useClass: ep___admin_avatarDecorations_list.default }; const $admin_avatarDecorations_update: Provider = { provide: 'ep:admin/avatar-decorations/update', useClass: ep___admin_avatarDecorations_update.default }; const $admin_deleteAllFilesOfAUser: Provider = { provide: 'ep:admin/delete-all-files-of-a-user', useClass: ep___admin_deleteAllFilesOfAUser.default }; +const $admin_updateProxyAccount: Provider = { provide: 'ep:admin/update-proxy-account', useClass: ep___admin_updateProxyAccount.default }; const $admin_unsetUserAvatar: Provider = { provide: 'ep:admin/unset-user-avatar', useClass: ep___admin_unsetUserAvatar.default }; const $admin_unsetUserBanner: Provider = { provide: 'ep:admin/unset-user-banner', useClass: ep___admin_unsetUserBanner.default }; const $admin_drive_cleanRemoteFiles: Provider = { provide: 'ep:admin/drive/clean-remote-files', useClass: ep___admin_drive_cleanRemoteFiles.default }; @@ -809,6 +811,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_avatarDecorations_list, $admin_avatarDecorations_update, $admin_deleteAllFilesOfAUser, + $admin_updateProxyAccount, $admin_unsetUserAvatar, $admin_unsetUserBanner, $admin_drive_cleanRemoteFiles, @@ -1195,6 +1198,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_avatarDecorations_list, $admin_avatarDecorations_update, $admin_deleteAllFilesOfAUser, + $admin_updateProxyAccount, $admin_unsetUserAvatar, $admin_unsetUserBanner, $admin_drive_cleanRemoteFiles, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 15809b2678..a5caefc68d 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -34,6 +34,7 @@ import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-d import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js'; import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js'; import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js'; +import * as ep___admin_updateProxyAccount from './endpoints/admin/update-proxy-account.js'; import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js'; import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js'; import * as ep___admin_drive_cleanRemoteFiles from './endpoints/admin/drive/clean-remote-files.js'; @@ -421,6 +422,7 @@ const eps = [ ['admin/avatar-decorations/list', ep___admin_avatarDecorations_list], ['admin/avatar-decorations/update', ep___admin_avatarDecorations_update], ['admin/delete-all-files-of-a-user', ep___admin_deleteAllFilesOfAUser], + ['admin/update-proxy-account', ep___admin_updateProxyAccount], ['admin/unset-user-avatar', ep___admin_unsetUserAvatar], ['admin/unset-user-banner', ep___admin_unsetUserBanner], ['admin/drive/clean-remote-files', ep___admin_drive_cleanRemoteFiles], diff --git a/packages/backend/src/server/api/endpoints/admin/update-proxy-account.ts b/packages/backend/src/server/api/endpoints/admin/update-proxy-account.ts new file mode 100644 index 0000000000..ee723cabc2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/update-proxy-account.ts @@ -0,0 +1,350 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import * as mfm from 'mfm-js'; +import { JSDOM } from 'jsdom'; +import type { Config } from '@/config.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; +import { extractHashtags } from '@/misc/extract-hashtags.js'; +import type { + UsersRepository, + UserProfilesRepository, + MiUser, + MiUserProfile, + DriveFilesRepository, + PagesRepository, + MiMeta, +} from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; +import { + descriptionSchema, + MiLocalUser, + nameSchema, +} from '@/models/User.js'; +import { ApiError } from '@/server/api/error.js'; +import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { AccountUpdateService } from '@/core/AccountUpdateService.js'; +import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; +import { ApiLoggerService } from '@/server/api/ApiLoggerService.js'; +import { HashtagService } from '@/core/HashtagService.js'; +import { CacheService } from '@/core/CacheService.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { safeForSql } from '@/misc/safe-for-sql.js'; +import { ProxyAccountService } from '@/core/ProxyAccountService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:update-proxy-account', + + errors: { + noSuchAvatar: { + message: 'No such avatar file.', + code: 'NO_SUCH_AVATAR', + id: '539f3a45-f215-4f81-a9a8-31293640207f', + }, + + noSuchBanner: { + message: 'No such banner file.', + code: 'NO_SUCH_BANNER', + id: '0d8f5629-f210-41c2-9433-735831a58595', + }, + + avatarNotAnImage: { + message: 'The file specified as an avatar is not an image.', + code: 'AVATAR_NOT_AN_IMAGE', + id: 'f419f9f8-2f4d-46b1-9fb4-49d3a2fd7191', + }, + + bannerNotAnImage: { + message: 'The file specified as a banner is not an image.', + code: 'BANNER_NOT_AN_IMAGE', + id: '75aedb19-2afd-4e6d-87fc-67941256fa60', + }, + + noSuchPage: { + message: 'No such page.', + code: 'NO_SUCH_PAGE', + id: '8e01b590-7eb9-431b-a239-860e086c408e', + }, + + invalidRegexp: { + message: 'Invalid Regular Expression.', + code: 'INVALID_REGEXP', + id: '0d786918-10df-41cd-8f33-8dec7d9a89a5', + }, + + tooManyMutedWords: { + message: 'Too many muted words.', + code: 'TOO_MANY_MUTED_WORDS', + id: '010665b1-a211-42d2-bc64-8f6609d79785', + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5', + }, + + uriNull: { + message: 'User ActivityPup URI is null.', + code: 'URI_NULL', + id: 'bf326f31-d430-4f97-9933-5d61e4d48a23', + }, + + forbiddenToSetYourself: { + message: 'You can\'t set yourself as your own alias.', + code: 'FORBIDDEN_TO_SET_YOURSELF', + id: '25c90186-4ab0-49c8-9bba-a1fa6c202ba4', + }, + + restrictedByRole: { + message: 'This feature is restricted by your role.', + code: 'RESTRICTED_BY_ROLE', + id: '8feff0ba-5ab5-585b-31f4-4df816663fad', + }, + + nameContainsProhibitedWords: { + message: 'Your new name contains prohibited words.', + code: 'YOUR_NAME_CONTAINS_PROHIBITED_WORDS', + id: '0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191', + httpStatusCode: 422, + }, + + accessDenied: { + message: 'Only administrators can edit members of the role.', + code: 'ACCESS_DENIED', + id: '25b5bc31-dc79-4ebd-9bd2-c84978fd052c', + }, + }, + + res: { + type: 'object', + nullable: false, optional: false, + ref: 'UserDetailed', + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + name: { ...nameSchema, nullable: true }, + description: { ...descriptionSchema, nullable: true }, + avatarId: { type: 'string', format: 'misskey:id', nullable: true }, + bannerId: { type: 'string', format: 'misskey:id', nullable: true }, + }, +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.meta) + private instanceMeta: MiMeta, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + @Inject(DI.pagesRepository) + private pagesRepository: PagesRepository, + + private roleService: RoleService, + private userEntityService: UserEntityService, + private driveFileEntityService: DriveFileEntityService, + private globalEventService: GlobalEventService, + private accountUpdateService: AccountUpdateService, + private remoteUserResolveService: RemoteUserResolveService, + private apiLoggerService: ApiLoggerService, + private hashtagService: HashtagService, + private cacheService: CacheService, + private httpRequestService: HttpRequestService, + private avatarDecorationService: AvatarDecorationService, + private utilityService: UtilityService, + private moderationLogService: ModerationLogService, + private proxyAccountService: ProxyAccountService, + ) { + super(meta, paramDef, async (ps, me) => { + const _me = await this.usersRepository.findOneByOrFail({ id: me.id }); + if (!await this.roleService.isModerator(_me)) { + throw new ApiError(meta.errors.accessDenied); + } + + const proxy = await this.proxyAccountService.fetch(); + if (!proxy) throw new ApiError(meta.errors.noSuchUser); + + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: proxy.id }); + + const updates = {} as Partial; + const profileUpdates = {} as Partial; + + if (ps.name !== undefined) { + if (ps.name === null) { + updates.name = null; + } else { + const trimmedName = ps.name.trim(); + updates.name = trimmedName === '' ? null : trimmedName; + } + } + if (ps.description !== undefined) profileUpdates.description = ps.description; + + if (ps.avatarId) { + const avatar = await this.driveFilesRepository.findOneBy({ id: ps.avatarId }); + + if (avatar == null) throw new ApiError(meta.errors.noSuchAvatar); + if (!avatar.type.startsWith('image/')) throw new ApiError(meta.errors.avatarNotAnImage); + + updates.avatarId = avatar.id; + updates.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar'); + updates.avatarBlurhash = avatar.blurhash; + } else if (ps.avatarId === null) { + updates.avatarId = null; + updates.avatarUrl = null; + updates.avatarBlurhash = null; + } + + if (ps.bannerId) { + const banner = await this.driveFilesRepository.findOneBy({ id: ps.bannerId }); + + if (banner == null) throw new ApiError(meta.errors.noSuchBanner); + if (!banner.type.startsWith('image/')) throw new ApiError(meta.errors.bannerNotAnImage); + + updates.bannerId = banner.id; + updates.bannerUrl = this.driveFileEntityService.getPublicUrl(banner); + updates.bannerBlurhash = banner.blurhash; + } else if (ps.bannerId === null) { + updates.bannerId = null; + updates.bannerUrl = null; + updates.bannerBlurhash = null; + } + + //#region emojis/tags + let emojis = [] as string[]; + let tags = [] as string[]; + + const newName = updates.name === undefined ? proxy.name : updates.name; + const newDescription = profileUpdates.description === undefined ? profile.description : profileUpdates.description; + + if (newName != null) { + let hasProhibitedWords = false; + if (!await this.roleService.isModerator(proxy)) { + hasProhibitedWords = this.utilityService.isKeyWordIncluded(newName, this.instanceMeta.prohibitedWordsForNameOfUser); + } + if (hasProhibitedWords) { + throw new ApiError(meta.errors.nameContainsProhibitedWords); + } + + const tokens = mfm.parseSimple(newName); + emojis = emojis.concat(extractCustomEmojisFromMfm(tokens)); + } + + if (newDescription != null) { + const tokens = mfm.parse(newDescription); + emojis = emojis.concat(extractCustomEmojisFromMfm(tokens)); + tags = extractHashtags(tokens).map(tag => normalizeForSearch(tag)).splice(0, 32); + } + + for (const field of profile.fields) { + const nameTokens = mfm.parseSimple(field.name); + const valueTokens = mfm.parseSimple(field.value); + emojis = emojis.concat([ + ...extractCustomEmojisFromMfm(nameTokens), + ...extractCustomEmojisFromMfm(valueTokens), + ]); + } + + if (profile.followedMessage != null) { + const tokens = mfm.parse(profile.followedMessage); + emojis = emojis.concat(extractCustomEmojisFromMfm(tokens)); + } + + updates.emojis = emojis; + updates.tags = tags; + + // ハッシュタグ更新 + this.hashtagService.updateUsertags(proxy, tags); + //#endregion + + if (Object.keys(updates).length > 0) { + await this.usersRepository.update(proxy.id, updates); + this.globalEventService.publishInternalEvent('localUserUpdated', { id: proxy.id }); + } + + await this.userProfilesRepository.update(proxy.id, { + ...profileUpdates, + verifiedLinks: [], + }); + + const updated = await this.userEntityService.pack(proxy.id, proxy, { + schema: 'MeDetailed', + }); + const updatedProfile = await this.userProfilesRepository.findOneByOrFail({ userId: proxy.id }); + + this.cacheService.userProfileCache.set(proxy.id, updatedProfile); + + this.moderationLogService.log(me, 'updateUser', { + userId: proxy.id, + userUsername: proxy.username, + userHost: proxy.host, + }); + + const urls = updatedProfile.fields.filter(x => x.value.startsWith('https://')); + for (const url of urls) { + this.verifyLink(url.value, proxy); + } + + return updated; + }); + } + private async verifyLink(url: string, user: MiLocalUser) { + if (!safeForSql(url)) return; + + try { + const html = await this.httpRequestService.getHtml(url); + + const { window } = new JSDOM(html); + const doc = window.document; + + const myLink = `${this.config.url}/@${user.username}`; + + const aEls = Array.from(doc.getElementsByTagName('a')); + const linkEls = Array.from(doc.getElementsByTagName('link')); + + const includesMyLink = aEls.some(a => a.href === myLink); + const includesRelMeLinks = [...aEls, ...linkEls].some(link => link.rel === 'me' && link.href === myLink); + + if (includesMyLink || includesRelMeLinks) { + await this.userProfilesRepository.createQueryBuilder('profile').update() + .where('userId = :userId', { userId: user.id }) + .set({ + verifiedLinks: () => `array_append("verifiedLinks", '${url}')`, // ここでSQLインジェクションされそうなのでとりあえず safeForSql で弾いている + }) + .execute(); + } + + window.close(); + } catch (err) { + // なにもしない + } + } +} diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index df3cfee171..46f1985ecb 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -311,6 +311,11 @@ export type ModerationLogPayloads = { avatarDecorationId: string; avatarDecoration: any; }; + updateUser: { + userId: string; + userUsername: string; + userHost: string | null; + }; unsetUserAvatar: { userId: string; userUsername: string; diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 01a3dbbb30..676e58a63a 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -391,6 +391,12 @@ type AdminUpdateAbuseUserReportRequest = operations['admin___update-abuse-user-r // @public (undocumented) type AdminUpdateMetaRequest = operations['admin___update-meta']['requestBody']['content']['application/json']; +// @public (undocumented) +type AdminUpdateProxyAccountRequest = operations['admin___update-proxy-account']['requestBody']['content']['application/json']; + +// @public (undocumented) +type AdminUpdateProxyAccountResponse = operations['admin___update-proxy-account']['responses']['200']['content']['application/json']; + // @public (undocumented) type AdminUpdateUserNoteRequest = operations['admin___update-user-note']['requestBody']['content']['application/json']; @@ -1262,6 +1268,8 @@ declare namespace entities { AdminAvatarDecorationsListResponse, AdminAvatarDecorationsUpdateRequest, AdminDeleteAllFilesOfAUserRequest, + AdminUpdateProxyAccountRequest, + AdminUpdateProxyAccountResponse, AdminUnsetUserAvatarRequest, AdminUnsetUserBannerRequest, AdminDriveFilesRequest, diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index 1837f3db4f..2bab5e11e7 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -261,6 +261,17 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:update-proxy-account* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index cb1f4dbe96..5a0eed64e6 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -37,6 +37,8 @@ import type { AdminAvatarDecorationsListResponse, AdminAvatarDecorationsUpdateRequest, AdminDeleteAllFilesOfAUserRequest, + AdminUpdateProxyAccountRequest, + AdminUpdateProxyAccountResponse, AdminUnsetUserAvatarRequest, AdminUnsetUserBannerRequest, AdminDriveFilesRequest, @@ -605,6 +607,7 @@ export type Endpoints = { 'admin/avatar-decorations/list': { req: AdminAvatarDecorationsListRequest; res: AdminAvatarDecorationsListResponse }; 'admin/avatar-decorations/update': { req: AdminAvatarDecorationsUpdateRequest; res: EmptyResponse }; 'admin/delete-all-files-of-a-user': { req: AdminDeleteAllFilesOfAUserRequest; res: EmptyResponse }; + 'admin/update-proxy-account': { req: AdminUpdateProxyAccountRequest; res: AdminUpdateProxyAccountResponse }; 'admin/unset-user-avatar': { req: AdminUnsetUserAvatarRequest; res: EmptyResponse }; 'admin/unset-user-banner': { req: AdminUnsetUserBannerRequest; res: EmptyResponse }; 'admin/drive/clean-remote-files': { req: EmptyRequest; res: EmptyResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index a8f474c25c..0efe2e08d3 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -40,6 +40,8 @@ export type AdminAvatarDecorationsListRequest = operations['admin___avatar-decor export type AdminAvatarDecorationsListResponse = operations['admin___avatar-decorations___list']['responses']['200']['content']['application/json']; export type AdminAvatarDecorationsUpdateRequest = operations['admin___avatar-decorations___update']['requestBody']['content']['application/json']; export type AdminDeleteAllFilesOfAUserRequest = operations['admin___delete-all-files-of-a-user']['requestBody']['content']['application/json']; +export type AdminUpdateProxyAccountRequest = operations['admin___update-proxy-account']['requestBody']['content']['application/json']; +export type AdminUpdateProxyAccountResponse = operations['admin___update-proxy-account']['responses']['200']['content']['application/json']; export type AdminUnsetUserAvatarRequest = operations['admin___unset-user-avatar']['requestBody']['content']['application/json']; export type AdminUnsetUserBannerRequest = operations['admin___unset-user-banner']['requestBody']['content']['application/json']; export type AdminDriveFilesRequest = operations['admin___drive___files']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 280abba727..50f424ebff 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -224,6 +224,15 @@ export type paths = { */ post: operations['admin___delete-all-files-of-a-user']; }; + '/admin/update-proxy-account': { + /** + * admin/update-proxy-account + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:update-proxy-account* + */ + post: operations['admin___update-proxy-account']; + }; '/admin/unset-user-avatar': { /** * admin/unset-user-avatar @@ -6616,6 +6625,64 @@ export type operations = { }; }; }; + /** + * admin/update-proxy-account + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:update-proxy-account* + */ + 'admin___update-proxy-account': { + requestBody: { + content: { + 'application/json': { + name?: string | null; + description?: string | null; + /** Format: misskey:id */ + avatarId?: string | null; + /** Format: misskey:id */ + bannerId?: string | null; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': components['schemas']['UserDetailed']; + }; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; /** * admin/unset-user-avatar * @description No description provided.