Merge upstream
This commit is contained in:
commit
4b6cbdab39
@ -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
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -11,7 +11,6 @@ docker-compose.yml
|
||||
node_modules/
|
||||
packages/*/node_modules
|
||||
redis/
|
||||
keydb/
|
||||
files/
|
||||
fluent-emojis/
|
||||
.pnp.*
|
||||
|
28
.github/unused/test-backend.yml
vendored
28
.github/unused/test-backend.yml
vendored
@ -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
1
.gitignore
vendored
@ -53,7 +53,6 @@ run.bat
|
||||
api-docs.json
|
||||
*.log
|
||||
/redis
|
||||
/keydb
|
||||
*.code-workspace
|
||||
.DS_Store
|
||||
/files
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
31
locales/index.d.ts
vendored
@ -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": {
|
||||
/**
|
||||
|
@ -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: "アカウントの整理"
|
||||
|
@ -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": "중재 기록 수정하기"
|
||||
|
@ -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"`);
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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',
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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配信
|
||||
|
@ -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';
|
||||
}
|
||||
|
||||
|
@ -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 }>();
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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'),
|
||||
|
43
packages/backend/src/misc/collapsed-queue.ts
Normal file
43
packages/backend/src/misc/collapsed-queue.ts
Normal 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)));
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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,
|
||||
|
35
packages/backend/src/models/UserAccountMoveLog.ts
Normal file
35
packages/backend/src/models/UserAccountMoveLog.ts
Normal 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;
|
||||
}
|
@ -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>;
|
||||
|
@ -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;
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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`);
|
||||
|
@ -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) {
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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';
|
||||
}
|
||||
}
|
@ -89,6 +89,10 @@ export type DbUserTruncateJobData = {
|
||||
purgeDrive: boolean;
|
||||
};
|
||||
|
||||
export type DbUserSuspendJobData = {
|
||||
user: ThinUser
|
||||
};
|
||||
|
||||
export type DbUserImportJobData = {
|
||||
user: ThinUser;
|
||||
fileId: MiDriveFile['id'];
|
||||
|
@ -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,
|
||||
|
@ -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],
|
||||
|
@ -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,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
@ -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"
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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') {
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -36,6 +36,7 @@ const emit = defineEmits<{
|
||||
.icon {
|
||||
display: block;
|
||||
width: 60px;
|
||||
max-height: 60px;
|
||||
font-size: 60px; // unicodeな絵文字についてはwidthが効かないため
|
||||
margin: 0 auto;
|
||||
object-fit: contain;
|
||||
|
@ -63,6 +63,7 @@ function getReactionName(reaction: string): string {
|
||||
.reactionIcon {
|
||||
display: block;
|
||||
width: 60px;
|
||||
max-height: 60px;
|
||||
font-size: 60px; // unicodeな絵文字についてはwidthが効かないため
|
||||
object-fit: contain;
|
||||
margin: 0 auto;
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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>,
|
||||
|
@ -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>
|
||||
|
@ -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">
|
||||
|
@ -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,
|
||||
|
98
packages/frontend/src/pages/admin/useraccountmovelog.vue
Normal file
98
packages/frontend/src/pages/admin/useraccountmovelog.vue
Normal 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>
|
@ -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] =
|
||||
|
@ -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 {
|
||||
|
@ -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();
|
||||
|
@ -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();
|
||||
});
|
||||
|
@ -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',
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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'];
|
||||
|
||||
|
@ -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.
|
||||
*
|
||||
|
@ -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 };
|
||||
|
@ -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'];
|
||||
|
@ -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'];
|
||||
|
@ -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.
|
||||
|
@ -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',
|
||||
|
Loading…
Reference in New Issue
Block a user