Merge upstream

This commit is contained in:
オスカー、 2024-09-18 00:11:23 +09:00
commit 4b6cbdab39
Signed by: SWREI
GPG Key ID: 139D6573F92DA9F7
73 changed files with 1199 additions and 356 deletions

View File

@ -72,7 +72,7 @@ dbReplications: false
#───┘ Redis configuration └─────────────────────────────────────
redis:
host: keydb
host: dragonfly
port: 6379
#family: 0 # 0=Both, 4=IPv4, 6=IPv6
#pass: example-pass
@ -80,7 +80,7 @@ redis:
#db: 1
#redisForPubsub:
# host: keydb
# host: dragonfly
# port: 6379
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
# #pass: example-pass
@ -88,7 +88,7 @@ redis:
# #db: 1
#redisForJobQueue:
# host: keydb
# host: dragonfly
# port: 6379
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
# #pass: example-pass
@ -96,7 +96,7 @@ redis:
# #db: 1
#redisForTimelines:
# host: keydb
# host: dragonfly
# port: 6379
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
# #pass: example-pass

View File

@ -72,7 +72,7 @@ dbReplications: false
#───┘ Redis configuration └─────────────────────────────────────
redis:
host: keydb
host: dragonfly
port: 6379
#family: 0 # 0=Both, 4=IPv4, 6=IPv6
#pass: example-pass
@ -80,7 +80,7 @@ redis:
#db: 1
#redisForPubsub:
# host: keydb
# host: dragonfly
# port: 6379
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
# #pass: example-pass
@ -88,7 +88,7 @@ redis:
# #db: 1
#redisForJobQueue:
# host: keydb
# host: dragonfly
# port: 6379
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
# #pass: example-pass
@ -96,7 +96,7 @@ redis:
# #db: 1
#redisForTimelines:
# host: keydb
# host: dragonfly
# port: 6379
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
# #pass: example-pass

View File

@ -15,17 +15,25 @@ services:
- internal_network
- external_network
keydb:
dragonfly:
restart: unless-stopped
image: eqalpha/keydb:latest
image: docker.dragonflydb.io/dragonflydb/dragonfly
ulimits:
memlock: -1
environment:
DFLY_snapshot_cron: '* * * * *'
DFLY_version_check: false
DFLY_tcp_backlog: 2048
DFLY_default_lua_flags: allow-undeclared-keys
DFLY_pipeline_squash: 0
DFLY_multi_exec_squash: false
DFLY_conn_io_threads: 4
DFLY_epoll_file_threads: 4
DFLY_proactor_threads: 4
networks:
- internal_network
volumes:
- keydb-data:/data
healthcheck:
test: "keydb-cli ping"
interval: 5s
retries: 20
- dragonfly-data:/data
db:
restart: unless-stopped
@ -45,7 +53,7 @@ services:
volumes:
postgres-data:
keydb-data:
dragonfly-data:
networks:
internal_network:

View File

@ -11,7 +11,6 @@ docker-compose.yml
node_modules/
packages/*/node_modules
redis/
keydb/
files/
fluent-emojis/
.pnp.*

View File

@ -30,8 +30,18 @@ jobs:
env:
POSTGRES_DB: test-misskey
POSTGRES_HOST_AUTH_METHOD: trust
keydb:
image: eqalpha/keydb:latest
dragonfly:
image: docker.dragonflydb.io/dragonflydb/dragonfly
options: --ulimit "memlock=-1"
env:
DFLY_version_check: false
DFLY_tcp_backlog: 2048
DFLY_default_lua_flags: allow-undeclared-keys
DFLY_pipeline_squash: 0
DFLY_multi_exec_squash: false
DFLY_conn_io_threads: 4
DFLY_epoll_file_threads: 4
DFLY_proactor_threads: 4
ports:
- 56312:6379
@ -82,8 +92,18 @@ jobs:
env:
POSTGRES_DB: test-misskey
POSTGRES_HOST_AUTH_METHOD: trust
keydb:
image: eqalpha/keydb:latest
dragonfly:
image: docker.dragonflydb.io/dragonflydb/dragonfly
options: --ulimit "memlock=-1"
env:
DFLY_version_check: false
DFLY_tcp_backlog: 2048
DFLY_default_lua_flags: allow-undeclared-keys
DFLY_pipeline_squash: 0
DFLY_multi_exec_squash: false
DFLY_conn_io_threads: 4
DFLY_epoll_file_threads: 4
DFLY_proactor_threads: 4
ports:
- 56312:6379

1
.gitignore vendored
View File

@ -53,7 +53,6 @@ run.bat
api-docs.json
*.log
/redis
/keydb
*.code-workspace
.DS_Store
/files

View File

@ -37,8 +37,25 @@ spec:
value: "misskey"
ports:
- containerPort: 5432
- name: keydb
image: eqalpha/keydb:latest
- name: dragonfly
image: docker.dragonflydb.io/dragonflydb/dragonfly
env:
- name: DFLY_version_check
value: false
- name: DFLY_tcp_backlog
value: 2048
- name: DFLY_default_lua_flags
value: allow-undeclared-keys
- name: DFLY_pipeline_squash
value: 0
- name: DFLY_multi_exec_squash
value: false
- name: DFLY_conn_io_threads
value: 4
- name: DFLY_epoll_file_threads
value: 4
- name: DFLY_proactor_threads
value: 4
ports:
- containerPort: 6379
volumes:

View File

@ -3,17 +3,25 @@ version: "3"
# このconfigは、 dockerでMisskey本体を起動せず、 redisとpostgresql などだけを起動します
services:
keydb:
dragonfly:
restart: always
image: eqalpha/keydb:latest
image: docker.dragonflydb.io/dragonflydb/dragonfly
ulimits:
memlock: -1
environment:
DFLY_snapshot_cron: '* * * * *'
DFLY_version_check: false
DFLY_tcp_backlog: 2048
DFLY_default_lua_flags: allow-undeclared-keys
DFLY_pipeline_squash: 0
DFLY_multi_exec_squash: false
DFLY_conn_io_threads: 4
DFLY_epoll_file_threads: 4
DFLY_proactor_threads: 4
ports:
- "6379:6379"
volumes:
- ./keydb:/data
healthcheck:
test: "keydb-cli ping"
interval: 5s
retries: 20
- ./redis:/data
db:
restart: always

View File

@ -6,13 +6,13 @@ services:
restart: always
links:
- db
- keydb
- dragonfly
# - mcaptcha
# - meilisearch
depends_on:
db:
condition: service_healthy
keydb:
dragonfly:
condition: service_healthy
ports:
- "3000:3000"
@ -23,17 +23,25 @@ services:
- ./files:/misskey/files
- ./.config:/misskey/.config:ro
keydb:
dragonfly:
restart: always
image: eqalpha/keydb:latest
image: docker.dragonflydb.io/dragonflydb/dragonfly
ulimits:
memlock: -1
environment:
DFLY_snapshot_cron: '* * * * *'
DFLY_version_check: false
DFLY_tcp_backlog: 2048
DFLY_default_lua_flags: allow-undeclared-keys
DFLY_pipeline_squash: 0
DFLY_multi_exec_squash: false
DFLY_conn_io_threads: 4
DFLY_epoll_file_threads: 4
DFLY_proactor_threads: 4
networks:
- internal_network
volumes:
- ./keydb:/data
healthcheck:
test: "keydb-cli ping"
interval: 5s
retries: 20
- ./redis:/data
db:
restart: always

View File

@ -442,6 +442,10 @@ moderation: "Moderation"
moderationNote: "Moderation note"
addModerationNote: "Add moderation note"
moderationLogs: "Moderation logs"
userAccountMoveLogs: "Account migration logs"
userAccountMoveLogsTitle: "{from} migrated the account to {to}"
movedToId: "ID of the account migrated to"
moveFromId: "ID of the account migrated from"
nUsersMentioned: "Mentioned by {n} users"
securityKeyAndPasskey: "Security- and passkeys"
securityKey: "Security key"
@ -910,6 +914,7 @@ followingVisibility: "Visibility of follows"
followersVisibility: "Visibility of followers"
continueThread: "View thread continuation"
deleteAccountConfirm: "This will irreversibly delete your account. Proceed?"
deleteAccountConfirmAndWarn: "This will irreversibly delete your account.\nPlease note that re-logging in after a deletion request will interrupt the deletion of your account.\nProceed?"
incorrectPassword: "Incorrect password."
voteConfirm: "Confirm your vote for \"{choice}\"?"
hide: "Hide"
@ -1089,6 +1094,7 @@ audioFiles: "Audio"
dataSaver: "Data Saver"
accountMigration: "Account Migration"
accountMoved: "This user has moved to a new account:"
accountMovedFrom: "This user has been migrated from the following account:"
accountMovedShort: "This account has been migrated."
operationForbidden: "Operation forbidden"
forceShowAds: "Always show ads"
@ -1837,6 +1843,7 @@ _accountDelete:
sendEmail: "Once account deletion has been completed, an email will be sent to the email address registered to this account."
requestAccountDelete: "Request to delete my account"
started: "Deletion has been started."
dontLogin: "We recommend that you do not log in to your account, as this will interrupt the deletion process."
inProgress: "Your account is currently being deleted"
youCantUseThisTime: "You can't request account deletion for now."
youAreRootAndCantUseThisTime: "You can't request account deletion 'cause you are root."

31
locales/index.d.ts vendored
View File

@ -1796,6 +1796,22 @@ export interface Locale extends ILocale {
*
*/
"moderationLogs": string;
/**
* 使
*/
"userAccountMoveLogs": string;
/**
* {from} {to}
*/
"userAccountMoveLogsTitle": ParameterizedString<"from" | "to">;
/**
* ID
*/
"movedToId": string;
/**
* ID
*/
"moveFromId": string;
/**
* {n}稿
*/
@ -3668,6 +3684,13 @@ export interface Locale extends ILocale {
*
*/
"truncateAccountConfirm": string;
/**
*
*
*
*
*/
"deleteAccountConfirmAndWarn": string;
/**
*
*/
@ -4396,6 +4419,10 @@ export interface Locale extends ILocale {
*
*/
"accountMoved": string;
/**
*
*/
"accountMovedFrom": string;
/**
*
*/
@ -7331,6 +7358,10 @@ export interface Locale extends ILocale {
*
*/
"youAreRootAndCantUseThisTime": string;
/**
*
*/
"dontLogin": string;
};
"_accountTruncate": {
/**

View File

@ -445,6 +445,10 @@ moderation: "モデレーション"
moderationNote: "モデレーションノート"
addModerationNote: "モデレーションノートを追加する"
moderationLogs: "モデログ"
userAccountMoveLogs: "アカウント移行使用ログ"
userAccountMoveLogsTitle: "{from} が {to} にアカウントを移行しました"
movedToId: "移行先のアカウントのID"
moveFromId: "移行元のアカウントのID"
nUsersMentioned: "{n}人が投稿"
securityKeyAndPasskey: "セキュリティキー・パスキー"
securityKey: "セキュリティキー"
@ -913,6 +917,7 @@ followersVisibility: "フォロワーの公開範囲"
continueThread: "さらにスレッドを見る"
deleteAccountConfirm: "アカウントが削除されます。よろしいですか?"
truncateAccountConfirm: "ダイレクトとピン留めされたノート、関連ドライブのファイルを除くすべてのノートとドライブのファイルが削除されます。続行しますか?"
deleteAccountConfirmAndWarn: "アカウントが削除されます。\n削除リクエスト後に再ログインすると\nアカウントの削除が中断されてしまいますのでご注意ください。\nよろしいですか"
incorrectPassword: "パスワードが間違っています。"
voteConfirm: "「{choice}」に投票しますか?"
hide: "隠す"
@ -1095,6 +1100,7 @@ audioFiles: "音声"
dataSaver: "データセーバー"
accountMigration: "アカウントの移行"
accountMoved: "このユーザーは新しいアカウントに移行しました:"
accountMovedFrom: "このユーザーは次のアカウントから移行されました:"
accountMovedShort: "このアカウントは移行されています"
operationForbidden: "この操作はできません"
forceShowAds: "常に広告を表示する"
@ -1898,6 +1904,7 @@ _accountDelete:
inProgress: "削除が進行中"
youCantUseThisTime: "現在、アカウントの削除はできません。"
youAreRootAndCantUseThisTime: "あなたは最高管理者であるため、アカウントを削除することはできません。"
dontLogin: "削除が中断されてしまいますので、アカウントにログインしないことをおすすめします。"
_accountTruncate:
accountDelete: "アカウントの整理"

View File

@ -441,6 +441,10 @@ moderation: "중재"
moderationNote: "중재 기록"
addModerationNote: "중재 기록 추가하기"
moderationLogs: "모더레이션 로그"
userAccountMoveLogs: "계정 이사 사용 로그"
userAccountMoveLogsTitle: "{from} 가 {to} 로 계정을 이사했습니다"
movedToId: "이사 후 계정의 ID"
moveFromId: "이사 전 계정의 ID"
nUsersMentioned: "{n}명이 언급함"
securityKeyAndPasskey: "보안 키 또는 패스 키"
securityKey: "보안 키"
@ -910,6 +914,7 @@ followersVisibility: "내 팔로워를 볼 수 있는 사람"
continueThread: "글타래 더 보기"
deleteAccountConfirm: "계정이 삭제되고 되돌릴 수 없게 됩니다. 계속하시겠습니까? "
truncateAccountConfirm: "다이렉트 및 프로필 상단에 고정된 노트를 제외한 모든 노트가 (드라이브 정리 옵션을 켠 경우 모든 파일도) 삭제되고 이는 복구할 수 없습니다. 그래도 계속하시겠습니까?"
deleteAccountConfirmAndWarn: "계정이 삭제됩니다.\n삭제 요청 후 다시 로그인하면 계정 삭제가 중단되어 버립니다.\n계속하시겠습니까?"
incorrectPassword: "비밀번호가 올바르지 않습니다."
voteConfirm: "\"{choice}\"에 투표하시겠습니까?"
hide: "숨기기"
@ -1092,6 +1097,7 @@ audioFiles: "소리"
dataSaver: "데이터 절약 모드"
accountMigration: "계정 이동"
accountMoved: "이 유저는 다음 계정으로 이사했습니다:"
accountMovedFrom: "이 유저는 다음 계정에서 이사했습니다:"
accountMovedShort: "이사한 계정입니다"
operationForbidden: "사용할 수 없습니다"
forceShowAds: "광고를 항상 표시"
@ -1270,7 +1276,7 @@ youAreHidingSensitiveInformation: "'프라이빗 모드'에 의해 숨겨졌습
temporarilySeeThis: "무시하고 표시하기"
sensitiveDoubleClickRequired: "민감한 내용의 미디어를 더블 클릭해서 표시"
prohibitSkippingInitialTutorial: "튜토리얼을 건너뛸 수 없도록 하기"
prohibitSkippingInitialTutorialDescription: "신규 가입한 사용자에게 표시되는 튜토리얼을 건너뛸 수 없도록 합니다. 튜토리얼을 완료하지 않거나 튜토리얼 페이지를 우회하는 경우에도 강제로 리디렉션됩니다."
prohibitSkippingInitialTutorialDescription: "신규 가입한 유저에게 표시되는 튜토리얼을 건너뛸 수 없도록 합니다. 튜토리얼을 완료하지 않거나 튜토리얼 페이지를 우회하는 경우에도 강제로 리디렉션됩니다."
onboarding: "온보딩"
refreshMetadata: "서버 정보를 갱신하기"
removeAllFollowings: "모든 팔로우 관계를 제거하기"
@ -1424,7 +1430,7 @@ _initialTutorial:
description: "푸시 알림을 활성화하면 {name}의 알림을 나의 기기에서 받아볼 수 있게 됩니다."
_privacySettings:
title: "프라이버시 설정"
description: "대부분의 초기 사용자가 설정하는 것을 권장하는 옵션들입니다. 입맛에 맞게 변경해보세요."
description: "대부분의 초기 유저가 설정하는 것을 권장하는 옵션들입니다. 입맛에 맞게 변경해보세요."
theseSettingsCanEditLater: "이 설정들은 나중에도 변경할 수 있습니다."
youCanEditMoreSettingsInSettingsPageLater: "이외의 옵션은 '설정' 페이지에 있으니, 나중에 꼭 확인해보시기 바랍니다."
_profileSettings:
@ -1434,7 +1440,7 @@ _initialTutorial:
youCanEditMoreSettingsInSettingsPageLater: "이외의 옵션은 '설정' 페이지에 있으니, 나중에 꼭 확인해보시기 바랍니다."
mustBeSetBotOwner: "Bot 계정은 반드시 관리자를 기재해야 합니다. 아래에서 관리자 계정을 선택하세요."
_done:
title: "튜토리얼이 완료! 🎉"
title: "튜토리얼이 끝났습니다! 🎉"
description: "여기에서 소개한 기능은 극히 일부에 지나지 않습니다. Misskey의 사용 방법을 더 자세히 알아보려면 {link}를 확인해 주세요!"
haveFun: "{name}에서 즐거운 시간 보내시기 바랍니다!"
youCanReferTutorialBy: "이 튜토리얼은 [더 보기! -> 정보 -> 튜토리얼 보기]에서 다시 볼 수 있습니다."
@ -1450,7 +1456,7 @@ _initialTutorial:
backToOriginalPathDescription: "튜토리얼에 진입하기 전에 보고 있던 페이지로 이동합니다."
profile: "프로필 설정"
profileDescription: "더 많은 프로필 관련 설정 (배너, 추가 정보, 고양이...) 을 진행합니다."
exploreDescription: "지금 여기에서 인기 있는 노트 또는 사용자를 더 탐색해봅니다."
exploreDescription: "지금 여기에서 인기 있는 노트 또는 유저를 더 탐색해봅니다."
goToTimeline: "타임라인으로"
goToTimelineDescription: "설정을 더 진행하지 않고, 홈 타임라인으로 돌아갑니다."
_timelineDescription:
@ -1867,6 +1873,7 @@ _accountDelete:
inProgress: "삭제 진행 중"
youCantUseThisTime: "지금은 계정 삭제를 진행할 수 없습니다."
youAreRootAndCantUseThisTime: "당신은 최고 관리자이므로, 지금은 계정 삭제를 진행할 수 없습니다."
dontLogin: "삭제가 중단되어 버릴 수 있으므로, 계정에 로그인하지 않는 것을 권장합니다."
_accountTruncate:
accountTruncate: "계정 청소"
purgeDriveFiles: "드라이브의 파일도 정리하기"
@ -2175,7 +2182,7 @@ _permissions:
"write:admin:suspend-user": "유저 정지하기"
"write:admin:unset-user-avatar": "유저 아바타 삭제하기"
"write:admin:unset-user-banner": "유저 배너 삭제하기"
"write:admin:unset-user-mutual-link": "사용자의 서로링크 삭제하기"
"write:admin:unset-user-mutual-link": "유저의 서로링크 삭제하기"
"write:admin:unsuspend-user": "유저 정지 해제하기"
"write:admin:meta": "인스턴스 메타데이터 수정하기"
"write:admin:user-note": "중재 기록 수정하기"

View File

@ -0,0 +1,19 @@
export class Useraccountmovelogs1724749627479 {
name = 'Useraccountmovelogs1724749627479'
async up(queryRunner) {
await queryRunner.query(`CREATE TABLE "user_account_move_log" ("id" character varying(32) NOT NULL, "movedToId" character varying(32) NOT NULL, "movedFromId" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_8ffd4ae965a5e3a0fbf4b084212" PRIMARY KEY ("id")); COMMENT ON COLUMN "user_account_move_log"."createdAt" IS 'The created date of the UserIp.'`);
await queryRunner.query(`CREATE INDEX "IDX_d5ee7d4d1b5e7a69d8855ab069" ON "user_account_move_log" ("movedToId") `);
await queryRunner.query(`CREATE INDEX "IDX_82930731d6390e7bb429a1938f" ON "user_account_move_log" ("movedFromId") `);
await queryRunner.query(`ALTER TABLE "user_account_move_log" ADD CONSTRAINT "FK_d5ee7d4d1b5e7a69d8855ab0696" FOREIGN KEY ("movedToId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "user_account_move_log" ADD CONSTRAINT "FK_82930731d6390e7bb429a1938f8" FOREIGN KEY ("movedFromId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_account_move_log" DROP CONSTRAINT "FK_82930731d6390e7bb429a1938f8"`);
await queryRunner.query(`ALTER TABLE "user_account_move_log" DROP CONSTRAINT "FK_d5ee7d4d1b5e7a69d8855ab0696"`);
await queryRunner.query(`DROP INDEX "public"."IDX_82930731d6390e7bb429a1938f"`);
await queryRunner.query(`DROP INDEX "public"."IDX_d5ee7d4d1b5e7a69d8855ab069"`);
await queryRunner.query(`DROP TABLE "user_account_move_log"`);
}
}

View File

@ -10,7 +10,7 @@ import { bindThis } from '@/decorators.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MutingsRepository, UserListMembershipsRepository, UsersRepository } from '@/models/_.js';
import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MutingsRepository, UserListMembershipsRepository, UserAccountMoveLogRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type { RelationshipJobData, ThinUser } from '@/queue/types.js';
import { IdService } from '@/core/IdService.js';
@ -48,6 +48,15 @@ export class AccountMoveService {
@Inject(DI.instancesRepository)
private instancesRepository: InstancesRepository,
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
@Inject(DI.userAccountMoveLogRepository)
private userAccountMoveLogRepository: UserAccountMoveLogRepository,
@Inject(DI.config)
private config: Config,
private userEntityService: UserEntityService,
private idService: IdService,
private apPersonService: ApPersonService,
@ -119,6 +128,8 @@ export class AccountMoveService {
this.copyBlocking(src, dst),
this.copyMutings(src, dst),
this.updateLists(src, dst),
this.mergeModerationNote(src, dst),
this.insertAccountMoveLog(src, dst),
]);
} catch {
/* skip if any error happens */
@ -256,6 +267,32 @@ export class AccountMoveService {
}
}
@bindThis
private async mergeModerationNote(src: ThinUser, dst: MiUser): Promise<void> {
const srcprofile = await this.userProfilesRepository.findOneBy({ userId: src.id });
const dstprofile = await this.userProfilesRepository.findOneBy({ userId: dst.id });
if (!srcprofile || !dstprofile) return;
await this.userProfilesRepository.update({ userId: dst.id }, {
moderationNote: srcprofile.moderationNote + '\n' + dstprofile.moderationNote,
});
await this.userProfilesRepository.update({ userId: src.id }, {
moderationNote: srcprofile.moderationNote + '\n' + dstprofile.moderationNote,
});
}
@bindThis
private async insertAccountMoveLog(src: ThinUser, dst: MiUser): Promise<void> {
await this.userAccountMoveLogRepository.insert({
id: this.idService.gen(),
movedToId: dst.id,
movedFromId: src.id,
createdAt: new Date(),
});
}
@bindThis
private async adjustFollowingCounts(localFollowerIds: string[], oldAccount: MiUser): Promise<void> {
if (localFollowerIds.length === 0) return;

View File

@ -138,7 +138,7 @@ export class AnnouncementService {
limit: number,
offset: number,
moderator: MiUser,
): Promise<(MiAnnouncement & { userInfo: Packed<'UserLite'> | null, reads: number })[]> {
): Promise<(MiAnnouncement & { userInfo: Packed<'UserLite'> | null, reads: number, lastReadAt: Date | null })[]> {
const query = this.announcementsRepository.createQueryBuilder('announcement');
if (userId) {
@ -157,13 +157,14 @@ export class AnnouncementService {
.offset(offset)
.getMany();
const reads = new Map<MiAnnouncement, number>();
for (const announcement of announcements) {
reads.set(announcement, await this.announcementReadsRepository.countBy({
announcementId: announcement.id,
}));
}
const reads = announcements.length > 0
? await this.announcementReadsRepository.createQueryBuilder()
.select('"announcementId", count(*) as "reads", max("id") as "lastReadId"')
.where('"announcementId" IN (:...announcementIds)', { announcementIds: announcements.map(a => a.id) })
.groupBy('"announcementId"')
.getRawMany<{ announcementId: string, reads: number, lastReadId: string | null }>()
.then(rs => new Map(rs.map(r => [r.announcementId, { reads: r.reads, lastReadAt: r.lastReadId ? this.idService.parse(r.lastReadId).date : null }])))
: new Map();
const users = await this.usersRepository.findBy({
id: In(announcements.map(a => a.userId).filter(id => id != null)),
@ -174,8 +175,8 @@ export class AnnouncementService {
return announcements.map(announcement => ({
...announcement,
...reads.get(announcement.id) ?? { reads: 0, lastReadAt: null },
userInfo: packedUsers.find(u => u.id === announcement.userId) ?? null,
reads: reads.get(announcement) ?? 0,
}));
}
@ -293,18 +294,20 @@ export class AnnouncementService {
'read.id IS NOT NULL as "isRead"',
]);
query
.andWhere(
new Brackets((qb) => {
qb.orWhere('announcement."userId" = :userId', { userId: me.id });
qb.orWhere('announcement."userId" IS NULL');
}),
)
.andWhere(
new Brackets((qb) => {
qb.orWhere('announcement."forExistingUsers" = false');
qb.orWhere('announcement.id > :userId', { userId: me.id });
}),
);
.andWhere(new Brackets((qb) => {
qb.orWhere(new Brackets((nqb) => {
nqb.andWhere('announcement."userId" = :userId', { userId: me.id });
nqb.andWhere(isActive ? 'read.id IS NULL' : 'read.id IS NOT NULL');
}));
qb.orWhere(new Brackets((nqb) => {
nqb.andWhere('announcement."userId" IS NULL');
nqb.andWhere('announcement."isActive" = :isActive', { isActive });
}));
}))
.andWhere(new Brackets((qb) => {
qb.orWhere('announcement."forExistingUsers" = false');
qb.orWhere('announcement.id > :userId', { userId: me.id });
}));
} else {
query.select([
'announcement.*',
@ -312,12 +315,9 @@ export class AnnouncementService {
]);
query.andWhere('announcement."userId" IS NULL');
query.andWhere('announcement."forExistingUsers" = false');
query.andWhere('announcement."isActive" = :isActive', { isActive });
}
query.andWhere('announcement."isActive" = :isActive', {
isActive: isActive,
});
if (isActive) {
query.orderBy({
'"isRead"': 'ASC',

View File

@ -168,11 +168,14 @@ export class AntennaService implements OnApplicationShutdown {
if (antenna.src === 'home') {
// TODO
} else if (antenna.src === 'list') {
const listUsers = (await this.userListMembershipsRepository.findBy({
userListId: antenna.userListId!,
})).map(x => x.userId);
if (!listUsers.includes(note.userId)) return false;
if (antenna.userListId == null) return false;
const exists = await this.userListMembershipsRepository.exists({
where: {
userListId: antenna.userListId,
userId: note.userId,
},
});
if (!exists) return false;
} else if (antenna.src === 'users') {
const accts = antenna.users.map(x => {
const { username, host } = Acct.parse(x);

View File

@ -102,6 +102,7 @@ import { HashtagEntityService } from './entities/HashtagEntityService.js';
import { InstanceEntityService } from './entities/InstanceEntityService.js';
import { InviteCodeEntityService } from './entities/InviteCodeEntityService.js';
import { ModerationLogEntityService } from './entities/ModerationLogEntityService.js';
import { UserAccountMoveLogEntityService } from './entities/UserAccountMoveLogEntityService.js';
import { MutingEntityService } from './entities/MutingEntityService.js';
import { RenoteMutingEntityService } from './entities/RenoteMutingEntityService.js';
import { NoteEntityService } from './entities/NoteEntityService.js';
@ -244,6 +245,7 @@ const $HashtagEntityService: Provider = { provide: 'HashtagEntityService', useEx
const $InstanceEntityService: Provider = { provide: 'InstanceEntityService', useExisting: InstanceEntityService };
const $InviteCodeEntityService: Provider = { provide: 'InviteCodeEntityService', useExisting: InviteCodeEntityService };
const $ModerationLogEntityService: Provider = { provide: 'ModerationLogEntityService', useExisting: ModerationLogEntityService };
const $UserAccountMoveLogEntityService: Provider = { provide: 'UserAccountMoveLogEntityService', useExisting: UserAccountMoveLogEntityService };
const $MutingEntityService: Provider = { provide: 'MutingEntityService', useExisting: MutingEntityService };
const $RenoteMutingEntityService: Provider = { provide: 'RenoteMutingEntityService', useExisting: RenoteMutingEntityService };
const $NoteEntityService: Provider = { provide: 'NoteEntityService', useExisting: NoteEntityService };
@ -385,6 +387,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
InstanceEntityService,
InviteCodeEntityService,
ModerationLogEntityService,
UserAccountMoveLogEntityService,
MutingEntityService,
RenoteMutingEntityService,
NoteEntityService,
@ -522,6 +525,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$InstanceEntityService,
$InviteCodeEntityService,
$ModerationLogEntityService,
$UserAccountMoveLogEntityService,
$MutingEntityService,
$RenoteMutingEntityService,
$NoteEntityService,
@ -659,6 +663,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
InstanceEntityService,
InviteCodeEntityService,
ModerationLogEntityService,
UserAccountMoveLogEntityService,
MutingEntityService,
RenoteMutingEntityService,
NoteEntityService,
@ -795,6 +800,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$InstanceEntityService,
$InviteCodeEntityService,
$ModerationLogEntityService,
$UserAccountMoveLogEntityService,
$MutingEntityService,
$RenoteMutingEntityService,
$NoteEntityService,

View File

@ -59,6 +59,7 @@ import { isReply } from '@/misc/is-reply.js';
import { trackPromise } from '@/misc/promise-tracker.js';
import { isNotNull } from '@/misc/is-not-null.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { CollapsedQueue } from '@/misc/collapsed-queue.js';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@ -150,6 +151,7 @@ type Option = {
export class NoteCreateService implements OnApplicationShutdown {
private logger: Logger;
#shutdownController = new AbortController();
private updateNotesCountQueue: CollapsedQueue<MiNote['id'], number>;
constructor(
@Inject(DI.config)
@ -217,6 +219,7 @@ export class NoteCreateService implements OnApplicationShutdown {
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('note:create');
this.updateNotesCountQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount);
}
@bindThis
@ -548,7 +551,7 @@ export class NoteCreateService implements OnApplicationShutdown {
// Register host
if (this.userEntityService.isRemoteUser(user)) {
this.federatedInstanceService.fetch(user.host).then(async i => {
this.instancesRepository.increment({ id: i.id }, 'notesCount', 1);
this.updateNotesCountQueue.enqueue(i.id, 1);
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
this.instanceChart.updateNote(i.host, note, true);
}
@ -1093,12 +1096,23 @@ export class NoteCreateService implements OnApplicationShutdown {
}
@bindThis
public dispose(): void {
this.#shutdownController.abort();
private collapseNotesCount(oldValue: number, newValue: number) {
return oldValue + newValue;
}
@bindThis
public onApplicationShutdown(signal?: string | undefined): void {
this.dispose();
private async performUpdateNotesCount(id: MiNote['id'], incrBy: number) {
await this.instancesRepository.increment({ id: id }, 'notesCount', incrBy);
}
@bindThis
public async dispose(): Promise<void> {
this.#shutdownController.abort();
await this.updateNotesCountQueue.performAllNow();
}
@bindThis
public async onApplicationShutdown(signal?: string | undefined): Promise<void> {
await this.dispose();
}
}

View File

@ -378,6 +378,16 @@ export class QueueService {
});
}
@bindThis
public createUserSuspendJob(user: ThinUser) {
return this.dbQueue.add('userSuspend', {
user: { id: user.id },
}, {
removeOnComplete: true,
removeOnFail: true,
});
}
@bindThis
public createReportAbuseJob(report: MiAbuseUserReport) {
return this.dbQueue.add('reportAbuse',

View File

@ -9,16 +9,7 @@ import { bindThis } from '@/decorators.js';
import { DI } from '@/di-symbols.js';
import type Logger from '@/logger.js';
import type { MiUser } from '@/models/User.js';
import type {
AntennasRepository,
ClipNotesRepository,
ClipsRepository,
FollowingsRepository,
FollowRequestsRepository,
UserListMembershipsRepository,
UserListsRepository,
WebhooksRepository,
} from '@/models/_.js';
import type { FollowingsRepository } from '@/models/_.js';
import { QueueService } from '@/core/QueueService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
@ -36,27 +27,6 @@ export class UserSuspendService {
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
@Inject(DI.followRequestsRepository)
private followRequestsRepository: FollowRequestsRepository,
@Inject(DI.antennasRepository)
private antennasRepository: AntennasRepository,
@Inject(DI.webhooksRepository)
private webhooksRepository: WebhooksRepository,
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
@Inject(DI.clipsRepository)
private clipsRepository: ClipsRepository,
@Inject(DI.clipNotesRepository)
private clipNotesRepository: ClipNotesRepository,
@Inject(DI.userListMembershipsRepository)
private userListMembershipsRepository: UserListMembershipsRepository,
private queueService: QueueService,
private globalEventService: GlobalEventService,
private apRendererService: ApRendererService,
@ -72,41 +42,7 @@ export class UserSuspendService {
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
const promises: Promise<unknown>[] = [];
let cursor = '';
while (true) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition, no-constant-condition
const clipNotes = await this.clipNotesRepository.createQueryBuilder('c')
.select('c.id')
.innerJoin('c.note', 'n')
.where('n.userId = :userId', { userId: user.id })
.andWhere('c.id > :cursor', { cursor })
.orderBy('c.id', 'ASC')
.limit(500)
.getRawMany<{ id: string }>();
if (clipNotes.length === 0) break;
cursor = clipNotes.at(-1)?.id ?? '';
promises.push(this.clipNotesRepository.createQueryBuilder()
.delete()
.where('id IN (:...ids)', { ids: clipNotes.map((clipNote) => clipNote.id) })
.execute());
}
await Promise.allSettled([
this.followRequestsRepository.delete({ followeeId: user.id }),
this.followRequestsRepository.delete({ followerId: user.id }),
this.antennasRepository.delete({ userId: user.id }),
this.webhooksRepository.delete({ userId: user.id }),
this.userListsRepository.delete({ userId: user.id }),
this.clipsRepository.delete({ userId: user.id }),
...promises,
this.userListMembershipsRepository.delete({ userId: user.id }),
]);
await this.queueService.createUserSuspendJob(user);
if (this.userEntityService.isLocalUser(user)) {
// 知り得る全SharedInboxにDelete配信

View File

@ -682,10 +682,7 @@ export class ApPersonService implements OnModuleInit {
// まずサーバー内で検索して様子見
let dst = await this.fetchPerson(src.movedToUri);
if (dst && this.userEntityService.isLocalUser(dst)) {
// targetがローカルユーザーだった場合データベースから引っ張ってくる
dst = await this.usersRepository.findOneByOrFail({ uri: src.movedToUri }) as MiLocalUser;
} else if (dst) {
if (dst) {
if (movePreventUris.includes(src.movedToUri)) return 'skip: circular move';
// targetを見つけたことがあるならtargetをupdatePersonする
@ -702,13 +699,15 @@ export class ApPersonService implements OnModuleInit {
dst = await this.resolvePerson(src.movedToUri);
}
if (dst.movedToUri === dst.uri) return 'skip: movedTo itself (dst)'; //
if (src.movedToUri !== dst.uri) return 'skip: missmatch uri'; //
if (dst.movedToUri === src.uri) return 'skip: dst.movedToUri === src.uri';
const dstUri = this.userEntityService.getUserUri(dst);
const srcUri = this.userEntityService.getUserUri(src);
if (dst.movedToUri === dstUri) return 'skip: movedTo itself (dst)'; //
if (src.movedToUri !== dstUri) return 'skip: missmatch uri'; //
if (dst.movedToUri === srcUri) return 'skip: dst.movedToUri === src.uri';
if (!dst.alsoKnownAs || dst.alsoKnownAs.length === 0) {
return 'skip: dst.alsoKnownAs is empty';
}
if (!dst.alsoKnownAs.includes(src.uri)) {
if (!dst.alsoKnownAs.includes(srcUri)) {
return 'skip: alsoKnownAs does not include from.uri';
}

View File

@ -7,6 +7,7 @@ import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm';
import type { MiUser } from '@/models/User.js';
import { AppLockService } from '@/core/AppLockService.js';
import { addTime, dateUTC, subtractTime } from '@/misc/prelude/time.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import Chart from '../core.js';
@ -54,10 +55,30 @@ export default class PerUserPvChart extends Chart<typeof schema> { // eslint-dis
}
@bindThis
public async getChartUsers(span: 'hour' | 'day', amount: number, cursor: Date | null, limit = 0, offset = 0): Promise<{
userId: string;
count: number;
}[]> {
return await this.getChartPv(span, amount, cursor, limit, offset);
public async getUsersRanking(span: 'hour' | 'day', order: 'ASC' | 'DESC', amount: number, cursor: Date | null, limit = 0, offset = 0): Promise<{ userId: string; count: number; }[]> {
const [y, m, d, h, _m, _s, _ms] = cursor ? Chart.parseDate(subtractTime(addTime(cursor, 1, span), 1)) : Chart.getCurrentDate();
const [y2, m2, d2, h2] = cursor ? Chart.parseDate(addTime(cursor, 1, span)) : [] as never;
const lt = dateUTC([y, m, d, h, _m, _s, _ms]);
const gt =
span === 'day' ? subtractTime(cursor ? dateUTC([y2, m2, d2, 0]) : dateUTC([y, m, d, 0]), amount - 1, 'day') :
span === 'hour' ? subtractTime(cursor ? dateUTC([y2, m2, d2, h2]) : dateUTC([y, m, d, h]), amount - 1, 'hour') :
new Error('not happen') as never;
const repository =
span === 'hour' ? this.repositoryForHour :
span === 'day' ? this.repositoryForDay :
new Error('not happen') as never;
// ログ取得
return await repository.createQueryBuilder()
.select('"group" as "userId", sum("___upv_user" + "___upv_visitor") as "count"')
.where('date BETWEEN :gt AND :lt', { gt: Chart.dateToTimestamp(gt), lt: Chart.dateToTimestamp(lt) })
.groupBy('"userId"')
.orderBy('"count"', order)
.offset(offset)
.limit(limit)
.getRawMany<{ userId: string, count: number }>();
}
}

View File

@ -147,10 +147,8 @@ export default abstract class Chart<T extends Schema> {
// ↓にしたいけどfindOneとかで型エラーになる
//private repositoryForHour: Repository<RawRecord<T>>;
//private repositoryForDay: Repository<RawRecord<T>>;
private repositoryForHour: Repository<{ id: number; group?: string | null; date: number;}>;
private repositoryForDay: Repository<{ id: number; group?: string | null; date: number;}>;
private repositoryUserPvForHour: Repository<{ id: number; group?: string | null; date: number; ___pv_user:number; ___upv_user:number; ___pv_visitor:number; ___upv_visitor:number;}>;
private repositoryUserPvForDay: Repository<{ id: number; group?: string | null; date: number; ___pv_user:number; ___upv_user:number; ___pv_visitor:number; ___upv_visitor:number;}>;
protected repositoryForHour: Repository<{ id: number; group?: string | null; date: number; }>;
protected repositoryForDay: Repository<{ id: number; group?: string | null; date: number; }>;
/**
* 1(CASCADE削除などアプリケーション側で感知できない変動によるズレの修正用)
*/
@ -186,11 +184,11 @@ export default abstract class Chart<T extends Schema> {
return columns;
}
private static dateToTimestamp(x: Date): number {
protected static dateToTimestamp(x: Date): number {
return Math.floor(x.getTime() / 1000);
}
private static parseDate(date: Date): [number, number, number, number, number, number, number] {
protected static parseDate(date: Date): [number, number, number, number, number, number, number] {
const y = date.getUTCFullYear();
const m = date.getUTCMonth();
const d = date.getUTCDate();
@ -202,7 +200,7 @@ export default abstract class Chart<T extends Schema> {
return [y, m, d, h, _m, _s, _ms];
}
private static getCurrentDate() {
protected static getCurrentDate() {
return Chart.parseDate(new Date());
}
@ -274,8 +272,6 @@ export default abstract class Chart<T extends Schema> {
const { hour, day } = Chart.schemaToEntity(name, schema, grouped);
this.repositoryForHour = db.getRepository<{ id: number; group?: string | null; date: number; }>(hour);
this.repositoryForDay = db.getRepository<{ id: number; group?: string | null; date: number; }>(day);
this.repositoryUserPvForHour = db.getRepository<{ id: number; group?: string | null; date: number; ___pv_user:number; ___upv_user:number; ___pv_visitor:number; ___upv_visitor:number;}>(hour);
this.repositoryUserPvForDay = db.getRepository<{ id: number; group?: string | null; date: number; ___pv_user:number; ___upv_user:number; ___pv_visitor:number; ___upv_visitor:number;}>(day);
}
@bindThis
@ -725,51 +721,4 @@ export default abstract class Chart<T extends Schema> {
}
return object as Unflatten<ChartResult<T>>;
}
@bindThis
public async getChartPv(span: 'hour' | 'day', amount: number, cursor: Date | null, limit: number, offset: number): Promise<
{
userId: string,
count: number,
}[]
> {
const [y, m, d, h, _m, _s, _ms] = cursor ? Chart.parseDate(subtractTime(addTime(cursor, 1, span), 1)) : Chart.getCurrentDate();
const [y2, m2, d2, h2] = cursor ? Chart.parseDate(addTime(cursor, 1, span)) : [] as never;
const lt = dateUTC([y, m, d, h, _m, _s, _ms]);
const gt =
span === 'day' ? subtractTime(cursor ? dateUTC([y2, m2, d2, 0]) : dateUTC([y, m, d, 0]), amount - 1, 'day') :
span === 'hour' ? subtractTime(cursor ? dateUTC([y2, m2, d2, h2]) : dateUTC([y, m, d, h]), amount - 1, 'hour') :
new Error('not happen') as never;
const repository =
span === 'hour' ? this.repositoryUserPvForHour :
span === 'day' ? this.repositoryUserPvForDay :
new Error('not happen') as never;
// ログ取得
const logs = await repository.createQueryBuilder()
.where('date BETWEEN :gt AND :lt', { gt: Chart.dateToTimestamp(gt), lt: Chart.dateToTimestamp(lt) })
.orderBy('___pv_visitor + ___upv_visitor + ___pv_user + ___upv_user', 'DESC')
.skip(offset)
.take(limit)
.getMany() as {
___pv_visitor: number,
___upv_visitor: number,
___pv_user: number,
___upv_user: number,
group: string,
}[];
const result = [] as {
userId: string,
count: number,
}[];
for (const row of logs) {
const userId = row.group;
const count = row.___pv_user + row.___upv_user + row.___pv_visitor + row.___upv_visitor;
result.push({ userId, count });
}
return result;
}
}

View File

@ -0,0 +1,53 @@
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { MiUserAccountMoveLog, UserAccountMoveLogRepository } from '@/models/_.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import type { MiUser } from '@/models/User.js';
import { bindThis } from '@/decorators.js';
import { Packed } from '@/misc/json-schema.js';
import { IdService } from '@/core/IdService.js';
import { UserEntityService } from './UserEntityService.js';
@Injectable()
export class UserAccountMoveLogEntityService {
constructor(
@Inject(DI.userAccountMoveLogRepository)
private userAccountMoveLogRepository: UserAccountMoveLogRepository,
private userEntityService: UserEntityService,
private idService: IdService,
) {
}
@bindThis
public async pack(
src: MiUserAccountMoveLog['id'] | MiUserAccountMoveLog,
me: { id: MiUser['id'] } | null | undefined,
) : Promise<Packed<'UserAccountMoveLog'>> {
const log = typeof src === 'object' ? src : await this.userAccountMoveLogRepository.findOneByOrFail({ id: src });
return await awaitAll({
id: log.id,
createdAt: this.idService.parse(log.id).date.toISOString(),
movedFromId: log.movedFromId,
movedFrom: this.userEntityService.pack(log.movedFrom ?? log.movedFromId, me, {
schema: 'UserDetailed',
}),
movedToId: log.movedToId,
movedTo: this.userEntityService.pack(log.movedTo ?? log.movedToId, me, {
schema: 'UserDetailed',
}),
});
}
@bindThis
public async packMany(
reports: (MiUserAccountMoveLog['id'] | MiUserAccountMoveLog)[],
me: { id: MiUser['id'] } | null | undefined,
) : Promise<Packed<'UserAccountMoveLog'>[]> {
return (await Promise.allSettled(reports.map(x => this.pack(x, me))))
.filter(result => result.status === 'fulfilled')
.map(result => (result as PromiseFulfilledResult<Packed<'UserAccountMoveLog'>>).value);
}
}

View File

@ -37,6 +37,7 @@ export const DI = {
userListMembershipsRepository: Symbol('userListMembershipsRepository'),
userNotePiningsRepository: Symbol('userNotePiningsRepository'),
userIpsRepository: Symbol('userIpsRepository'),
userAccountMoveLogRepository: Symbol('userAccountMoveLogRepository'),
usedUsernamesRepository: Symbol('usedUsernamesRepository'),
followingsRepository: Symbol('followingsRepository'),
followRequestsRepository: Symbol('followRequestsRepository'),

View File

@ -0,0 +1,43 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
type Job<V> = {
value: V;
timer: NodeJS.Timeout;
};
export class CollapsedQueue<K, V> {
private jobs: Map<K, Job<V>> = new Map();
constructor(
private timeout: number,
private collapse: (oldValue: V, newValue: V) => V,
private perform: (key: K, value: V) => Promise<void>,
) {}
enqueue(key: K, value: V) {
if (this.jobs.has(key)) {
const old = this.jobs.get(key)!;
const merged = this.collapse(old.value, value);
this.jobs.set(key, { ...old, value: merged });
} else {
const timer = setTimeout(() => {
const job = this.jobs.get(key)!;
this.jobs.delete(key);
this.perform(key, job.value);
}, this.timeout);
this.jobs.set(key, { value, timer });
}
}
async performAllNow() {
const entries = [...this.jobs.entries()];
this.jobs.clear();
for (const [_key, job] of entries) {
clearTimeout(job.timer);
}
await Promise.allSettled(entries.map(([key, job]) => this.perform(key, job.value)));
}
}

View File

@ -38,6 +38,7 @@ import { packedQueueCountSchema } from '@/models/json-schema/queue.js';
import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/json-schema/emoji.js';
import { packedRenoteMutingSchema } from '@/models/json-schema/renote-muting.js';
import { packedUserListMembershipSchema, packedUserListSchema } from '@/models/json-schema/user-list.js';
import { packedUserAccountMoveLogSchema } from '@/models/json-schema/user-account-move-log.js';
import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js';
import { packedSigninSchema } from '@/models/json-schema/signin.js';
import {
@ -72,6 +73,7 @@ export const refs = {
UserList: packedUserListSchema,
UserListMembership: packedUserListMembershipSchema,
UserAccountMoveLog: packedUserAccountMoveLogSchema,
Ad: packedAdSchema,
Announcement: packedAnnouncementSchema,
App: packedAppSchema,

View File

@ -74,6 +74,7 @@ import {
MiUserProfile,
MiUserPublickey,
MiUserSecurityKey,
MiUserAccountMoveLog,
MiWebhook,
MiBubbleGameRecord,
MiReversiGame,
@ -201,6 +202,12 @@ const $userListMembershipsRepository: Provider = {
inject: [DI.db],
};
const $userAccountMoveLogRepository: Provider = {
provide: DI.userAccountMoveLogRepository,
useFactory: (db: DataSource) => db.getRepository(MiUserAccountMoveLog),
inject: [DI.db],
};
const $userNotePiningsRepository: Provider = {
provide: DI.userNotePiningsRepository,
useFactory: (db: DataSource) => db.getRepository(MiUserNotePining),
@ -531,6 +538,7 @@ const $abuseReportResolversRepository: Provider = {
$userListsRepository,
$userListFavoritesRepository,
$userListMembershipsRepository,
$userAccountMoveLogRepository,
$userNotePiningsRepository,
$userIpsRepository,
$usedUsernamesRepository,
@ -604,6 +612,7 @@ const $abuseReportResolversRepository: Provider = {
$userListsRepository,
$userListFavoritesRepository,
$userListMembershipsRepository,
$userAccountMoveLogRepository,
$userNotePiningsRepository,
$userIpsRepository,
$usedUsernamesRepository,

View File

@ -0,0 +1,35 @@
import { Entity, Index, Column, ManyToOne, JoinColumn, PrimaryColumn } from 'typeorm';
import { id } from './util/id.js';
import { MiUser } from './User.js';
@Entity('user_account_move_log')
export class MiUserAccountMoveLog {
@PrimaryColumn(id())
public id: string;
@Index()
@Column(id())
public movedToId: MiUser['id'];
@ManyToOne(type => MiUser, {
onDelete: 'CASCADE',
})
@JoinColumn()
public movedTo: MiUser | null;
@Index()
@Column(id())
public movedFromId: MiUser['id'];
@ManyToOne(type => MiUser, {
onDelete: 'CASCADE',
})
@JoinColumn()
public movedFrom: MiUser | null;
@Column('timestamp with time zone', {
comment: 'The created date of the UserIp.',
default: () => 'CURRENT_TIMESTAMP',
})
public createdAt: Date;
}

View File

@ -64,6 +64,7 @@ import { MiUserProfile } from '@/models/UserProfile.js';
import { MiUserPublickey } from '@/models/UserPublickey.js';
import { MiUserSecurityKey } from '@/models/UserSecurityKey.js';
import { MiUserMemo } from '@/models/UserMemo.js';
import { MiUserAccountMoveLog } from '@/models/UserAccountMoveLog.js';
import { MiWebhook } from '@/models/Webhook.js';
import { MiChannel } from '@/models/Channel.js';
import { MiRetentionAggregation } from '@/models/RetentionAggregation.js';
@ -148,6 +149,7 @@ export {
MiUserMemo,
MiBubbleGameRecord,
MiReversiGame,
MiUserAccountMoveLog,
};
export type AbuseReportResolversRepository = Repository<MiAbuseReportResolver>;
@ -211,6 +213,7 @@ export type UserPendingsRepository = Repository<MiUserPending>;
export type UserProfilesRepository = Repository<MiUserProfile>;
export type UserPublickeysRepository = Repository<MiUserPublickey>;
export type UserSecurityKeysRepository = Repository<MiUserSecurityKey>;
export type UserAccountMoveLogRepository = Repository<MiUserAccountMoveLog>;
export type WebhooksRepository = Repository<MiWebhook>;
export type ChannelsRepository = Repository<MiChannel>;
export type RetentionAggregationsRepository = Repository<MiRetentionAggregation>;

View File

@ -0,0 +1,36 @@
export const packedUserAccountMoveLogSchema = {
type: 'object',
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
example: 'xxxxxxxxxx',
},
createdAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
movedToId: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
movedTo: {
type: 'object',
ref: 'UserDetailed',
optional: false, nullable: false,
},
movedFromId: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
movedFrom: {
type: 'object',
ref: 'UserDetailed',
optional: false, nullable: false,
},
},
} as const;

View File

@ -73,6 +73,7 @@ import { MiUserPending } from '@/models/UserPending.js';
import { MiUserProfile } from '@/models/UserProfile.js';
import { MiUserPublickey } from '@/models/UserPublickey.js';
import { MiUserSecurityKey } from '@/models/UserSecurityKey.js';
import { MiUserAccountMoveLog } from '@/models/UserAccountMoveLog.js';
import { MiWebhook } from '@/models/Webhook.js';
import { MiChannel } from '@/models/Channel.js';
import { MiRetentionAggregation } from '@/models/RetentionAggregation.js';
@ -154,6 +155,7 @@ export const entities = [
MiUserListMembership,
MiUserNotePining,
MiUserSecurityKey,
MiUserAccountMoveLog,
MiUsedUsername,
MiFollowing,
MiFollowRequest,

View File

@ -16,6 +16,7 @@ import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMu
import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js';
import { CleanProcessorService } from './processors/CleanProcessorService.js';
import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesProcessorService.js';
import { UserSuspendProcessorService } from './processors/UserSuspendProcessorService.js';
import { DeleteAccountProcessorService } from './processors/DeleteAccountProcessorService.js';
import { TruncateAccountProcessorService } from './processors/TruncateAccountProcessorService.js';
import { DeleteDriveFilesProcessorService } from './processors/DeleteDriveFilesProcessorService.js';
@ -72,6 +73,7 @@ import { AutoNoteRemovalProcessorService } from './processors/AutoNoteRemovalPro
ImportUserListsProcessorService,
ImportCustomEmojisProcessorService,
ImportAntennasProcessorService,
UserSuspendProcessorService,
DeleteAccountProcessorService,
TruncateAccountProcessorService,
DeleteFileProcessorService,

View File

@ -29,6 +29,7 @@ import { ImportBlockingProcessorService } from './processors/ImportBlockingProce
import { ImportUserListsProcessorService } from './processors/ImportUserListsProcessorService.js';
import { ImportCustomEmojisProcessorService } from './processors/ImportCustomEmojisProcessorService.js';
import { ImportAntennasProcessorService } from './processors/ImportAntennasProcessorService.js';
import { UserSuspendProcessorService } from './processors/UserSuspendProcessorService.js';
import { DeleteAccountProcessorService } from './processors/DeleteAccountProcessorService.js';
import { TruncateAccountProcessorService } from './processors/TruncateAccountProcessorService.js';
import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js';
@ -110,6 +111,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
private importUserListsProcessorService: ImportUserListsProcessorService,
private importCustomEmojisProcessorService: ImportCustomEmojisProcessorService,
private importAntennasProcessorService: ImportAntennasProcessorService,
private userSuspendProcessorService: UserSuspendProcessorService,
private deleteAccountProcessorService: DeleteAccountProcessorService,
private truncateAccountProcessorService: TruncateAccountProcessorService,
private deleteFileProcessorService: DeleteFileProcessorService,
@ -192,6 +194,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
case 'importCustomEmojis': return this.importCustomEmojisProcessorService.process(job);
case 'importAntennas': return this.importAntennasProcessorService.process(job);
case 'deleteAccount': return this.deleteAccountProcessorService.process(job);
case 'userSuspend': return this.userSuspendProcessorService.process(job);
case 'reportAbuse': return this.reportAbuseProcessorService.process(job);
case 'truncateAccount': return this.truncateAccountProcessorService.process(job);
default: throw new Error(`unrecognized job type ${job.name} for db`);

View File

@ -40,7 +40,7 @@ export class DeleteAccountProcessorService {
private roleService: RoleService,
private queueLoggerService: QueueLoggerService,
) {
this.logger = this.queueLoggerService.logger.createSubLogger('delete-account');
this.logger = this.queueLoggerService.logger.createSubLogger('account:delete');
}
private async deleteNotes(user: MiUser) {

View File

@ -4,7 +4,7 @@
*/
import { URL } from 'node:url';
import { Injectable } from '@nestjs/common';
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import httpSignature from '@peertube/http-signature';
import * as Bull from 'bullmq';
import type Logger from '@/logger.js';
@ -26,12 +26,15 @@ import { JsonLdService } from '@/core/activitypub/JsonLdService.js';
import { ApInboxService } from '@/core/activitypub/ApInboxService.js';
import { bindThis } from '@/decorators.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { CollapsedQueue } from '@/misc/collapsed-queue.js';
import { MiNote } from '@/models/Note.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type { InboxJobData } from '../types.js';
@Injectable()
export class InboxProcessorService {
export class InboxProcessorService implements OnApplicationShutdown {
private logger: Logger;
private updateInstanceQueue: CollapsedQueue<MiNote['id'], Date>;
constructor(
private utilityService: UtilityService,
@ -48,6 +51,7 @@ export class InboxProcessorService {
private queueLoggerService: QueueLoggerService,
) {
this.logger = this.queueLoggerService.logger.createSubLogger('inbox');
this.updateInstanceQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseUpdateInstanceJobs, this.performUpdateInstance);
}
@bindThis
@ -180,10 +184,7 @@ export class InboxProcessorService {
// Update stats
this.federatedInstanceService.fetch(authUser.user.host).then(i => {
this.federatedInstanceService.update(i.id, {
latestRequestReceivedAt: new Date(),
isNotResponding: false,
});
this.updateInstanceQueue.enqueue(i.id, new Date());
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
@ -211,4 +212,27 @@ export class InboxProcessorService {
}
return 'ok';
}
@bindThis
public collapseUpdateInstanceJobs(oldValue: Date, newValue: Date) {
return oldValue < newValue ? newValue : oldValue;
}
@bindThis
public async performUpdateInstance(id: string, value: Date) {
await this.federatedInstanceService.update(id, {
latestRequestReceivedAt: value,
isNotResponding: false,
});
}
@bindThis
public async dispose(): Promise<void> {
await this.updateInstanceQueue.performAllNow();
}
@bindThis
async onApplicationShutdown(signal?: string) {
await this.dispose();
}
}

View File

@ -0,0 +1,101 @@
import { Inject, Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import { DI } from '@/di-symbols.js';
import type Logger from '@/logger.js';
import type {
AntennasRepository,
ClipNotesRepository,
ClipsRepository,
FollowRequestsRepository,
UserListMembershipsRepository,
UserListsRepository, UsersRepository,
WebhooksRepository,
} from '@/models/_.js';
import { QueueLoggerService } from '@/queue/QueueLoggerService.js';
import type * as Bull from "bullmq";
import type { DbUserSuspendJobData } from "@/queue/types.js";
@Injectable()
export class UserSuspendProcessorService {
public logger: Logger;
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.followRequestsRepository)
private followRequestsRepository: FollowRequestsRepository,
@Inject(DI.antennasRepository)
private antennasRepository: AntennasRepository,
@Inject(DI.webhooksRepository)
private webhooksRepository: WebhooksRepository,
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
@Inject(DI.clipsRepository)
private clipsRepository: ClipsRepository,
@Inject(DI.clipNotesRepository)
private clipNotesRepository: ClipNotesRepository,
@Inject(DI.userListMembershipsRepository)
private userListMembershipsRepository: UserListMembershipsRepository,
private queueLoggerService: QueueLoggerService,
) {
this.logger = this.queueLoggerService.logger.createSubLogger('account:suspend');
}
@bindThis
public async process(job: Bull.Job<DbUserSuspendJobData>): Promise<string | void> {
this.logger.warn(`Cleaning up suspended account of ${job.data.user.id} ...`, { userSuspendJobData: job.data });
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
if (user == null) {
return 'User not found';
}
const promises: Promise<unknown>[] = [];
let cursor = '';
while (true) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition, no-constant-condition
const clipNotes = await this.clipNotesRepository.createQueryBuilder('c')
.select('c.id')
.innerJoin('c.note', 'n')
.where('n.userId = :userId', { userId: user.id })
.andWhere('c.id > :cursor', { cursor })
.orderBy('c.id', 'ASC')
.limit(100)
.getRawMany<{ id: string }>();
if (clipNotes.length === 0) break;
cursor = clipNotes.at(-1)?.id ?? '';
promises.push(this.clipNotesRepository.createQueryBuilder()
.delete()
.where('id IN (:...ids)', { ids: clipNotes.map((clipNote) => clipNote.id) })
.execute());
}
await Promise.allSettled([
this.followRequestsRepository.delete({ followeeId: user.id }),
this.followRequestsRepository.delete({ followerId: user.id }),
this.antennasRepository.delete({ userId: user.id }),
this.webhooksRepository.delete({ userId: user.id }),
this.userListsRepository.delete({ userId: user.id }),
this.clipsRepository.delete({ userId: user.id }),
...promises,
this.userListMembershipsRepository.delete({ userId: user.id }),
]);
this.logger.info(`Completed cleaning up suspended account of ${job.data.user.id}`);
return 'done';
}
}

View File

@ -89,6 +89,10 @@ export type DbUserTruncateJobData = {
purgeDrive: boolean;
};
export type DbUserSuspendJobData = {
user: ThinUser
};
export type DbUserImportJobData = {
user: ThinUser;
fileId: MiDriveFile['id'];

View File

@ -79,6 +79,7 @@ import * as ep___admin_resolveAbuseUserReport from './endpoints/admin/resolve-ab
import * as ep___admin_sendEmail from './endpoints/admin/send-email.js';
import * as ep___admin_serverInfo from './endpoints/admin/server-info.js';
import * as ep___admin_showModerationLogs from './endpoints/admin/show-moderation-logs.js';
import * as ep___admin_showUserAccountMoveLogs from './endpoints/admin/show-user-account-move-logs.js';
import * as ep___admin_showUser from './endpoints/admin/show-user.js';
import * as ep___admin_showUsers from './endpoints/admin/show-users.js';
import * as ep___admin_suspendUser from './endpoints/admin/suspend-user.js';
@ -474,6 +475,7 @@ const $admin_resolveAbuseUserReport: Provider = { provide: 'ep:admin/resolve-abu
const $admin_sendEmail: Provider = { provide: 'ep:admin/send-email', useClass: ep___admin_sendEmail.default };
const $admin_serverInfo: Provider = { provide: 'ep:admin/server-info', useClass: ep___admin_serverInfo.default };
const $admin_showModerationLogs: Provider = { provide: 'ep:admin/show-moderation-logs', useClass: ep___admin_showModerationLogs.default };
const $admin_showUserAccountMoveLogs: Provider = { provide: 'ep:admin/show-user-account-move-logs', useClass: ep___admin_showUserAccountMoveLogs.default };
const $admin_showUser: Provider = { provide: 'ep:admin/show-user', useClass: ep___admin_showUser.default };
const $admin_showUsers: Provider = { provide: 'ep:admin/show-users', useClass: ep___admin_showUsers.default };
const $admin_suspendUser: Provider = { provide: 'ep:admin/suspend-user', useClass: ep___admin_suspendUser.default };
@ -873,6 +875,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_sendEmail,
$admin_serverInfo,
$admin_showModerationLogs,
$admin_showUserAccountMoveLogs,
$admin_showUser,
$admin_showUsers,
$admin_suspendUser,
@ -1266,6 +1269,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_sendEmail,
$admin_serverInfo,
$admin_showModerationLogs,
$admin_showUserAccountMoveLogs,
$admin_showUser,
$admin_showUsers,
$admin_suspendUser,

View File

@ -79,6 +79,7 @@ import * as ep___admin_resolveAbuseUserReport from './endpoints/admin/resolve-ab
import * as ep___admin_sendEmail from './endpoints/admin/send-email.js';
import * as ep___admin_serverInfo from './endpoints/admin/server-info.js';
import * as ep___admin_showModerationLogs from './endpoints/admin/show-moderation-logs.js';
import * as ep___admin_showUserAccountMoveLogs from './endpoints/admin/show-user-account-move-logs.js';
import * as ep___admin_showUser from './endpoints/admin/show-user.js';
import * as ep___admin_showUsers from './endpoints/admin/show-users.js';
import * as ep___admin_suspendUser from './endpoints/admin/suspend-user.js';
@ -472,6 +473,7 @@ const eps = [
['admin/send-email', ep___admin_sendEmail],
['admin/server-info', ep___admin_serverInfo],
['admin/show-moderation-logs', ep___admin_showModerationLogs],
['admin/show-user-account-move-logs', ep___admin_showUserAccountMoveLogs],
['admin/show-user', ep___admin_showUser],
['admin/show-users', ep___admin_showUsers],
['admin/suspend-user', ep___admin_suspendUser],

View File

@ -97,6 +97,11 @@ export const meta = {
type: 'number',
optional: false, nullable: false,
},
lastReadAt: {
type: 'string',
optional: false, nullable: true,
format: 'date-time',
},
},
},
},
@ -140,6 +145,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
userId: announcement.userId,
user: announcement.userInfo,
reads: announcement.reads,
lastReadAt: announcement.lastReadAt?.toISOString() ?? null,
}));
});
}

View File

@ -0,0 +1,94 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { DI } from '@/di-symbols.js';
import type { UserAccountMoveLogRepository } from '@/models/_.js';
import { UserAccountMoveLogEntityService } from '@/core/entities/UserAccountMoveLogEntityService.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
kind: 'read:admin:show-account-move-log',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
createdAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
movedToId: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
movedTo: {
type: 'object',
optional: false, nullable: false,
ref: 'UserDetailed',
},
movedFromId: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
movedFrom: {
type: 'object',
optional: false, nullable: false,
ref: 'UserDetailed',
},
},
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
movedFromId: { type: 'string', format: 'misskey:id', nullable: true },
movedToId: { type: 'string', format: 'misskey:id', nullable: true },
},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.userAccountMoveLogRepository)
private userAccountMoveLogRepository: UserAccountMoveLogRepository,
private userAccountMoveLogEntityService: UserAccountMoveLogEntityService,
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.userAccountMoveLogRepository.createQueryBuilder('accountMoveLogs'), ps.sinceId, ps.untilId);
if (ps.movedFromId != null) {
query.andWhere('accountMoveLogs.movedFromId = :movedFromId', { movedFromId: ps.movedFromId });
}
if (ps.movedToId != null) {
query.andWhere('accountMoveLogs.movedToId = :movedToId', { movedToId: ps.movedToId });
}
const accountMoveLogs = await query.limit(ps.limit).getMany();
return await this.userAccountMoveLogEntityService.packMany(accountMoveLogs, me);
});
}
}

View File

@ -72,12 +72,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.hostname) {
query.andWhere('user.host = :hostname', { hostname: ps.hostname.toLowerCase() });
}
const chartUsers: { userId: string; count: number; }[] = [];
let pvRankedUsers: { userId: string; count: number; }[] | undefined = undefined;
if (ps.sort?.endsWith('pv')) {
await this.perUserPvChart.getChartUsers('hour', 0, null, ps.limit, ps.offset).then(users => {
chartUsers.push(...users);
});
// 直近12時間のPVランキングを取得
pvRankedUsers = await this.perUserPvChart.getUsersRanking(
'hour', ps.sort.startsWith('+') ? 'DESC' : 'ASC',
12, null, ps.limit, ps.offset,
);
}
switch (ps.sort) {
case '+follower': query.orderBy('user.followersCount', 'DESC'); break;
case '-follower': query.orderBy('user.followersCount', 'ASC'); break;
@ -85,16 +89,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
case '-createdAt': query.orderBy('user.id', 'ASC'); break;
case '+updatedAt': query.andWhere('user.updatedAt IS NOT NULL').orderBy('user.updatedAt', 'DESC'); break;
case '-updatedAt': query.andWhere('user.updatedAt IS NOT NULL').orderBy('user.updatedAt', 'ASC'); break;
case '+pv':
if (chartUsers.length > 0) {
query.andWhere('user.id IN (:...userIds)', { userIds: chartUsers.map(user => user.userId) });
}
break;
case '-pv':
if (chartUsers.length > 0) {
query.andWhere('user.id IN (:...userIds)', { userIds: chartUsers.map(user => user.userId) });
}
break;
case '+pv': query.andWhere((pvRankedUsers?.length ?? 0) > 0 ? 'user.id IN (:...userIds)' : '1 = 0', { userIds: pvRankedUsers?.map(user => user.userId) ?? [] }); break;
case '-pv': query.andWhere((pvRankedUsers?.length ?? 0) > 0 ? 'user.id IN (:...userIds)' : '1 = 0', { userIds: pvRankedUsers?.map(user => user.userId) ?? [] }); break;
default: query.orderBy('user.id', 'ASC'); break;
}
@ -107,14 +103,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const users = await query.getMany();
if (ps.sort === '+pv') {
users.sort((a, b) => {
const aPv = chartUsers.find(user => user.userId === a.id)?.count ?? 0;
const bPv = chartUsers.find(user => user.userId === b.id)?.count ?? 0;
const aPv = pvRankedUsers?.find(u => u.userId === a.id)?.count ?? 0;
const bPv = pvRankedUsers?.find(u => u.userId === b.id)?.count ?? 0;
return bPv - aPv;
});
} else if (ps.sort === '-pv') {
users.sort((a, b) => {
const aPv = chartUsers.find(user => user.userId === a.id)?.count ?? 0;
const bPv = chartUsers.find(user => user.userId === b.id)?.count ?? 0;
const aPv = pvRankedUsers?.find(u => u.userId === a.id)?.count ?? 0;
const bPv = pvRankedUsers?.find(u => u.userId === b.id)?.count ?? 0;
return aPv - bPv;
});
}

View File

@ -1,8 +1,19 @@
version: "3"
services:
keydbtest:
image: eqalpha/keydb:latest
dragonflytest:
image: docker.dragonflydb.io/dragonflydb/dragonfly
ulimits:
memlock: -1
environment:
DFLY_version_check: false
DFLY_tcp_backlog: 2048
DFLY_default_lua_flags: allow-undeclared-keys
DFLY_pipeline_squash: 0
DFLY_multi_exec_squash: false
DFLY_conn_io_threads: 4
DFLY_epoll_file_threads: 4
DFLY_proactor_threads: 4
ports:
- "127.0.0.1:56312:6379"

View File

@ -6,7 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div v-if="user" :class="$style.root">
<i class="ti ti-plane-departure" style="margin-right: 8px;"></i>
{{ i18n.ts.accountMoved }}
<span v-if="movedTo">{{ i18n.ts.accountMoved }}</span>
<span v-if="movedFrom">{{ i18n.ts.accountMovedFrom }}</span>
<MkMention :class="$style.link" :username="user.username" :host="user.host ?? localHost"/>
</div>
</template>
@ -22,10 +23,11 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
const user = ref<Misskey.entities.UserLite>();
const props = defineProps<{
movedTo: string; // user id
movedTo?: string; // user id
movedFrom?: string; // user id
}>();
misskeyApi('users/show', { userId: props.movedTo }).then(u => user.value = u);
misskeyApi('users/show', { userId: props.movedTo ?? props.movedFrom }).then(u => user.value = u);
</script>
<style lang="scss" module>

View File

@ -46,6 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts">
import { markRaw, ref, shallowRef, computed, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
import sanitizeHtml from 'sanitize-html';
import { debounce } from 'throttle-debounce';
import contains from '@/scripts/contains.js';
import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base.js';
import { acct } from '@/filters/user.js';
@ -189,6 +190,7 @@ const mfmTags = ref<string[]>([]);
const mfmParams = ref<string[]>([]);
const select = ref(-1);
const zIndex = os.claimZIndex('high');
const abortController = ref<AbortController>();
function complete<T extends keyof CompleteInfo>(type: T, value: CompleteInfo[T]['payload']) {
emit('done', { type, value });
@ -217,6 +219,39 @@ function setPosition() {
}
}
const searchUsers = debounce(1000, (query: string, cacheKey: string) => {
if (abortController.value) {
abortController.value.abort();
}
abortController.value = new AbortController();
misskeyApi('users/search-by-username-and-host', {
username: query,
limit: 10,
detail: false,
}, undefined, abortController.value.signal).then(searchedUsers => {
users.value = searchedUsers as any[];
fetching.value = false;
//
sessionStorage.setItem(cacheKey, JSON.stringify(searchedUsers));
});
});
const searchHashtags = debounce(1000, (query: string, cacheKey: string) => {
if (abortController.value) {
abortController.value.abort();
}
abortController.value = new AbortController();
misskeyApi('hashtags/search', {
query,
limit: 30,
}, undefined, abortController.value.signal).then(searchedHashtags => {
hashtags.value = searchedHashtags as any[];
fetching.value = false;
//
sessionStorage.setItem(cacheKey, JSON.stringify(searchedHashtags));
});
});
function exec() {
select.value = -1;
if (suggests.value) {
@ -238,16 +273,7 @@ function exec() {
users.value = JSON.parse(cache);
fetching.value = false;
} else {
misskeyApi('users/search-by-username-and-host', {
username: props.q,
limit: 10,
detail: false,
}).then(searchedUsers => {
users.value = searchedUsers as any[];
fetching.value = false;
//
sessionStorage.setItem(cacheKey, JSON.stringify(searchedUsers));
});
searchUsers(props.q, cacheKey);
}
} else if (props.type === 'hashtag') {
if (!props.q || props.q === '') {
@ -261,15 +287,7 @@ function exec() {
hashtags.value = hashtags;
fetching.value = false;
} else {
misskeyApi('hashtags/search', {
query: props.q,
limit: 30,
}).then(searchedHashtags => {
hashtags.value = searchedHashtags as any[];
fetching.value = false;
//
sessionStorage.setItem(cacheKey, JSON.stringify(searchedHashtags));
});
searchHashtags(props.q, cacheKey);
}
}
} else if (props.type === 'emoji') {

View File

@ -594,6 +594,7 @@ defineExpose({
width: auto;
height: auto;
min-width: 0;
padding: 0;
&:disabled {
cursor: not-allowed;
@ -700,7 +701,7 @@ defineExpose({
> .item {
position: relative;
padding: 0;
padding: 0 3px;
width: var(--eachSize);
height: var(--eachSize);
contain: strict;

View File

@ -46,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:withTooltip="true"
:reaction="notification.reaction.replace(/^:(\w+):$/, ':$1@.:')"
:noStyle="true"
style="width: 100%; height: 100%;"
style="width: 100%; height: 100% !important; object-fit: contain;"
/>
</div>
</div>
@ -123,7 +123,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:withTooltip="true"
:reaction="reaction.reaction.replace(/^:(\w+):$/, ':$1@.:')"
:noStyle="true"
style="width: 100%; height: 100%;"
style="width: 100%; height: 100% !important; object-fit: contain;"
/>
</div>
</div>

View File

@ -36,6 +36,7 @@ const emit = defineEmits<{
.icon {
display: block;
width: 60px;
max-height: 60px;
font-size: 60px; // unicodewidth
margin: 0 auto;
object-fit: contain;

View File

@ -63,6 +63,7 @@ function getReactionName(reaction: string): string {
.reactionIcon {
display: block;
width: 60px;
max-height: 60px;
font-size: 60px; // unicodewidth
object-fit: contain;
margin: 0 auto;

View File

@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ message }}
</MkInfo>
<div v-if="!totpLogin" class="normal-signin _gaps_m">
<MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username webauthn" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange">
<MkInput v-model="username" :debounce="true" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username webauthn" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange">
<template #prefix>@</template>
<template #suffix>@{{ host }}</template>
</MkInput>
@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #prefix><i class="ti ti-lock"></i></template>
<template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template>
</MkInput>
<MkButton type="submit" large primary rounded :disabled="signing" style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton>
<MkButton type="submit" large primary rounded :disabled="!user || signing" style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton>
</div>
<div v-if="totpLogin" class="2fa-signin" :class="{ securityKeys: user && user.securityKeys }">
<div v-if="user && user.securityKeys" class="twofa-group tap-group">
@ -64,6 +64,7 @@ import { login } from '@/account.js';
import { i18n } from '@/i18n.js';
const signing = ref(false);
const userAbortController = ref<AbortController>();
const user = ref<Misskey.entities.UserDetailed | null>(null);
const username = ref('');
const password = ref('');
@ -97,9 +98,13 @@ const props = defineProps({
});
function onUsernameChange(): void {
if (userAbortController.value) {
userAbortController.value.abort();
}
userAbortController.value = new AbortController();
misskeyApi('users/show', {
username: username.value,
}).then(userResponse => {
}, undefined, userAbortController.value.signal).then(userResponse => {
user.value = userResponse;
}, () => {
user.value = null;

View File

@ -189,7 +189,7 @@ async function onSubmit(): Promise<void> {
submitting.value = true;
try {
await misskeyApi('signup', {
await os.apiWithDialog('signup', {
username: username.value,
password: password.value.password,
emailAddress: email.value,
@ -198,35 +198,27 @@ async function onSubmit(): Promise<void> {
'm-captcha-response': mCaptchaResponse.value,
'g-recaptcha-response': reCaptchaResponse.value,
'turnstile-response': turnstileResponse.value,
});
if (instance.emailRequiredForSignup) {
os.alert({
type: 'success',
title: i18n.ts._signup.almostThere,
text: i18n.tsx._signup.emailSent({ email: email.value }),
});
emit('signupEmailPending');
} else {
const res = await misskeyApi('signin', {
username: username.value,
password: password.value.password,
});
emit('signup', res);
}, undefined, (res) => {
if (instance.emailRequiredForSignup) {
os.alert({
type: 'success',
title: i18n.ts._signup.almostThere,
text: i18n.tsx._signup.emailSent({ email: email.value }),
});
emit('signupEmailPending');
} else {
emit('signup', { id: res.id, i: res.token });
if (props.autoSet) {
return login(res.i, '/onboarding');
if (props.autoSet) {
login(res.i, '/onboarding');
}
}
}
});
} catch {
submitting.value = false;
hcaptcha.value?.reset?.();
recaptcha.value?.reset?.();
turnstile.value?.reset?.();
os.alert({
type: 'error',
text: i18n.ts.somethingHappened,
});
}
}
</script>

View File

@ -53,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts._announcement.silence }}
<template #caption>{{ i18n.ts._announcement.silenceDescription }}</template>
</MkSwitch>
<p v-if="reads">{{ i18n.tsx.nUsersRead({ n: reads }) }}</p>
<p v-if="reads">{{ i18n.tsx.nUsersRead({ n: reads }) }} <span v-if="lastReadAt">(<MkTime :time="lastReadAt" mode="absolute"/>)</span></p>
<MkUserCardMini v-if="props.user.id" :user="props.user"></MkUserCardMini>
<MkButton v-if="announcement" danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
</div>
@ -94,6 +94,7 @@ const closeDuration = ref<number>(props.announcement ? props.announcement.closeD
const displayOrder = ref<number>(props.announcement ? props.announcement.displayOrder : 0);
const silence = ref<boolean>(props.announcement ? props.announcement.silence : false);
const reads = ref<number>(props.announcement ? props.announcement.reads : 0);
const lastReadAt = ref<string | null>(props.announcement ? props.announcement.lastReadAt : null);
const emit = defineEmits<{
(ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void,

View File

@ -16,16 +16,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header>{{ i18n.ts.selectUser }}</template>
<div>
<div :class="$style.form">
<MkInput v-if="localOnly" v-model="username" :autofocus="true" @update:modelValue="search">
<MkInput v-if="localOnly" v-model="username" :debounce="true" :autofocus="true" @update:modelValue="search">
<template #label>{{ i18n.ts.username }}</template>
<template #prefix>@</template>
</MkInput>
<FormSplit v-else :minWidth="170">
<MkInput v-model="username" :autofocus="true" @update:modelValue="search">
<MkInput v-model="username" :debounce="true" :autofocus="true" @update:modelValue="search">
<template #label>{{ i18n.ts.username }}</template>
<template #prefix>@</template>
</MkInput>
<MkInput v-model="host" :datalist="[hostname]" @update:modelValue="search">
<MkInput v-model="host" :debounce="true" :datalist="[hostname]" @update:modelValue="search">
<template #label>{{ i18n.ts.host }}</template>
<template #prefix>@</template>
</MkInput>
@ -92,18 +92,23 @@ const users = ref<Misskey.entities.UserLite[]>([]);
const recentUsers = ref<Misskey.entities.UserDetailed[]>([]);
const selected = ref<Misskey.entities.UserLite | null>(null);
const dialogEl = ref();
const abortController = ref<AbortController>();
function search() {
if (abortController.value) {
abortController.value.abort();
}
if (username.value === '' && host.value === '') {
users.value = [];
return;
}
abortController.value = new AbortController();
misskeyApi('users/search-by-username-and-host', {
username: username.value,
host: props.localOnly ? '.' : host.value,
limit: 10,
detail: false,
}).then(_users => {
}, undefined, abortController.value.signal).then(_users => {
users.value = _users.filter((u) => {
if (props.includeSelf) {
return true;

View File

@ -34,60 +34,77 @@ export const apiWithDialog = (<E extends keyof Misskey.Endpoints = keyof Misskey
endpoint: E,
data: P = {} as P,
token?: string | null | undefined,
) => {
onSuccess?: ((res: Misskey.api.SwitchCaseResponseType<E, P>) => void) | null | undefined,
onFailure?: ((err: Misskey.api.APIError) => void) | null,
): Promise<Misskey.api.SwitchCaseResponseType<E, P>> => {
const promise = misskeyApi(endpoint, data, token);
promiseDialog(promise, null, async (err) => {
let title: string | undefined;
let text = err.message + '\n' + err.id;
if (err.code === 'INTERNAL_ERROR') {
title = i18n.ts.internalServerError;
text = i18n.ts.internalServerErrorDescription;
const date = new Date().toISOString();
const { result } = await actions({
type: 'error',
title,
text,
details: err.info,
actions: [{
value: 'ok',
text: i18n.ts.gotIt,
primary: true,
}, {
value: 'copy',
text: i18n.ts.copyErrorInfo,
}],
});
if (result === 'copy') {
copyToClipboard(`Endpoint: ${endpoint}\nInfo: ${JSON.stringify(err.info)}\nDate: ${date}`);
success();
}
return;
} else if (err.code === 'RATE_LIMIT_EXCEEDED') {
title = i18n.ts.cannotPerformTemporary;
text = i18n.ts.cannotPerformTemporaryDescription;
} else if (err.code === 'INVALID_PARAM') {
title = i18n.ts.invalidParamError;
text = i18n.ts.invalidParamErrorDescription;
} else if (err.code === 'ROLE_PERMISSION_DENIED') {
title = i18n.ts.permissionDeniedError;
text = i18n.ts.permissionDeniedErrorDescription;
} else if (err.code.startsWith('TOO_MANY')) {
title = i18n.ts.youCannotCreateAnymore;
text = `${i18n.ts.error}: ${err.id}`;
} else if (err.message.startsWith('Unexpected token')) {
title = i18n.ts.gotInvalidResponseError;
text = i18n.ts.gotInvalidResponseErrorDescription;
}
alert({
promiseDialog(promise, onSuccess, onFailure ?? (err => apiErrorHandler(err, endpoint)));
return promise;
});
export async function apiErrorHandler(err: Misskey.api.APIError, endpoint?: string): Promise<void> {
let title: string | undefined;
let text = err.message + '\n' + err.id;
if (err.code === 'INTERNAL_ERROR') {
title = i18n.ts.internalServerError;
text = i18n.ts.internalServerErrorDescription;
const date = new Date().toISOString();
const { result } = await actions({
type: 'error',
title,
text,
details: err.info,
actions: [{
value: 'ok',
text: i18n.ts.gotIt,
primary: true,
}, {
value: 'copy',
text: i18n.ts.copyErrorInfo,
}],
});
});
if (result === 'copy') {
copyToClipboard(`Endpoint: ${endpoint}\nInfo: ${JSON.stringify(err.info)}\nDate: ${date}`);
success();
}
return;
} else if (err.code === 'RATE_LIMIT_EXCEEDED') {
title = i18n.ts.cannotPerformTemporary;
text = i18n.ts.cannotPerformTemporaryDescription;
} else if (err.code === 'INVALID_PARAM') {
title = i18n.ts.invalidParamError;
text = i18n.ts.invalidParamErrorDescription;
} else if (err.code === 'ROLE_PERMISSION_DENIED') {
title = i18n.ts.permissionDeniedError;
text = i18n.ts.permissionDeniedErrorDescription;
} else if (err.code?.startsWith('TOO_MANY')) {
title = i18n.ts.youCannotCreateAnymore;
text = `${i18n.ts.error}: ${err.id}`;
}
return promise;
}) as typeof misskeyApi;
// @ts-expect-error Misskey内部で定義されていない不明なエラー
if (!err.id && (err.statusCode ?? 0) > 499) {
title = i18n.ts.gotInvalidResponseError;
text = i18n.ts.gotInvalidResponseErrorDescription;
}
if (err.id && !title) {
title = i18n.ts.somethingHappened;
} else if (!title) {
title = i18n.ts.somethingHappened;
text = err.message;
}
alert({
type: 'error',
title,
text,
// @ts-expect-error Misskeyのエラーならinfoを、そうでなければそのまま表示
details: err.id ? err.info : err as unknown,
});
}
export function promiseDialog<T>(
promise: Promise<T>,

View File

@ -149,7 +149,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i>
</span>
<span>{{ announcement.title }}</span>
<span v-if="announcement.reads > 0" style="margin-left: auto; opacity: 0.7;">{{ i18n.ts.messageRead }}</span>
<span v-if="announcement.reads > 0" style="margin-left: auto; opacity: 0.7;">{{ i18n.ts.messageRead }} <span v-if="announcement.lastReadAt">(<MkTime :time="announcement.lastReadAt" mode="absolute"/>)</span></span>
</div>
</div>
</template>

View File

@ -76,7 +76,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="announcement.silence" :helpText="i18n.ts._announcement.silenceDescription">
{{ i18n.ts._announcement.silence }}
</MkSwitch>
<p v-if="announcement.reads">{{ i18n.tsx.nUsersRead({ n: announcement.reads }) }}</p>
<p v-if="announcement.reads">{{ i18n.tsx.nUsersRead({ n: announcement.reads }) }} <span v-if="announcement.lastReadAt">(<MkTime :time="announcement.lastReadAt" mode="absolute"/>)</span></p>
<MkUserCardMini v-if="announcement.userId" :user="announcement.user" @click="editUser(announcement)"></MkUserCardMini>
<MkButton v-else class="button" inline primary @click="editUser(announcement)">{{ i18n.ts.specifyUser }}</MkButton>
<div class="buttons _buttons">

View File

@ -156,6 +156,11 @@ const menuDef = computed(() => [{
text: i18n.ts.moderationLogs,
to: '/admin/modlog',
active: currentPage.value?.route.name === 'modlog',
}, {
icon: 'ti ti-list-search',
text: i18n.ts.userAccountMoveLogs,
to: '/admin/useraccountmovelog',
active: currentPage.value?.route.name === 'useraccountmovelog',
}],
}, {
title: i18n.ts.settings,

View File

@ -0,0 +1,98 @@
<template>
<MkStickyContainer>
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="900">
<div style="display: flex; gap: var(--margin); flex-wrap: wrap;">
<MkInput v-model="movedFromId" style="margin: 0; flex: 1;">
<template #label> {{ i18n.ts.moveFromId }}</template>
</MkInput>
<MkInput v-model="movedToId" style="margin: 0; flex: 1;">
<template #label> {{ i18n.ts.movedToId }}</template>
</MkInput>
</div>
<MkPagination v-slot="{items}" ref="logs" :pagination="pagination" style="margin-top: var(--margin);">
<div class="_gaps_s">
<MkFolder v-for="item in items" :key="item.id">
<template #label>
{{ i18n.tsx.userAccountMoveLogsTitle({
from: '@' + item.movedFrom.username + (item.movedFrom.host ? `@${item.movedFrom.host}` : ''),
to: '@' + item.movedTo.username + (item.movedTo.host ? `@${item.movedTo.host}` : '')
})
}}
</template>
<div :class="$style.card">
<MkA :to="userPage(item.movedFrom)" :class="$style.cardContent">
<MkAvatar :user="item.movedFrom" :class="$style.avatar" link/>
<MkAcct :user="item.movedFrom"/>
</MkA>
<MkA :to="userPage(item.movedTo)" :class="$style.cardContent">
<MkAvatar :user="item.movedTo" :class="$style.avatar"/>
<MkAcct :user="item.movedTo"/>
</MkA>
</div>
</MkFolder>
</div>
</MkPagination>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { computed, shallowRef, ref } from 'vue';
import XHeader from './_header_.vue';
import MkInput from '@/components/MkInput.vue';
import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { userPage } from '@/filters/user.js';
import MkFolder from '@/components/MkFolder.vue';
const logs = shallowRef<InstanceType<typeof MkPagination>>();
const movedToId = ref('');
const movedFromId = ref('');
const pagination = {
endpoint: 'admin/show-user-account-move-logs' as const,
limit: 30,
params: computed(() => ({
movedFromId: movedFromId.value === '' ? null : movedFromId.value,
movedToId: movedToId.value === '' ? null : movedToId.value,
})),
};
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata(() => ({
title: i18n.ts.userAccountMoveLogs,
icon: 'ti ti-list-search',
}));
</script>
<style lang="scss" module>
.card {
display: flex;
gap: var(--margin);
border-radius: var(--radius);
padding: var(--margin);
align-items: center;
justify-content: center;
flex-wrap: wrap;
}
.avatar {
width: 48px;
height: 48px;
}
.cardContent{
display: flex;
gap: var(--margin);
align-items: center;
flex-direction: column;
}
</style>

View File

@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :contentMax="700">
<div class="_gaps_m">
<div class="_gaps_m">
<MkInput v-model="endpoint" :datalist="endpoints" @update:modelValue="onEndpointChange()">
<MkInput v-model="endpoint" :debounce="true" :datalist="endpoints" @update:modelValue="onEndpointChange()">
<template #label>Endpoint</template>
</MkInput>
<MkTextarea v-model="body" code>
@ -50,6 +50,7 @@ const endpoints = ref<string[]>([]);
const sending = ref(false);
const res = ref('');
const withCredential = ref(true);
const endpointAbortController = ref<AbortController>();
misskeyApi('endpoints').then(endpointResponse => {
endpoints.value = endpointResponse;
@ -68,7 +69,11 @@ function send() {
}
function onEndpointChange() {
misskeyApi('endpoint', { endpoint: endpoint.value }, withCredential.value ? undefined : null).then(resp => {
if (endpointAbortController.value) {
endpointAbortController.value.abort();
}
endpointAbortController.value = new AbortController();
misskeyApi('endpoint', { endpoint: endpoint.value }, withCredential.value ? undefined : null, endpointAbortController.value.signal).then(resp => {
const endpointBody = {};
for (const p of resp.params) {
endpointBody[p.name] =

View File

@ -349,6 +349,7 @@ definePageMetadata(() => ({
> .img {
width: 42px;
height: 42px;
object-fit: contain;
}
> .body {
@ -395,6 +396,7 @@ definePageMetadata(() => ({
> .img {
width: 32px;
height: 32px;
object-fit: contain;
}
> .body {

View File

@ -132,7 +132,9 @@ async function deleteAccount() {
{
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.deleteAccountConfirm,
text: i18n.ts.deleteAccountConfirmAndWarn,
okWaitInitiate: 'dialog',
okWaitDuration: 5,
});
if (canceled) return;
}
@ -147,6 +149,7 @@ async function deleteAccount() {
await os.alert({
title: i18n.ts._accountDelete.started,
text: i18n.ts._accountDelete.dontLogin,
});
await signout();

View File

@ -13,6 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="profile _gaps">
<MkAccountMoved v-if="user.movedTo" :movedTo="user.movedTo"/>
<MkAccountMoved v-if="movedFromLog" :movedFrom="movedFromLog[0]?.movedFromId"/>
<MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!" class="warn"/>
<div :key="user.id" class="main _panel">
@ -281,6 +282,7 @@ const memoDraft = ref(props.user.memo);
const isEditingMemo = ref(false);
const moderationNote = ref(props.user.moderationNote);
const editModerationNote = ref(false);
const movedFromLog = ref<null | {movedFromId:string;}[]>(null);
const hideModerationNote = !iAmModerator || (defaultStore.state.privateMode && defaultStore.state.hideModerationLog);
const hideRoleList = defaultStore.state.privateMode && defaultStore.state.hideRoleList;
@ -307,6 +309,15 @@ function menu(ev: MouseEvent) {
os.popupMenu(menu, ev.currentTarget ?? ev.target).finally(cleanup);
}
async function fetchMovedFromLog() {
if (!props.user.id) {
movedFromLog.value = null;
return;
}
movedFromLog.value = await misskeyApi('admin/show-user-account-move-logs', { movedToId: props.user.id });
}
function parallaxLoop() {
parallaxAnimationId.value = window.requestAnimationFrame(parallaxLoop);
parallax();
@ -383,6 +394,9 @@ function buildSkebStatus(): string {
watch([props.user], () => {
memoDraft.value = props.user.memo;
fetchSkebStatus();
if ($i?.isModerator) {
fetchMovedFromLog();
}
});
onMounted(() => {
@ -401,6 +415,9 @@ onMounted(() => {
}
}
fetchSkebStatus();
if ($i?.isModerator) {
fetchMovedFromLog();
}
nextTick(() => {
adjustMemoTextarea();
});

View File

@ -436,6 +436,10 @@ const routes: RouteDef[] = [{
path: '/modlog',
name: 'modlog',
component: page(() => import('@/pages/admin/modlog.vue')),
}, {
path: '/useraccountmovelog',
name: 'useraccountmovelog',
component: page(() => import('@/pages/admin/useraccountmovelog.vue')),
}, {
path: '/settings',
name: 'settings',

View File

@ -44,14 +44,15 @@ export function misskeyApi<
},
signal,
}).then(async (res) => {
const body = res.status === 204 ? null : await res.json();
if (res.status === 200) {
if (res.ok && res.status !== 204) {
const body = await res.json();
resolve(body);
} else if (res.status === 204) {
resolve(undefined as _ResT); // void -> undefined
} else {
reject(body.error);
// エラー応答で JSON.parse に失敗した場合は HTTP ステータスコードとメッセージを返す
const body = await res.json().catch(() => ({ statusCode: res.status, message: res.statusText }));
reject(typeof body.error === 'object' ? body.error : body);
}
}).catch(reject);
});

View File

@ -337,6 +337,12 @@ type AdminShowModerationLogsRequest = operations['admin___show-moderation-logs']
// @public (undocumented)
type AdminShowModerationLogsResponse = operations['admin___show-moderation-logs']['responses']['200']['content']['application/json'];
// @public (undocumented)
type AdminShowUserAccountMoveLogsRequest = operations['admin___show-user-account-move-logs']['requestBody']['content']['application/json'];
// @public (undocumented)
type AdminShowUserAccountMoveLogsResponse = operations['admin___show-user-account-move-logs']['responses']['200']['content']['application/json'];
// @public (undocumented)
type AdminShowUserRequest = operations['admin___show-user']['requestBody']['content']['application/json'];
@ -1295,6 +1301,8 @@ declare namespace entities {
AdminServerInfoResponse,
AdminShowModerationLogsRequest,
AdminShowModerationLogsResponse,
AdminShowUserAccountMoveLogsRequest,
AdminShowUserAccountMoveLogsResponse,
AdminShowUserRequest,
AdminShowUserResponse,
AdminShowUsersRequest,
@ -1795,6 +1803,7 @@ declare namespace entities {
User,
UserList,
UserListMembership,
UserAccountMoveLog,
Ad,
Announcement,
App,
@ -2774,7 +2783,7 @@ type PagesUpdateRequest = operations['pages___update']['requestBody']['content']
function parse(acct: string): Acct;
// @public (undocumented)
export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "read:admin:abuse-report-resolvers", "write:admin:abuse-report-resolvers", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "read:admin:show-users", "write:admin:suspend-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unset-user-mutual-link", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:indie-auth", "read:admin:indie-auth", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:sso", "read:admin:sso", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse"];
export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "read:admin:abuse-report-resolvers", "write:admin:abuse-report-resolvers", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-account-move-log", "read:admin:show-user", "read:admin:show-users", "write:admin:suspend-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unset-user-mutual-link", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:indie-auth", "read:admin:indie-auth", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:sso", "read:admin:sso", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse"];
// @public (undocumented)
type PingResponse = operations['ping']['responses']['200']['content']['application/json'];
@ -3073,6 +3082,9 @@ function toString_2(acct: Acct): string;
// @public (undocumented)
type User = components['schemas']['User'];
// @public (undocumented)
type UserAccountMoveLog = components['schemas']['UserAccountMoveLog'];
// @public (undocumented)
type UserDetailed = components['schemas']['UserDetailed'];

View File

@ -810,6 +810,17 @@ declare module '../api.js' {
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:admin:show-account-move-log*
*/
request<E extends 'admin/show-user-account-move-logs', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*

View File

@ -94,6 +94,8 @@ import type {
AdminServerInfoResponse,
AdminShowModerationLogsRequest,
AdminShowModerationLogsResponse,
AdminShowUserAccountMoveLogsRequest,
AdminShowUserAccountMoveLogsResponse,
AdminShowUserRequest,
AdminShowUserResponse,
AdminShowUsersRequest,
@ -660,6 +662,7 @@ export type Endpoints = {
'admin/send-email': { req: AdminSendEmailRequest; res: EmptyResponse };
'admin/server-info': { req: EmptyRequest; res: AdminServerInfoResponse };
'admin/show-moderation-logs': { req: AdminShowModerationLogsRequest; res: AdminShowModerationLogsResponse };
'admin/show-user-account-move-logs': { req: AdminShowUserAccountMoveLogsRequest; res: AdminShowUserAccountMoveLogsResponse };
'admin/show-user': { req: AdminShowUserRequest; res: AdminShowUserResponse };
'admin/show-users': { req: AdminShowUsersRequest; res: AdminShowUsersResponse };
'admin/suspend-user': { req: AdminSuspendUserRequest; res: EmptyResponse };

View File

@ -97,6 +97,8 @@ export type AdminSendEmailRequest = operations['admin___send-email']['requestBod
export type AdminServerInfoResponse = operations['admin___server-info']['responses']['200']['content']['application/json'];
export type AdminShowModerationLogsRequest = operations['admin___show-moderation-logs']['requestBody']['content']['application/json'];
export type AdminShowModerationLogsResponse = operations['admin___show-moderation-logs']['responses']['200']['content']['application/json'];
export type AdminShowUserAccountMoveLogsRequest = operations['admin___show-user-account-move-logs']['requestBody']['content']['application/json'];
export type AdminShowUserAccountMoveLogsResponse = operations['admin___show-user-account-move-logs']['responses']['200']['content']['application/json'];
export type AdminShowUserRequest = operations['admin___show-user']['requestBody']['content']['application/json'];
export type AdminShowUserResponse = operations['admin___show-user']['responses']['200']['content']['application/json'];
export type AdminShowUsersRequest = operations['admin___show-users']['requestBody']['content']['application/json'];

View File

@ -9,6 +9,7 @@ export type UserDetailed = components['schemas']['UserDetailed'];
export type User = components['schemas']['User'];
export type UserList = components['schemas']['UserList'];
export type UserListMembership = components['schemas']['UserListMembership'];
export type UserAccountMoveLog = components['schemas']['UserAccountMoveLog'];
export type Ad = components['schemas']['Ad'];
export type Announcement = components['schemas']['Announcement'];
export type App = components['schemas']['App'];

View File

@ -673,6 +673,15 @@ export type paths = {
*/
post: operations['admin___show-moderation-logs'];
};
'/admin/show-user-account-move-logs': {
/**
* admin/show-user-account-move-logs
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:admin:show-account-move-log*
*/
post: operations['admin___show-user-account-move-logs'];
};
'/admin/show-user': {
/**
* admin/show-user
@ -4126,6 +4135,21 @@ export type components = {
user: components['schemas']['UserLite'];
withReplies: boolean;
};
UserAccountMoveLog: {
/**
* Format: id
* @example xxxxxxxxxx
*/
id: string;
/** Format: date-time */
createdAt: string;
/** Format: id */
movedToId: string;
movedTo: components['schemas']['UserDetailed'];
/** Format: id */
movedFromId: string;
movedFrom: components['schemas']['UserDetailed'];
};
Ad: {
/**
* Format: id
@ -6217,6 +6241,8 @@ export type operations = {
userId: string | null;
user: components['schemas']['UserLite'] | null;
reads: number;
/** Format: date-time */
lastReadAt: string | null;
})[];
};
};
@ -9589,6 +9615,79 @@ export type operations = {
};
};
};
/**
* admin/show-user-account-move-logs
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:admin:show-account-move-log*
*/
'admin___show-user-account-move-logs': {
requestBody: {
content: {
'application/json': {
/** @default 10 */
limit?: number;
/** Format: misskey:id */
sinceId?: string;
/** Format: misskey:id */
untilId?: string;
/** Format: misskey:id */
movedFromId?: string | null;
/** Format: misskey:id */
movedToId?: string | null;
};
};
};
responses: {
/** @description OK (with results) */
200: {
content: {
'application/json': {
/** Format: id */
id: string;
/** Format: date-time */
createdAt: string;
/** Format: id */
movedToId: string;
movedTo: components['schemas']['UserDetailed'];
/** Format: id */
movedFromId: string;
movedFrom: 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/show-user
* @description No description provided.

View File

@ -57,6 +57,7 @@ export const permissions = [
'write:admin:send-email',
'read:admin:server-info',
'read:admin:show-moderation-log',
'read:admin:show-account-move-log',
'read:admin:show-user',
'read:admin:show-users',
'write:admin:suspend-user',