Merge branch 'develop' into notes-embed

This commit is contained in:
かっこかり 2023-04-16 21:40:08 +09:00 committed by GitHub
commit adb12dea62
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
182 changed files with 4546 additions and 1343 deletions

View File

@ -4,7 +4,10 @@
"service": "app", "service": "app",
"workspaceFolder": "/workspace", "workspaceFolder": "/workspace",
"features": { "features": {
"ghcr.io/devcontainers-contrib/features/pnpm:2": {} "ghcr.io/devcontainers-contrib/features/pnpm:2": {},
"ghcr.io/devcontainers/features/node:1": {
"version": "18.16.0"
}
}, },
"forwardPorts": [3000], "forwardPorts": [3000],
"postCreateCommand": "sudo chmod 755 .devcontainer/init.sh && .devcontainer/init.sh", "postCreateCommand": "sudo chmod 755 .devcontainer/init.sh && .devcontainer/init.sh",

View File

@ -16,7 +16,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v3.6.0 uses: actions/setup-node@v3.6.0
with: with:
node-version: 18.x node-version-file: '.node-version'
cache: 'pnpm' cache: 'pnpm'
- name: Install dependencies - name: Install dependencies

View File

@ -21,7 +21,7 @@ jobs:
run_install: false run_install: false
- uses: actions/setup-node@v3.6.0 - uses: actions/setup-node@v3.6.0
with: with:
node-version: 18.x node-version-file: '.node-version'
cache: 'pnpm' cache: 'pnpm'
- run: corepack enable - run: corepack enable
- run: pnpm i --frozen-lockfile - run: pnpm i --frozen-lockfile
@ -48,7 +48,7 @@ jobs:
run_install: false run_install: false
- uses: actions/setup-node@v3.6.0 - uses: actions/setup-node@v3.6.0
with: with:
node-version: 18.x node-version-file: '.node-version'
cache: 'pnpm' cache: 'pnpm'
- run: corepack enable - run: corepack enable
- run: pnpm i --frozen-lockfile - run: pnpm i --frozen-lockfile
@ -74,7 +74,7 @@ jobs:
run_install: false run_install: false
- uses: actions/setup-node@v3.6.0 - uses: actions/setup-node@v3.6.0
with: with:
node-version: 18.x node-version-file: '.node-version'
cache: 'pnpm' cache: 'pnpm'
- run: corepack enable - run: corepack enable
- run: pnpm i --frozen-lockfile - run: pnpm i --frozen-lockfile

View File

@ -25,7 +25,7 @@ jobs:
- name: Use Node.js 18.x - name: Use Node.js 18.x
uses: actions/setup-node@v3.6.0 uses: actions/setup-node@v3.6.0
with: with:
node-version: 18.x node-version-file: '.node-version'
cache: 'pnpm' cache: 'pnpm'
- run: corepack enable - run: corepack enable
- run: pnpm i --frozen-lockfile - run: pnpm i --frozen-lockfile

View File

@ -1 +1 @@
v18.13.0 18.16.0

View File

@ -11,6 +11,79 @@
- -
--> -->
## 13.x.x (unreleased)
### General
- ユーザーへの自分用メモ機能
* ユーザーに対して、自分だけが見られるメモを追加できるようになりました。
(自分自身に対してもメモを追加できます。)
* ユーザーメニューから追加できます。
デスクトップ表示ではusernameの右側のボタンからも追加可能
### Client
- コントロールパネルのカスタム絵文字ページおよびaboutのカスタム絵文字の検索インプットで、`:emojiname1::emojiname2:`のように検索して絵文字を検索できるように
* 絵文字ピッカーから入力可能になります
- データセーバーモードを追加
* 画像が全て隠れた状態で表示されるようになります
- 1枚だけのメディアリストの画像のアスペクト比を画像に応じて縦長にするように
- Fix: リアクションをホバーした時のユーザーリストで猫耳が切れてしまっていた問題を修正
### Server
- Fix: エクスポートデータの拡張子がunknownになる問題を修正
- Fix: Content-Dispositionのパースでエラーが発生した場合にダウンロードが完了しない問題を修正
- Fix: API: i/update avatarIdとbannerIdにnullを渡した時、画像がリセットされない問題を修正
## 13.11.3
### General
- 指定したロールを持つユーザーのノートのみが流れるロールタイムラインを追加
- Deckのカラムとしても追加可能
- カスタム絵文字関連の改善
* ートなどに含まれるemojispopulateEmojiの結果プロキシされたURLではなくオリジナルのURLを指すように
* MFMでx3/x4もしくはscale.x/yが2.5以上に指定されていた場合にはオリジナル品質の絵文字を使用するように
- カスタム絵文字でリアクションできないことがある問題を修正
### Client
- チャンネルのピン留めされたノートの順番が正しくない問題を修正
### Server
- フォローインポートなどでの大量のフォロー等操作をキューイングするように #10544 @nmkj-io
- Misskey Webでのサーバーサイドエラー画面を改善
- Misskey Webでのサーバーサイドエラーのログが残るように
- ノート作成時のアンテナ追加パフォーマンスを改善
- アンテナとロールTLのuntil/sinceプロパティが動くように
## 13.11.2
### Note
- 13.11.0または13.11.1から13.11.2以降にアップデートする場合、Redisのカスタム絵文字のキャッシュを削除する必要があります(https://github.com/misskey-dev/misskey/issues/10502#issuecomment-1502790755 参照)
### General
- チャンネルの検索用ページの追加
### Client
- 常に広告を見られるオプションを追加
- ユーザーページの画像一覧が表示されない問題を修正
- webhook, 連携アプリ一覧でコンテンツが重複して表示される問題を修正
- iPhoneで絵文字ピッカーの表示が崩れる問題を修正
- iPhoneでウィジェットドロワーの「ウィジェットを編集」が押しにくい問題を修正
- 投稿フォームのデザインを調整
- ギャラリーの人気の投稿が無限にページングされる問題を修正
### Server
- channels/search Endpoint APIの追加
- APIパラメータサイズ上限を32kbから1mbに緩和
- プッシュ通知送信時のパフォーマンスを改善
- ローカルのカスタム絵文字のキャッシュが効いていなかった問題を修正
- アンテナのノート、チャンネルのノート、通知が正常に作成できないことがある問題を修正
- ストリーミングのLTLチャンネルでサーバー側にエラーログが出るのを修正
### Service Worker
- 「通知が既読になったらプッシュ通知を削除する」を復活
* 「プッシュ通知が更新されました」の挙動を変えた(ホストとバージョンを表示するようにし、一定時間後の削除は行わないように)
- プッシュ通知が実績を解除 (achievementEarned) に対応
- プッシュ通知のアクションから既存のクライアントの投稿フォームを開くことになった際の挙動を修正
- たくさんのプッシュ通知を閉じた際、その通知の数だけnotifications/mark-all-as-readを叩くのをやめるように
## 13.11.1 ## 13.11.1
@ -55,6 +128,8 @@
- 猫耳のアバター内部部分をぼかしでマスク表示してより猫耳っぽく見えるように - 猫耳のアバター内部部分をぼかしでマスク表示してより猫耳っぽく見えるように
- 「UIのアニメーションを減らす」 (`reduceAnimation`) で猫耳を撫でられなくなります - 「UIのアニメーションを減らす」 (`reduceAnimation`) で猫耳を撫でられなくなります
- Add Minimizing ("folding") of windows - Add Minimizing ("folding") of windows
- 「データセーバー」モードを追加
- 非NSFWメディアが隠れている際にも「閲覧注意」が出てしまう問題を修正
### Server ### Server
- PostgreSQLのレプリケーション対応 - PostgreSQLのレプリケーション対応

View File

@ -245,7 +245,6 @@ You can override the default story by creating a impl story file (`MyComponent.s
```ts ```ts
/* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-duplicates */
import { StoryObj } from '@storybook/vue3'; import { StoryObj } from '@storybook/vue3';
import MyComponent from './MyComponent.vue'; import MyComponent from './MyComponent.vue';
export const Default = { export const Default = {

View File

@ -1,6 +1,6 @@
# syntax = docker/dockerfile:1.4 # syntax = docker/dockerfile:1.4
ARG NODE_VERSION=18.13.0-bullseye ARG NODE_VERSION=18.16.0-bullseye
# build assets & compile TypeScript # build assets & compile TypeScript

View File

@ -53,6 +53,7 @@ gulp.task('build:backend:style', () => {
'./packages/backend/src/server/web/style.css', './packages/backend/src/server/web/style.css',
'./packages/backend/src/server/web/bios.css', './packages/backend/src/server/web/bios.css',
'./packages/backend/src/server/web/cli.css', './packages/backend/src/server/web/cli.css',
'./packages/backend/src/server/web/error.css',
'./packages/backend/src/server/web/embed.css' './packages/backend/src/server/web/embed.css'
]) ])
.pipe(cssnano({ .pipe(cssnano({

View File

@ -20,6 +20,7 @@ noNotes: "Keine Notizen gefunden"
noNotifications: "Keine Benachrichtigungen gefunden" noNotifications: "Keine Benachrichtigungen gefunden"
instance: "Instanz" instance: "Instanz"
settings: "Einstellungen" settings: "Einstellungen"
notificationSettings: "Benachrichtigungseinstellungen"
basicSettings: "Allgemeine Einstellungen" basicSettings: "Allgemeine Einstellungen"
otherSettings: "Weitere Einstellungen" otherSettings: "Weitere Einstellungen"
openInWindow: "In einem Fenster öffnen" openInWindow: "In einem Fenster öffnen"
@ -991,6 +992,7 @@ largeNoteReactions: "Reaktionen vergrößert anzeigen"
noteIdOrUrl: "Notiz-ID oder URL" noteIdOrUrl: "Notiz-ID oder URL"
accountMigration: "Konto-Umzug" accountMigration: "Konto-Umzug"
accountMoved: "Dieser Benutzer ist zu einem neuen Konto umgezogen:" accountMoved: "Dieser Benutzer ist zu einem neuen Konto umgezogen:"
forceShowAds: "Werbung immer anzeigen"
_accountMigration: _accountMigration:
moveTo: "Dieses Konto zu einem neuen umziehen" moveTo: "Dieses Konto zu einem neuen umziehen"
moveToLabel: "Umzugsziel:" moveToLabel: "Umzugsziel:"
@ -1406,6 +1408,8 @@ _channel:
following: "Gefolgt" following: "Gefolgt"
usersCount: "{n} Teilnehmer" usersCount: "{n} Teilnehmer"
notesCount: "{n} Notizen" notesCount: "{n} Notizen"
nameAndDescription: "Name und Beschreibung"
nameOnly: "Nur Name"
_menuDisplay: _menuDisplay:
sideFull: "Seitlich" sideFull: "Seitlich"
sideIcon: "Seitlich (Icons)" sideIcon: "Seitlich (Icons)"
@ -1886,6 +1890,7 @@ _deck:
channel: "Kanal" channel: "Kanal"
mentions: "Erwähnungen" mentions: "Erwähnungen"
direct: "Direktnachrichten" direct: "Direktnachrichten"
roleTimeline: "Rollenchronik"
_dialog: _dialog:
charactersExceeded: "Maximallänge überschritten! Momentan {current} von {max}" charactersExceeded: "Maximallänge überschritten! Momentan {current} von {max}"
charactersBelow: "Minimallänge unterschritten! Momentan {current} von {min}" charactersBelow: "Minimallänge unterschritten! Momentan {current} von {min}"

View File

@ -20,6 +20,7 @@ noNotes: "No notes"
noNotifications: "No notifications" noNotifications: "No notifications"
instance: "Instance" instance: "Instance"
settings: "Settings" settings: "Settings"
notificationSettings: "Notification Settings"
basicSettings: "Basic Settings" basicSettings: "Basic Settings"
otherSettings: "Other Settings" otherSettings: "Other Settings"
openInWindow: "Open in window" openInWindow: "Open in window"
@ -67,7 +68,7 @@ import: "Import"
export: "Export" export: "Export"
files: "Files" files: "Files"
download: "Download" download: "Download"
driveFileDeleteConfirm: "Are you sure you want to delete \"{name}\"? All notes with this file attached will also be deleted." driveFileDeleteConfirm: "Are you sure you want to delete \"{name}\"? It will also vanish from all contents that use it."
unfollowConfirm: "Are you sure you want to unfollow {name}?" unfollowConfirm: "Are you sure you want to unfollow {name}?"
exportRequested: "You've requested an export. This may take a while. It will be added to your Drive once completed." exportRequested: "You've requested an export. This may take a while. It will be added to your Drive once completed."
importRequested: "You've requested an import. This may take a while." importRequested: "You've requested an import. This may take a while."
@ -500,7 +501,7 @@ objectStoragePrefixDesc: "Files will be stored under directories with this prefi
objectStorageEndpoint: "Endpoint" objectStorageEndpoint: "Endpoint"
objectStorageEndpointDesc: "Leave this empty if you are using AWS S3, otherwise specify the endpoint as '<host>' or '<host>:<port>', depending on the service you are using." objectStorageEndpointDesc: "Leave this empty if you are using AWS S3, otherwise specify the endpoint as '<host>' or '<host>:<port>', depending on the service you are using."
objectStorageRegion: "Region" objectStorageRegion: "Region"
objectStorageRegionDesc: "Specify a region like 'xx-east-1'. If your service does not distinguish between regions, leave this blank or enter 'us-east-1'." objectStorageRegionDesc: "Specify a region like 'xx-east-1'. If your service does not distinguish between regions, enter 'us-east-1'. Leave empty if using AWS configuration files or environment variables."
objectStorageUseSSL: "Use SSL" objectStorageUseSSL: "Use SSL"
objectStorageUseSSLDesc: "Turn this off if you are not going to use HTTPS for API connections" objectStorageUseSSLDesc: "Turn this off if you are not going to use HTTPS for API connections"
objectStorageUseProxy: "Connect over Proxy" objectStorageUseProxy: "Connect over Proxy"
@ -918,7 +919,7 @@ unsubscribePushNotification: "Disable push notifications"
pushNotificationAlreadySubscribed: "Push notifications are already enabled" pushNotificationAlreadySubscribed: "Push notifications are already enabled"
pushNotificationNotSupported: "Your browser or instance does not support push notifications" pushNotificationNotSupported: "Your browser or instance does not support push notifications"
sendPushNotificationReadMessage: "Delete push notifications once the relevant notifications or messages have been read" sendPushNotificationReadMessage: "Delete push notifications once the relevant notifications or messages have been read"
sendPushNotificationReadMessageCaption: "A notification containing the text \"{emptyPushNotificationMessage}\" will be displayed for a short time. This may increase the battery usage of your device, if applicable." sendPushNotificationReadMessageCaption: "A notification containing the text \"{emptyPushNotificationMessage}\" will be displayed for a short time. This may increase the power consumption of your device."
windowMaximize: "Maximize" windowMaximize: "Maximize"
windowMinimize: "Minimize" windowMinimize: "Minimize"
windowRestore: "Restore" windowRestore: "Restore"
@ -991,6 +992,7 @@ largeNoteReactions: "Enlargen displayed reactions"
noteIdOrUrl: "Note ID or URL" noteIdOrUrl: "Note ID or URL"
accountMigration: "Account Migration" accountMigration: "Account Migration"
accountMoved: "This user has moved to a new account:" accountMoved: "This user has moved to a new account:"
forceShowAds: "Always show ads"
_accountMigration: _accountMigration:
moveTo: "Migrate this account to a different one" moveTo: "Migrate this account to a different one"
moveToLabel: "Account to move to:" moveToLabel: "Account to move to:"
@ -1406,6 +1408,8 @@ _channel:
following: "Followed" following: "Followed"
usersCount: "{n} Participants" usersCount: "{n} Participants"
notesCount: "{n} Notes" notesCount: "{n} Notes"
nameAndDescription: "Name and description"
nameOnly: "Name only"
_menuDisplay: _menuDisplay:
sideFull: "Side" sideFull: "Side"
sideIcon: "Side (Icons)" sideIcon: "Side (Icons)"
@ -1868,7 +1872,7 @@ _deck:
swapRight: "Swap with the right column" swapRight: "Swap with the right column"
swapUp: "Swap with the above column" swapUp: "Swap with the above column"
swapDown: "Swap with the below column" swapDown: "Swap with the below column"
stackLeft: "Stack with the left column" stackLeft: "Stack on left column"
popRight: "Pop column to the right" popRight: "Pop column to the right"
profile: "Profile" profile: "Profile"
newProfile: "New profile" newProfile: "New profile"
@ -1886,6 +1890,7 @@ _deck:
channel: "Channel" channel: "Channel"
mentions: "Mentions" mentions: "Mentions"
direct: "Direct notes" direct: "Direct notes"
roleTimeline: "Role Timeline"
_dialog: _dialog:
charactersExceeded: "You've exceeded the maximum character limit! Currently at {current} of {max}." charactersExceeded: "You've exceeded the maximum character limit! Currently at {current} of {max}."
charactersBelow: "You're below the minimum character limit! Currently at {current} of {min}." charactersBelow: "You're below the minimum character limit! Currently at {current} of {min}."

View File

@ -122,6 +122,8 @@ unmarkAsSensitive: "Hapus tanda konten sensitif"
enterFileName: "Masukkan nama berkas" enterFileName: "Masukkan nama berkas"
mute: "Bisukan" mute: "Bisukan"
unmute: "Hapus bisukan" unmute: "Hapus bisukan"
renoteMute: "Matikan renote"
renoteUnmute: "Batal mematikan renote"
block: "Blokir" block: "Blokir"
unblock: "Buka blokir" unblock: "Buka blokir"
suspend: "Bekukan" suspend: "Bekukan"
@ -393,11 +395,15 @@ about: "Informasi"
aboutMisskey: "Tentang Misskey" aboutMisskey: "Tentang Misskey"
administrator: "Admin" administrator: "Admin"
token: "Token" token: "Token"
totp: "Aplikasi autentikator"
totpDescription: "Gunakan aplikasi autentikator untuk mendapatkan kata sandi sekali pakai"
moderator: "Moderator" moderator: "Moderator"
moderation: "Moderasi" moderation: "Moderasi"
nUsersMentioned: "{n} pengguna disebut" nUsersMentioned: "{n} pengguna disebut"
securityKeyAndPasskey: "Security key dan passkey"
securityKey: "Kunci keamanan" securityKey: "Kunci keamanan"
lastUsed: "Terakhir digunakan" lastUsed: "Terakhir digunakan"
lastUsedAt: "Penggunaan terakhir: {t}"
unregister: "Batalkan pendaftaran" unregister: "Batalkan pendaftaran"
passwordLessLogin: "Setel login tanpa kata sandi" passwordLessLogin: "Setel login tanpa kata sandi"
resetPassword: "Atur ulang kata sandi" resetPassword: "Atur ulang kata sandi"
@ -844,6 +850,7 @@ tenMinutes: "10 Menit"
oneHour: "1 Jam" oneHour: "1 Jam"
oneDay: "1 Hari" oneDay: "1 Hari"
oneWeek: "1 Bulan" oneWeek: "1 Bulan"
oneMonth: "satu bulan"
reflectMayTakeTime: "Mungkin perlu beberapa saat untuk dicerminkan." reflectMayTakeTime: "Mungkin perlu beberapa saat untuk dicerminkan."
failedToFetchAccountInformation: "Gagal untuk mendapatkan informasi akun" failedToFetchAccountInformation: "Gagal untuk mendapatkan informasi akun"
rateLimitExceeded: "Batas sudah terlampaui" rateLimitExceeded: "Batas sudah terlampaui"
@ -901,6 +908,7 @@ pushNotificationNotSupported: "Browser atau instansi kamu tidak mendukung pember
sendPushNotificationReadMessage: "Hapus pemberitahuan push ketika pemberitahuan relevan atau pesan telah dibaca" sendPushNotificationReadMessage: "Hapus pemberitahuan push ketika pemberitahuan relevan atau pesan telah dibaca"
sendPushNotificationReadMessageCaption: "Pemberitahuan berisi teks「{emptyPushNotificationMessage}」akan ditampilkan dalam waktu pendek. Ini mungkin dapat menambah pemakaian baterai pada perangkat kamu." sendPushNotificationReadMessageCaption: "Pemberitahuan berisi teks「{emptyPushNotificationMessage}」akan ditampilkan dalam waktu pendek. Ini mungkin dapat menambah pemakaian baterai pada perangkat kamu."
windowMaximize: "Maksimalkan" windowMaximize: "Maksimalkan"
windowMinimize: "Minimalkan"
windowRestore: "Kembalikan" windowRestore: "Kembalikan"
caption: "Keterangan" caption: "Keterangan"
loggedInAsBot: "Sedang login sebagai bot" loggedInAsBot: "Sedang login sebagai bot"
@ -939,6 +947,12 @@ collapseRenotes: "Tutup renote yang sudah kamu lihat"
internalServerError: "Kesalahan internal peladen" internalServerError: "Kesalahan internal peladen"
internalServerErrorDescription: "Peladen sedang mengalami galat tak terduga" internalServerErrorDescription: "Peladen sedang mengalami galat tak terduga"
copyErrorInfo: "Salin detil galat" copyErrorInfo: "Salin detil galat"
joinThisServer: "Gabung server ini"
exploreOtherServers: "Cari server lain"
letsLookAtTimeline: "LIhat timeline"
disableFederationConfirm: "Matikan federasi?"
disableFederationConfirmWarn: "Mematikan federasi tidak membuat kiriman menjadi privat. Umumnya, mematikan federasi tidak diperlukan."
disableFederationOk: "Matikan federasi"
_achievements: _achievements:
earnedAt: "Terbuka pada" earnedAt: "Terbuka pada"
_types: _types:

View File

@ -20,6 +20,7 @@ noNotes: "Nessuna nota!"
noNotifications: "Nessuna notifica" noNotifications: "Nessuna notifica"
instance: "Istanza" instance: "Istanza"
settings: "Impostazioni" settings: "Impostazioni"
notificationSettings: "Preferenze di notifica"
basicSettings: "Impostazioni generali" basicSettings: "Impostazioni generali"
otherSettings: "Altre impostazioni" otherSettings: "Altre impostazioni"
openInWindow: "Apri in una finestra" openInWindow: "Apri in una finestra"
@ -786,7 +787,7 @@ gallery: "Galleria"
recentPosts: "Le più recenti" recentPosts: "Le più recenti"
popularPosts: "Le più visualizzate" popularPosts: "Le più visualizzate"
shareWithNote: "Condividere in nota" shareWithNote: "Condividere in nota"
ads: "Pubblicità" ads: "Banner"
expiration: "Scadenza" expiration: "Scadenza"
startingperiod: "Periodo di inizio" startingperiod: "Periodo di inizio"
memo: "Promemoria" memo: "Promemoria"
@ -991,6 +992,7 @@ largeNoteReactions: "Ingrandisci le reazioni"
noteIdOrUrl: "ID della Nota o URL" noteIdOrUrl: "ID della Nota o URL"
accountMigration: "Migrazione del profilo" accountMigration: "Migrazione del profilo"
accountMoved: "Questo profilo ha migrato altrove:" accountMoved: "Questo profilo ha migrato altrove:"
forceShowAds: "Mostra sempre i banner"
_accountMigration: _accountMigration:
moveTo: "Migrare questo profilo verso un un altro" moveTo: "Migrare questo profilo verso un un altro"
moveToLabel: "Profilo verso cui migrare" moveToLabel: "Profilo verso cui migrare"
@ -1406,6 +1408,8 @@ _channel:
following: "Seguiti" following: "Seguiti"
usersCount: "{n} partecipanti" usersCount: "{n} partecipanti"
notesCount: "{n} note" notesCount: "{n} note"
nameAndDescription: "Nome e descrizione"
nameOnly: "Solo il nome"
_menuDisplay: _menuDisplay:
sideFull: "Laterale" sideFull: "Laterale"
sideIcon: "Laterale (solo icone)" sideIcon: "Laterale (solo icone)"

View File

@ -20,6 +20,7 @@ noNotes: "ノートはありません"
noNotifications: "通知はありません" noNotifications: "通知はありません"
instance: "サーバー" instance: "サーバー"
settings: "設定" settings: "設定"
notificationSettings: "通知の設定"
basicSettings: "基本設定" basicSettings: "基本設定"
otherSettings: "その他の設定" otherSettings: "その他の設定"
openInWindow: "ウィンドウで開く" openInWindow: "ウィンドウで開く"
@ -270,6 +271,7 @@ home: "ホーム"
remoteUserCaution: "リモートユーザーのため、情報が不完全です。" remoteUserCaution: "リモートユーザーのため、情報が不完全です。"
activity: "アクティビティ" activity: "アクティビティ"
images: "画像" images: "画像"
image: "画像"
birthday: "誕生日" birthday: "誕生日"
yearsOld: "{age}歳" yearsOld: "{age}歳"
registeredDate: "登録日" registeredDate: "登録日"
@ -473,6 +475,8 @@ createAccount: "アカウントを作成"
existingAccount: "既存のアカウント" existingAccount: "既存のアカウント"
regenerate: "再生成" regenerate: "再生成"
fontSize: "フォントサイズ" fontSize: "フォントサイズ"
mediaListWithOneImageAppearance: "画像が1枚のみのメディアリストの高さ"
limitTo: "{x}を上限に"
noFollowRequests: "フォロー申請はありません" noFollowRequests: "フォロー申請はありません"
openImageInNewTab: "画像を新しいタブで開く" openImageInNewTab: "画像を新しいタブで開く"
dashboard: "ダッシュボード" dashboard: "ダッシュボード"
@ -917,8 +921,8 @@ subscribePushNotification: "プッシュ通知を有効化"
unsubscribePushNotification: "プッシュ通知を停止する" unsubscribePushNotification: "プッシュ通知を停止する"
pushNotificationAlreadySubscribed: "プッシュ通知は有効です" pushNotificationAlreadySubscribed: "プッシュ通知は有効です"
pushNotificationNotSupported: "ブラウザかサーバーがプッシュ通知に非対応" pushNotificationNotSupported: "ブラウザかサーバーがプッシュ通知に非対応"
sendPushNotificationReadMessage: "通知やメッセージが既読になったらプッシュ通知を削除する" sendPushNotificationReadMessage: "通知が既読になったらプッシュ通知を削除する"
sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」という通知が一瞬表示されるようになります。端末の電池消費量が増加する可能性があります。" sendPushNotificationReadMessageCaption: "端末の電池消費量が増加する可能性があります。"
windowMaximize: "最大化" windowMaximize: "最大化"
windowMinimize: "最小化" windowMinimize: "最小化"
windowRestore: "元に戻す" windowRestore: "元に戻す"
@ -989,9 +993,15 @@ enableChartsForFederatedInstances: "リモートサーバーのチャートを
showClipButtonInNoteFooter: "ノートのアクションにクリップを追加" showClipButtonInNoteFooter: "ノートのアクションにクリップを追加"
largeNoteReactions: "ノートのリアクションを大きく表示" largeNoteReactions: "ノートのリアクションを大きく表示"
noteIdOrUrl: "ートIDまたはURL" noteIdOrUrl: "ートIDまたはURL"
video: "動画"
videos: "動画"
dataSaver: "データセーバー"
accountMigration: "アカウントの引っ越し" accountMigration: "アカウントの引っ越し"
accountMoved: "このユーザーは新しいアカウントに引っ越しました:" accountMoved: "このユーザーは新しいアカウントに引っ越しました:"
copyEmbedCode: "埋め込みコードをコピー" copyEmbedCode: "埋め込みコードをコピー"
forceShowAds: "常に広告を表示する"
addMemo: "メモを追加"
editMemo: "メモを編集"
_accountMigration: _accountMigration:
moveTo: "このアカウントを新しいアカウントに引っ越す" moveTo: "このアカウントを新しいアカウントに引っ越す"
@ -1427,6 +1437,8 @@ _channel:
following: "フォロー中" following: "フォロー中"
usersCount: "{n}人が参加中" usersCount: "{n}人が参加中"
notesCount: "{n}投稿があります" notesCount: "{n}投稿があります"
nameAndDescription: "名前と説明"
nameOnly: "名前のみ"
_menuDisplay: _menuDisplay:
sideFull: "横" sideFull: "横"
@ -1940,6 +1952,7 @@ _deck:
channel: "チャンネル" channel: "チャンネル"
mentions: "あなた宛て" mentions: "あなた宛て"
direct: "ダイレクト" direct: "ダイレクト"
roleTimeline: "ロールタイムライン"
_dialog: _dialog:
charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}" charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}"

View File

@ -20,6 +20,7 @@ noNotes: "ノートはあらへん"
noNotifications: "通知はあらへん" noNotifications: "通知はあらへん"
instance: "サーバー" instance: "サーバー"
settings: "設定" settings: "設定"
notificationSettings: "通知の設定"
basicSettings: "基本設定" basicSettings: "基本設定"
otherSettings: "ほかの設定" otherSettings: "ほかの設定"
openInWindow: "ウィンドウで開くで" openInWindow: "ウィンドウで開くで"
@ -991,6 +992,7 @@ largeNoteReactions: "ノートのリアクションを大きする"
noteIdOrUrl: "ートIDかURL" noteIdOrUrl: "ートIDかURL"
accountMigration: "アカウントのお引っ越し" accountMigration: "アカウントのお引っ越し"
accountMoved: "このユーザーはさらのアカウントに引っ越したで:" accountMoved: "このユーザーはさらのアカウントに引っ越したで:"
forceShowAds: "常に広告を表示しとく"
_accountMigration: _accountMigration:
moveTo: "このアカウントをさらのアカウントに引っ越すで" moveTo: "このアカウントをさらのアカウントに引っ越すで"
moveToLabel: "引っ越し先のアカウント:" moveToLabel: "引っ越し先のアカウント:"
@ -1406,6 +1408,8 @@ _channel:
following: "フォロー中やで" following: "フォロー中やで"
usersCount: "{n}人が参加中やで" usersCount: "{n}人が参加中やで"
notesCount: "{n}こ投稿があるで" notesCount: "{n}こ投稿があるで"
nameAndDescription: "名前と説明"
nameOnly: "名前だけ"
_menuDisplay: _menuDisplay:
sideFull: "横" sideFull: "横"
sideIcon: "横(アイコン)" sideIcon: "横(アイコン)"
@ -1886,6 +1890,7 @@ _deck:
channel: "チャンネル" channel: "チャンネル"
mentions: "あんた宛て" mentions: "あんた宛て"
direct: "ダイレクト" direct: "ダイレクト"
roleTimeline: "ロールタイムライン"
_dialog: _dialog:
charactersExceeded: "最大の文字数を上回っとるで!今は {current} / 最大でも {max}" charactersExceeded: "最大の文字数を上回っとるで!今は {current} / 最大でも {max}"
charactersBelow: "最小の文字数を下回っとるで!今は {current} / 最低でも {min}" charactersBelow: "最小の文字数を下回っとるで!今は {current} / 最低でも {min}"

View File

@ -163,11 +163,15 @@ instanceInfo: "ອີນສະແຕນ"
statistics: "ສະຖິຕິ" statistics: "ສະຖິຕິ"
clearQueue: "ລ້າງຄິວ" clearQueue: "ລ້າງຄິວ"
clearCachedFiles: "ລຶບລ້າງແຄສ" clearCachedFiles: "ລຶບລ້າງແຄສ"
noUsers: "ບໍ່ພົບຜູ້ໃຊ້"
editProfile: "ແກ້ໄຂໂປຣໄຟລ໌" editProfile: "ແກ້ໄຂໂປຣໄຟລ໌"
done: "ສຳເລັດ" done: "ສຳເລັດ"
processing: "ກຳລັງປະມວນຜົນ" processing: "ກຳລັງປະມວນຜົນ"
preview: "ສະແດງເປັນຕົວຢ່າງ" preview: "ສະແດງເປັນຕົວຢ່າງ"
default: "ຄ່າເລີ່ມຕົ້ນ" default: "ຄ່າເລີ່ມຕົ້ນ"
defaultValueIs: "ຄ່າເລີ່ມຕົ້ນ: {value}"
noCustomEmojis: "ບໍ່ມີອີໂມຈິ"
noJobs: "ບໍ່ມີຊິ້ນວຽກ"
federating: "ສະຫະພັນ" federating: "ສະຫະພັນ"
blocked: "ບລັອກແລ້ວ " blocked: "ບລັອກແລ້ວ "
suspended: "ໂຈະ" suspended: "ໂຈະ"
@ -182,6 +186,9 @@ changePassword: "ປ່ຽນ​ລະ​ຫັດ​ຜ່ານ"
security: "ຄວາມປອດໄພ" security: "ຄວາມປອດໄພ"
retypedNotMatch: "ວັດສະດຸປ້ອນບໍ່ກົງກັນ" retypedNotMatch: "ວັດສະດຸປ້ອນບໍ່ກົງກັນ"
currentPassword: "ລະຫັດຜ່ານປະຈຸບັນ" currentPassword: "ລະຫັດຜ່ານປະຈຸບັນ"
newPassword: "ລະຫັດຜ່ານໃໝ່"
newPasswordRetype: "ໃສ່ລະຫັດຜ່ານໃໝ່ອີກເທື່ອໜຶ່ງ"
attachFile: "ແນບໄຟລ໌"
more: "ເພີ່ມເຕີມ!" more: "ເພີ່ມເຕີມ!"
featured: "ໄຮໄລທ໌" featured: "ໄຮໄລທ໌"
usernameOrUserId: "ຊື່ຜູ້ໃຊ້ ຫຼື id ຜູ້ໃຊ້" usernameOrUserId: "ຊື່ຜູ້ໃຊ້ ຫຼື id ຜູ້ໃຊ້"
@ -196,25 +203,31 @@ saved: "ບັນທຶກແລ້ວ"
messaging: "ແຊ໋ດ" messaging: "ແຊ໋ດ"
upload: "ອັບໂຫຼດ" upload: "ອັບໂຫຼດ"
keepOriginalUploading: "ຮັກສາຮູບພາບຕົ້ນສະບັບ" keepOriginalUploading: "ຮັກສາຮູບພາບຕົ້ນສະບັບ"
fromDrive: "ຈາກ Drive"
fromUrl: "ຈາກ URL" fromUrl: "ຈາກ URL"
uploadFromUrl: "ອັບໂຫຼດຈາກ URL" uploadFromUrl: "ອັບໂຫຼດຈາກ URL"
uploadFromUrlDescription: "URL ຂອງໄຟລ໌ທີ່ທ່ານຕ້ອງການອັບໂຫລດ" uploadFromUrlDescription: "URL ຂອງໄຟລ໌ທີ່ທ່ານຕ້ອງການອັບໂຫລດ"
uploadFromUrlRequested: "ຮ້ອງຂໍການອັບໂຫລດ"
messageRead: "ອ່ານແລ້ວ" messageRead: "ອ່ານແລ້ວ"
startMessaging: "ເລີ່ມການສົນທະນາໃໝ່" startMessaging: "ເລີ່ມການສົນທະນາໃໝ່"
nUsersRead: "ອ່ານໂດຍ {n}" nUsersRead: "ອ່ານໂດຍ {n}"
tos: "ເງື່ອນໄຂການໃຫ້ບໍລິການ" tos: "ເງື່ອນໄຂການໃຫ້ບໍລິການ"
start: "ເລີ່ມຕົ້ນນຳໃຊ້ເລີຍ" start: "ເລີ່ມຕົ້ນນຳໃຊ້ເລີຍ"
home: "ໜ້າຫຼັກ" home: "ໜ້າຫຼັກ"
activity: "ກິດຈະກຳ"
images: "ຮູບພາບ" images: "ຮູບພາບ"
birthday: "ວັນເກີດ" birthday: "ວັນເກີດ"
yearsOld: "{age} ປີ" yearsOld: "{age} ປີ"
registeredDate: "ວັນທີ່ເປັນສະມາຊິກ" registeredDate: "ວັນທີ່ເປັນສະມາຊິກ"
location: "ທີ່ຕັ້ງ" location: "ທີ່ຕັ້ງ"
theme: "ແທ໋ມ" theme: "ແທ໋ມ"
themeForLightMode: "ຮູບແບບສີສັນເພື່ອໃຊ້ໃນໂໝດແສງ"
themeForDarkMode: "ຮູບແບບສີສັນທີ່ຈະໃຊ້ຢູ່ໃນໂໝດມືດ"
light: "ສະຫວ່າງ" light: "ສະຫວ່າງ"
dark: "ມືດ" dark: "ມືດ"
lightThemes: "ຊຸດຮູບແບບສະຫວ່າງ" lightThemes: "ຊຸດຮູບແບບສະຫວ່າງ"
darkThemes: "ຮູບແບບສີສັນມືດ" darkThemes: "ຮູບແບບສີສັນມືດ"
syncDeviceDarkMode: "ຊິງຄ໌ໂໝດມືດກັບການຕັ້ງຄ່າທົ່ວອຸປະກອນ"
drive: "ຂັບ" drive: "ຂັບ"
fileName: "ຊື່ໄຟລ໌" fileName: "ຊື່ໄຟລ໌"
selectFile: "ເລືອກໄຟລ໌" selectFile: "ເລືອກໄຟລ໌"
@ -265,6 +278,9 @@ invite: "ເຊີນ"
driveCapacityPerLocalAccount: "ຄວາມອາດສາມາດຂັບຕໍ່ຜູ້ໃຊ້ທ້ອງຖິ່ນ" driveCapacityPerLocalAccount: "ຄວາມອາດສາມາດຂັບຕໍ່ຜູ້ໃຊ້ທ້ອງຖິ່ນ"
driveCapacityPerRemoteAccount: "ໄດຣຟ໌ຄວາມອາດສາມາດຕໍ່ຜູ້ໃຊ້ທາງໄກ" driveCapacityPerRemoteAccount: "ໄດຣຟ໌ຄວາມອາດສາມາດຕໍ່ຜູ້ໃຊ້ທາງໄກ"
pinnedNotes: "ບັນທຶກທີ່ປັກໝຸດໄວ້" pinnedNotes: "ບັນທຶກທີ່ປັກໝຸດໄວ້"
turnstileSiteKey: "ກະແຈໄຊທ໌"
turnstileSecretKey: "ກະແຈລັບ"
name: "ຊື່"
userList: "ລາຍການ" userList: "ລາຍການ"
about: "ກ່ຽວກັບ" about: "ກ່ຽວກັບ"
aboutMisskey: "ກ່ຽວກັບ Misskey" aboutMisskey: "ກ່ຽວກັບ Misskey"
@ -326,6 +342,7 @@ _widgets:
instanceInfo: "ອີນສະແຕນ" instanceInfo: "ອີນສະແຕນ"
notifications: "ການແຈ້ງເຕືອນ" notifications: "ການແຈ້ງເຕືອນ"
timeline: "​ເສັ້ນກຳ​ນົດ​ເວ​ລາ​" timeline: "​ເສັ້ນກຳ​ນົດ​ເວ​ລາ​"
activity: "ກິດຈະກຳ"
federation: "ສະຫະພັນ" federation: "ສະຫະພັນ"
_userList: _userList:
chooseList: "ເລືອກບັນຊີລາຍການ" chooseList: "ເລືອກບັນຊີລາຍການ"
@ -335,6 +352,7 @@ _visibility:
home: "ໜ້າຫຼັກ" home: "ໜ້າຫຼັກ"
followers: "ຜູ້ຕິດຕາມ" followers: "ຜູ້ຕິດຕາມ"
_profile: _profile:
name: "ຊື່"
username: "ຊື່ຜູ້ໃຊ້" username: "ຊື່ຜູ້ໃຊ້"
_exportOrImport: _exportOrImport:
followingList: "ກຳລັງຕິດຕາມ" followingList: "ກຳລັງຕິດຕາມ"
@ -368,3 +386,5 @@ _deck:
list: "ລາຍການ" list: "ລາຍການ"
channel: "ຊ່ອງ" channel: "ຊ່ອງ"
mentions: "ກ່າວເຖິງ" mentions: "ກ່າວເຖິງ"
_webhookSettings:
name: "ຊື່"

View File

@ -122,6 +122,8 @@ unmarkAsSensitive: "ยกเลิกทำเครื่องหมายเ
enterFileName: "พิมพ์ชื่อไฟล์" enterFileName: "พิมพ์ชื่อไฟล์"
mute: "ปิดเสียง" mute: "ปิดเสียง"
unmute: "ยกเลิกการปิดเสียง" unmute: "ยกเลิกการปิดเสียง"
renoteMute: "ปิดเสียงรีโน้ต"
renoteUnmute: "เปิดเสียง รีโน้ต"
block: "บล็อค" block: "บล็อค"
unblock: "เลิกปิดกั้น" unblock: "เลิกปิดกั้น"
suspend: "ถูกระงับ" suspend: "ถูกระงับ"
@ -153,6 +155,7 @@ flagShowTimelineReplies: "แสดงตอบกลับ ในไทม์
flagShowTimelineRepliesDescription: "แสดงการตอบกลับของผู้ใช้งานไปยังโน้ตของผู้ใช้งานรายอื่นๆในไทม์ไลน์หากได้เปิดเอาไว้" flagShowTimelineRepliesDescription: "แสดงการตอบกลับของผู้ใช้งานไปยังโน้ตของผู้ใช้งานรายอื่นๆในไทม์ไลน์หากได้เปิดเอาไว้"
autoAcceptFollowed: "อนุมัติคำขอติดตามโดยอัตโนมัติทันที จากผู้ใช้งานที่คุณกำลังติดตาม" autoAcceptFollowed: "อนุมัติคำขอติดตามโดยอัตโนมัติทันที จากผู้ใช้งานที่คุณกำลังติดตาม"
addAccount: "เพิ่มบัญชี" addAccount: "เพิ่มบัญชี"
reloadAccountsList: "รีโหลดรายการบัญชีใหม่"
loginFailed: "การเข้าสู่ระบบไม่สำเร็จ" loginFailed: "การเข้าสู่ระบบไม่สำเร็จ"
showOnRemote: "ดูบนอินสแตนซ์ระยะไกล" showOnRemote: "ดูบนอินสแตนซ์ระยะไกล"
general: "ทั่วไป" general: "ทั่วไป"
@ -503,6 +506,7 @@ objectStorageUseSSLDesc: "ปิดการทำงานนี้ไว้
objectStorageUseProxy: "เชื่อมต่อผ่านพร็อกซี" objectStorageUseProxy: "เชื่อมต่อผ่านพร็อกซี"
objectStorageUseProxyDesc: "ปิดสิ่งนี้ไว้ถ้าหากคุณจะไม่ใช้ Proxy สำหรับการเชื่อมต่อ API" objectStorageUseProxyDesc: "ปิดสิ่งนี้ไว้ถ้าหากคุณจะไม่ใช้ Proxy สำหรับการเชื่อมต่อ API"
objectStorageSetPublicRead: "ตั้งค่า \"public-read\" ในการอัปโหลด" objectStorageSetPublicRead: "ตั้งค่า \"public-read\" ในการอัปโหลด"
s3ForcePathStyleDesc: "ถ้าหากเปิดใช้งาน s3ForcePathStyle ชื่อบัคเก็ตนั้นอาจจะต้องรวมอยู่ในเส้นทางของ URL ซึ่งตรงข้ามกับชื่อโฮสต์ของ URL คุณอาจจะต้องเปิดใช้งานการตั้งค่านี้เมื่อใช้บริการต่างๆ เช่น อินสแตนซ์ Minio ที่โฮสต์เองนะ"
serverLogs: "บันทึกของเซิร์ฟเวอร์" serverLogs: "บันทึกของเซิร์ฟเวอร์"
deleteAll: "ลบทั้งหมด" deleteAll: "ลบทั้งหมด"
showFixedPostForm: "แสดงแบบฟอร์มการโพสต์ที่ด้านบนสุดของไทม์ไลน์" showFixedPostForm: "แสดงแบบฟอร์มการโพสต์ที่ด้านบนสุดของไทม์ไลน์"
@ -545,7 +549,9 @@ userSilenced: "ผู้ใช้รายนี้กำลังถูกป
yourAccountSuspendedTitle: "บัญชีนี้นั้นถูกระงับ" yourAccountSuspendedTitle: "บัญชีนี้นั้นถูกระงับ"
yourAccountSuspendedDescription: "บัญชีนี้ถูกระงับ เนื่องจากละเมิดข้อกำหนดในการให้บริการของเซิร์ฟเวอร์หรืออาจจะละเมิดหลักเกณฑ์ชุมชน หรือ อาจจะโดนร้องเรียนเรื่องการละเมิดลิขสิทธิ์และอื่นๆอย่างต่อเนื่องซ้ำๆ หากคุณคิดว่าไม่ได้ทำผิดจริงๆหรือตัดสินผิดพลาด ได้โปรดกรุณาติดต่อผู้ดูแลระบบหากคุณต้องการทราบเหตุผลโดยละเอียดเพิ่มเติม และขอความกรุณาอย่าสร้างบัญชีใหม่" yourAccountSuspendedDescription: "บัญชีนี้ถูกระงับ เนื่องจากละเมิดข้อกำหนดในการให้บริการของเซิร์ฟเวอร์หรืออาจจะละเมิดหลักเกณฑ์ชุมชน หรือ อาจจะโดนร้องเรียนเรื่องการละเมิดลิขสิทธิ์และอื่นๆอย่างต่อเนื่องซ้ำๆ หากคุณคิดว่าไม่ได้ทำผิดจริงๆหรือตัดสินผิดพลาด ได้โปรดกรุณาติดต่อผู้ดูแลระบบหากคุณต้องการทราบเหตุผลโดยละเอียดเพิ่มเติม และขอความกรุณาอย่าสร้างบัญชีใหม่"
tokenRevoked: "โทเค็นไม่ถูกต้อง" tokenRevoked: "โทเค็นไม่ถูกต้อง"
tokenRevokedDescription: "โทเค็นนี้หมดอายุแล้วนะค่ะกรุณาเข้าสู่ระบบอีกครั้งนะ"
accountDeleted: "ลบบัญชีแล้ว" accountDeleted: "ลบบัญชีแล้ว"
accountDeletedDescription: "บัญชีนี้ถูกลบไปแล้วนะ"
menu: "เมนู" menu: "เมนู"
divider: "ตัวแบ่ง" divider: "ตัวแบ่ง"
addItem: "เพิ่มรายการ" addItem: "เพิ่มรายการ"
@ -914,6 +920,7 @@ pushNotificationNotSupported: "เบราว์เซอร์หรืออ
sendPushNotificationReadMessage: "ลบการแจ้งเตือนแบบพุชเมื่ออ่านการแจ้งเตือนหรือข้อความที่เกี่ยวข้องแล้ว" sendPushNotificationReadMessage: "ลบการแจ้งเตือนแบบพุชเมื่ออ่านการแจ้งเตือนหรือข้อความที่เกี่ยวข้องแล้ว"
sendPushNotificationReadMessageCaption: "การแจ้งเตือนที่มีข้อความ \"{emptyPushNotificationMessage}\" จะแสดงขึ้นมาในช่วงระยะเวลาสั้นๆ การดำเนินการนี้อาจทำให้เพิ่มการใช้งานแบตเตอรี่ของอุปกรณ์ถ้าหากมีนะ" sendPushNotificationReadMessageCaption: "การแจ้งเตือนที่มีข้อความ \"{emptyPushNotificationMessage}\" จะแสดงขึ้นมาในช่วงระยะเวลาสั้นๆ การดำเนินการนี้อาจทำให้เพิ่มการใช้งานแบตเตอรี่ของอุปกรณ์ถ้าหากมีนะ"
windowMaximize: "ขยายใหญ่สุดแล้ว" windowMaximize: "ขยายใหญ่สุดแล้ว"
windowMinimize: "ย่อเล็กที่สุด"
windowRestore: "เลิกทำ" windowRestore: "เลิกทำ"
caption: "รายละเอียด" caption: "รายละเอียด"
loggedInAsBot: "ล็อกอินเป็นบอตอยู่ในขณะนี้" loggedInAsBot: "ล็อกอินเป็นบอตอยู่ในขณะนี้"
@ -955,11 +962,17 @@ copyErrorInfo: "คัดลอกรายละเอียดข้อผิ
joinThisServer: "ลงชื่อสมัครใช้ในอินสแตนซ์นี้" joinThisServer: "ลงชื่อสมัครใช้ในอินสแตนซ์นี้"
exploreOtherServers: "มองหาอินสแตนซ์อื่น" exploreOtherServers: "มองหาอินสแตนซ์อื่น"
letsLookAtTimeline: "ลองดูที่ไทม์ไลน์" letsLookAtTimeline: "ลองดูที่ไทม์ไลน์"
disableFederationConfirm: "ปิดใช้งานสหพันธ์จริงๆหรอแน่ใจแล้วนะ?"
disableFederationConfirmWarn: "แม้ว่าจะถูกยกเลิกเอาไว้โพสต์ดังกล่าวนั้นจะยังคงเป็นสาธารณะต่อไป เว้นแต่ว่า...จะตั้งค่าเป็นอย่างอื่น โดยปกติคุณไม่จำเป็นต้องทำตรงนี้หรอกนะค่ะ"
disableFederationOk: "ปิดการใช้งาน"
invitationRequiredToRegister: "อินสแตนซ์นี้เป็นแบบรับเชิญเท่านั้น คุณต้องป้อนรหัสเชิญที่ถูกต้องถึงจะลงทะเบียนได้นะค่ะ" invitationRequiredToRegister: "อินสแตนซ์นี้เป็นแบบรับเชิญเท่านั้น คุณต้องป้อนรหัสเชิญที่ถูกต้องถึงจะลงทะเบียนได้นะค่ะ"
emailNotSupported: "อินสแตนซ์นี้ไม่รองรับการส่งอีเมลนะค่ะ" emailNotSupported: "อินสแตนซ์นี้ไม่รองรับการส่งอีเมลนะค่ะ"
postToTheChannel: "โพสต์ลงช่อง" postToTheChannel: "โพสต์ลงช่อง"
cannotBeChangedLater: "สิ่งนี้ไม่สามารถเปลี่ยนแปลงได้ในภายหลังนะ" cannotBeChangedLater: "สิ่งนี้ไม่สามารถเปลี่ยนแปลงได้ในภายหลังนะ"
reactionAcceptance: "การยอมรับรีแอคชั่น"
likeOnly: "ที่ชอบเท่านั้น" likeOnly: "ที่ชอบเท่านั้น"
likeOnlyForRemote: "ไลค์สำหรับอินสแตนซ์ระยะไกลเท่านั้น"
rolesAssignedToMe: "บทบาทที่ได้รับมอบหมายให้ฉัน"
resetPasswordConfirm: "รีเซ็ตรหัสผ่านของคุณจริงๆหรอ?" resetPasswordConfirm: "รีเซ็ตรหัสผ่านของคุณจริงๆหรอ?"
sensitiveWords: "คำที่ละเอียดอ่อน" sensitiveWords: "คำที่ละเอียดอ่อน"
sensitiveWordsDescription: "การเปิดเผยโน้ตทั้งหมดที่มีคำที่กำหนดค่าไว้จะถูกตั้งค่าเป็น \"หน้าแรก\" โดยอัตโนมัติ คุณยังสามารถแสดงหลายรายการได้โดยแยกรายการโดยใช้ตัวแบ่งบรรทัดได้นะ" sensitiveWordsDescription: "การเปิดเผยโน้ตทั้งหมดที่มีคำที่กำหนดค่าไว้จะถูกตั้งค่าเป็น \"หน้าแรก\" โดยอัตโนมัติ คุณยังสามารถแสดงหลายรายการได้โดยแยกรายการโดยใช้ตัวแบ่งบรรทัดได้นะ"
@ -971,6 +984,22 @@ drivecleaner: "ทำความสะอาดไดรฟ์"
retryAllQueuesNow: "ลองเรียกใช้คิวทั้งหมดอีกครั้ง" retryAllQueuesNow: "ลองเรียกใช้คิวทั้งหมดอีกครั้ง"
retryAllQueuesConfirmTitle: "ลองใหม่ทั้งหมดจริงๆหรอแน่ใจนะ?" retryAllQueuesConfirmTitle: "ลองใหม่ทั้งหมดจริงๆหรอแน่ใจนะ?"
retryAllQueuesConfirmText: "สิ่งนี้จะเพิ่มการโหลดเซิร์ฟเวอร์ชั่วคราวนะ" retryAllQueuesConfirmText: "สิ่งนี้จะเพิ่มการโหลดเซิร์ฟเวอร์ชั่วคราวนะ"
enableChartsForRemoteUser: "สร้างแผนภูมิข้อมูลผู้ใช้ระยะไกล"
enableChartsForFederatedInstances: "สร้างแผนภูมิข้อมูลอินสแตนซ์ระยะไกล"
showClipButtonInNoteFooter: "เพิ่ม \"คลิป\" เพื่อบันทึกเมนูการทำงาน"
largeNoteReactions: "ขยายรีแอคชั่นการแสดงผล"
noteIdOrUrl: "โน้ต ID หรือ URL"
accountMigration: "การโยกย้ายบัญชี"
accountMoved: "ผู้ใช้รายนี้ได้ย้ายไปยังบัญชีใหม่แล้ว:"
forceShowAds: "แสดงโฆษณาเสมอ"
_accountMigration:
moveTo: "ย้ายข้อมูลบัญชีนี้ไปยังบัญชีอีกหนึ่ง"
moveToLabel: "บัญชีที่จะย้ายไปที่:"
moveAccountDescription: "การกระทำนี้ไม่สามารถย้อนกลับได้นะ ขั้นตอนแรก ต้องสร้างนามแฝงสำหรับบัญชีนี้ในบัญชีที่คุณต้องการย้ายไป หลังจากนั้นแล้ว ป้อนบัญชีที่จะย้ายไปในรูปแบบดังต่อไปนี้: @person@instance.com"
moveFrom: "ย้ายข้อมูลบัญชีอื่นไปยังอีกบัญชีนี้หนึ่ง"
moveFromLabel: "บัญชีที่จะย้ายจาก:"
moveFromDescription: "สร้างนามแฝงสำหรับบัญชีที่จะย้ายจากบัญชีนี้ ถ้าหากคุณต้องการโอนผู้ติดตาม สิ่งนี้ต้องทำก่อนโอนก่อนนะค่ะ! หลังจากนั้น ป้อนบัญชีที่จะย้ายไปในรูปแบบต่อไปนี้: @person@instance.com"
migrationConfirm: "ย้ายข้อมูลบัญชีนี้ไปที่ {account} จริงๆนะ เมื่อมีการเริ่มต้นแล้ว กระบวนการนี้จะไม่สามารถหยุดหรือนำกลับคืนมาได้ และคุณจะไม่สามารถใช้บัญชีนี้ในสถานะดั้งเดิมได้อีกต่อไป\n\nนอกจากนี้ เพื่อให้แน่ใจยืนยันว่าคุณได้สร้างนามแฝงในบัญชีที่จะย้ายข้อมูลนะค่ะ"
_achievements: _achievements:
earnedAt: "ได้รับเมื่อ" earnedAt: "ได้รับเมื่อ"
_types: _types:
@ -1267,6 +1296,8 @@ _role:
followersMoreThanOrEq: "จำนวนผู้ติดตามมากกว่าหรือเท่ากับ\n" followersMoreThanOrEq: "จำนวนผู้ติดตามมากกว่าหรือเท่ากับ\n"
followingLessThanOrEq: "จำนวนบัญชีต่อไปนี้คือ น้อยกว่าหรือเท่ากับ" followingLessThanOrEq: "จำนวนบัญชีต่อไปนี้คือ น้อยกว่าหรือเท่ากับ"
followingMoreThanOrEq: "จำนวนบัญชีต่อไปนี้คือ มากกว่าหรือเท่ากับ" followingMoreThanOrEq: "จำนวนบัญชีต่อไปนี้คือ มากกว่าหรือเท่ากับ"
notesLessThanOrEq: "จำนวนโพสต์น้อยกว่าเท่ากับ"
notesMoreThanOrEq: "จำนวนโพสต์มากกว่าเท่ากับ"
and: "และ" and: "และ"
or: "หรือ" or: "หรือ"
not: "ไม่" not: "ไม่"
@ -1866,5 +1897,16 @@ _drivecleaner:
orderBySizeDesc: "ขนาดไฟล์จากมากไปหาน้อย" orderBySizeDesc: "ขนาดไฟล์จากมากไปหาน้อย"
orderByCreatedAtAsc: "วันที่จากน้อยไปหามาก" orderByCreatedAtAsc: "วันที่จากน้อยไปหามาก"
_webhookSettings: _webhookSettings:
createWebhook: "สร้าง Webhook"
name: "ชื่อ" name: "ชื่อ"
secret: "ความลับ"
events: "อีเว้นท์ Webhook"
active: "เปิดใช้งาน" active: "เปิดใช้งาน"
_events:
follow: "เมื่อกำลังติดตามผู้ใช้"
followed: "เมื่อกำลังติดตามแล้ว"
note: "เมื่อกำลังโพสต์โน้ต"
reply: "เมื่อได้รับการตอบกลับ"
renote: "รีโน้ตแล้วเมื่อ"
reaction: "เมื่อได้รับรีแอคชั่น"
mention: "เมื่อกำลังถูกกล่าวถึง"

View File

@ -20,6 +20,7 @@ noNotes: "没有帖文"
noNotifications: "无通知" noNotifications: "无通知"
instance: "服务器" instance: "服务器"
settings: "设置" settings: "设置"
notificationSettings: "通知设置"
basicSettings: "基本设置" basicSettings: "基本设置"
otherSettings: "其他设置" otherSettings: "其他设置"
openInWindow: "在新窗口中打开" openInWindow: "在新窗口中打开"
@ -991,6 +992,7 @@ largeNoteReactions: "使用大图标来显示回应"
noteIdOrUrl: "帖子ID或URL" noteIdOrUrl: "帖子ID或URL"
accountMigration: "账户迁移" accountMigration: "账户迁移"
accountMoved: "此用户已迁移账户" accountMoved: "此用户已迁移账户"
forceShowAds: "总是显示广告"
_accountMigration: _accountMigration:
moveTo: "把这个账户迁移到新的账户" moveTo: "把这个账户迁移到新的账户"
moveToLabel: "迁移后的账户" moveToLabel: "迁移后的账户"
@ -1406,6 +1408,8 @@ _channel:
following: "正在关注" following: "正在关注"
usersCount: "有{n}人参与" usersCount: "有{n}人参与"
notesCount: "有{n}个帖子" notesCount: "有{n}个帖子"
nameAndDescription: "名称与描述"
nameOnly: "仅名称"
_menuDisplay: _menuDisplay:
sideFull: "横向" sideFull: "横向"
sideIcon: "横向(图标)" sideIcon: "横向(图标)"
@ -1886,6 +1890,7 @@ _deck:
channel: "频道" channel: "频道"
mentions: "提及" mentions: "提及"
direct: "指定用户" direct: "指定用户"
roleTimeline: "角色时间线"
_dialog: _dialog:
charactersExceeded: "已经超过了最大字符数! 当前字符数 {current} / 限制字符数 {max}" charactersExceeded: "已经超过了最大字符数! 当前字符数 {current} / 限制字符数 {max}"
charactersBelow: "低于最小字符数!当前字符数 {current} / 限制字符数 {min}" charactersBelow: "低于最小字符数!当前字符数 {current} / 限制字符数 {min}"

View File

@ -20,6 +20,7 @@ noNotes: "無貼文。"
noNotifications: "沒有通知" noNotifications: "沒有通知"
instance: "實例" instance: "實例"
settings: "設定" settings: "設定"
notificationSettings: "通知選項"
basicSettings: "基本設定" basicSettings: "基本設定"
otherSettings: "其他設定" otherSettings: "其他設定"
openInWindow: "在新視窗開啟" openInWindow: "在新視窗開啟"
@ -506,6 +507,7 @@ objectStorageUseSSLDesc: "如果不使用https進行API連接請關閉"
objectStorageUseProxy: "使用網路代理" objectStorageUseProxy: "使用網路代理"
objectStorageUseProxyDesc: "如果不使用代理進行API連接請關閉" objectStorageUseProxyDesc: "如果不使用代理進行API連接請關閉"
objectStorageSetPublicRead: "上傳時設定為\"public-read\"" objectStorageSetPublicRead: "上傳時設定為\"public-read\""
s3ForcePathStyleDesc: "啟用 s3ForcePathStyle 會強制將儲存槽名稱指定為 URL 中路徑的一部分,而不是主機名。 使用自託管 Minio 之類的可能需要啟用。"
serverLogs: "伺服器日誌" serverLogs: "伺服器日誌"
deleteAll: "刪除所有記錄" deleteAll: "刪除所有記錄"
showFixedPostForm: "於時間軸頁頂顯示「發送貼文」方框" showFixedPostForm: "於時間軸頁頂顯示「發送貼文」方框"
@ -560,7 +562,7 @@ inboxUrl: "收件夾URL"
addedRelays: "已加入的中繼" addedRelays: "已加入的中繼"
serviceworkerInfo: "您需要啟用推送通知" serviceworkerInfo: "您需要啟用推送通知"
deletedNote: "已删除的貼文" deletedNote: "已删除的貼文"
invisibleNote: "隱藏的貼文" invisibleNote: "私密的貼文"
enableInfiniteScroll: "啟用自動滾動頁面模式" enableInfiniteScroll: "啟用自動滾動頁面模式"
visibility: "可見性" visibility: "可見性"
poll: "投票" poll: "投票"
@ -919,6 +921,7 @@ pushNotificationNotSupported: "瀏覽器或實例不支援推播通知"
sendPushNotificationReadMessage: "通知與訊息如果已讀的話,就將推播通知刪除" sendPushNotificationReadMessage: "通知與訊息如果已讀的話,就將推播通知刪除"
sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」通知將立刻顯示。可能會增加設備的電池消耗。" sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」通知將立刻顯示。可能會增加設備的電池消耗。"
windowMaximize: "最大化" windowMaximize: "最大化"
windowMinimize: "最小化"
windowRestore: "復原" windowRestore: "復原"
caption: "標題" caption: "標題"
loggedInAsBot: "以機器人帳戶登入中" loggedInAsBot: "以機器人帳戶登入中"
@ -960,6 +963,9 @@ copyErrorInfo: "複製錯誤資訊"
joinThisServer: "在此伺服器上註冊" joinThisServer: "在此伺服器上註冊"
exploreOtherServers: "探索其他伺服器" exploreOtherServers: "探索其他伺服器"
letsLookAtTimeline: "看看時間軸" letsLookAtTimeline: "看看時間軸"
disableFederationConfirm: "要停止聯邦功能嗎?"
disableFederationConfirmWarn: "即使停止了聯邦功能,貼文也不會變成私密的。在大部分的情況下,沒有必要停止聯邦功能。"
disableFederationOk: "停止聯邦功能"
invitationRequiredToRegister: "目前這個伺服器為邀請制,必須擁有邀請碼才能註冊。" invitationRequiredToRegister: "目前這個伺服器為邀請制,必須擁有邀請碼才能註冊。"
emailNotSupported: "這個伺服器不支援寄送郵件" emailNotSupported: "這個伺服器不支援寄送郵件"
postToTheChannel: "發布到頻道" postToTheChannel: "發布到頻道"
@ -985,9 +991,16 @@ showClipButtonInNoteFooter: "將摘錄添加至貼文"
largeNoteReactions: "將貼文的反應放大顯示" largeNoteReactions: "將貼文的反應放大顯示"
noteIdOrUrl: "貼文ID或URL" noteIdOrUrl: "貼文ID或URL"
accountMigration: "遷移帳戶" accountMigration: "遷移帳戶"
accountMoved: "這個使用者已遷移至新的帳戶:"
forceShowAds: "總是顯示廣告"
_accountMigration: _accountMigration:
moveTo: "將這個帳戶遷移至新的帳戶" moveTo: "將這個帳戶遷移至新的帳戶"
moveToLabel: "要遷移的帳戶:" moveToLabel: "要遷移到的帳戶:"
moveAccountDescription: "這個操作不可撤銷。首先,請確認已在要遷移到的帳戶中為這個帳戶建立了一個別名。建立別名之後,像這樣輸入你要遷移到的帳戶:@person@instance.com"
moveFrom: "從其他帳戶遷移到這個帳戶"
moveFromLabel: "要遷移過來的帳戶:"
moveFromDescription: "如果你想把跟隨者從別的帳戶遷移過來,必須先在這裡建立別名。請務必在執行遷移之前建立別名!請像這樣輸入要遷移的帳戶:@person@instance.com"
migrationConfirm: "確定要將這個帳戶遷移至 {account} 嗎?一旦遷移就無法撤銷,也就無法以原來的狀態使用這個帳戶。\n另外請確認在要遷移到的帳戶已經建立了一個別名。"
_achievements: _achievements:
earnedAt: "獲得日期" earnedAt: "獲得日期"
_types: _types:
@ -1395,6 +1408,8 @@ _channel:
following: "關注中" following: "關注中"
usersCount: "有{n}人參與" usersCount: "有{n}人參與"
notesCount: "有{n}個貼文" notesCount: "有{n}個貼文"
nameAndDescription: "名稱與說明"
nameOnly: "僅名稱"
_menuDisplay: _menuDisplay:
sideFull: "側向" sideFull: "側向"
sideIcon: "側向(圖示)" sideIcon: "側向(圖示)"
@ -1875,6 +1890,7 @@ _deck:
channel: "頻道" channel: "頻道"
mentions: "提及" mentions: "提及"
direct: "指定使用者" direct: "指定使用者"
roleTimeline: "角色時間軸"
_dialog: _dialog:
charactersExceeded: "已超過最大字數!現在 {current} / 限制 {max}" charactersExceeded: "已超過最大字數!現在 {current} / 限制 {max}"
charactersBelow: "低於最少字數!現在 {current} / 限制 {max}" charactersBelow: "低於最少字數!現在 {current} / 限制 {max}"

View File

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "13.11.1", "version": "13.11.3",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,18 @@
export class UserMemo1680702787050 {
name = 'UserMemo1680702787050'
async up(queryRunner) {
await queryRunner.query(`CREATE TABLE "user_memo" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "targetUserId" character varying(32) NOT NULL, "memo" character varying(2048) NOT NULL, CONSTRAINT "PK_e9aaa58f7d3699a84d79078f4d9" PRIMARY KEY ("id")); COMMENT ON COLUMN "user_memo"."userId" IS 'The ID of author.'; COMMENT ON COLUMN "user_memo"."targetUserId" IS 'The ID of target user.'; COMMENT ON COLUMN "user_memo"."memo" IS 'Memo.'`);
await queryRunner.query(`CREATE INDEX "IDX_650b49c5639b5840ee6a2b8f83" ON "user_memo" ("userId") `);
await queryRunner.query(`CREATE INDEX "IDX_66ac4a82894297fd09ba61f3d3" ON "user_memo" ("targetUserId") `);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_faef300913c738265638ba3ebc" ON "user_memo" ("userId", "targetUserId") `);
await queryRunner.query(`ALTER TABLE "user_memo" ADD CONSTRAINT "FK_650b49c5639b5840ee6a2b8f83e" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "user_memo" ADD CONSTRAINT "FK_66ac4a82894297fd09ba61f3d35" FOREIGN KEY ("targetUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_memo" DROP CONSTRAINT "FK_66ac4a82894297fd09ba61f3d35"`);
await queryRunner.query(`ALTER TABLE "user_memo" DROP CONSTRAINT "FK_650b49c5639b5840ee6a2b8f83e"`);
await queryRunner.query(`DROP TABLE "user_memo"`);
}
}

View File

@ -84,7 +84,7 @@
"got": "12.6.0", "got": "12.6.0",
"happy-dom": "8.9.0", "happy-dom": "8.9.0",
"hpagent": "1.2.0", "hpagent": "1.2.0",
"ioredis": "4.28.5", "ioredis": "5.3.1",
"ip-cidr": "3.1.0", "ip-cidr": "3.1.0",
"is-svg": "4.3.2", "is-svg": "4.3.2",
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
@ -159,7 +159,6 @@
"@types/content-disposition": "0.5.5", "@types/content-disposition": "0.5.5",
"@types/escape-regexp": "0.0.1", "@types/escape-regexp": "0.0.1",
"@types/fluent-ffmpeg": "2.1.21", "@types/fluent-ffmpeg": "2.1.21",
"@types/ioredis": "4.28.10",
"@types/jest": "29.5.0", "@types/jest": "29.5.0",
"@types/js-yaml": "4.0.5", "@types/js-yaml": "4.0.5",
"@types/jsdom": "21.1.1", "@types/jsdom": "21.1.1",

View File

@ -1,6 +1,6 @@
import { setTimeout } from 'node:timers/promises'; import { setTimeout } from 'node:timers/promises';
import { Global, Inject, Module } from '@nestjs/common'; import { Global, Inject, Module } from '@nestjs/common';
import Redis from 'ioredis'; import * as Redis from 'ioredis';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { DI } from './di-symbols.js'; import { DI } from './di-symbols.js';
import { loadConfig } from './config.js'; import { loadConfig } from './config.js';
@ -25,7 +25,7 @@ const $db: Provider = {
const $redis: Provider = { const $redis: Provider = {
provide: DI.redis, provide: DI.redis,
useFactory: (config) => { useFactory: (config) => {
return new Redis({ return new Redis.Redis({
port: config.redis.port, port: config.redis.port,
host: config.redis.host, host: config.redis.host,
family: config.redis.family == null ? 0 : config.redis.family, family: config.redis.family == null ? 0 : config.redis.family,
@ -40,7 +40,7 @@ const $redis: Provider = {
const $redisForPub: Provider = { const $redisForPub: Provider = {
provide: DI.redisForPub, provide: DI.redisForPub,
useFactory: (config) => { useFactory: (config) => {
const redis = new Redis({ const redis = new Redis.Redis({
port: config.redisForPubsub.port, port: config.redisForPubsub.port,
host: config.redisForPubsub.host, host: config.redisForPubsub.host,
family: config.redisForPubsub.family == null ? 0 : config.redisForPubsub.family, family: config.redisForPubsub.family == null ? 0 : config.redisForPubsub.family,
@ -56,7 +56,7 @@ const $redisForPub: Provider = {
const $redisForSub: Provider = { const $redisForSub: Provider = {
provide: DI.redisForSub, provide: DI.redisForSub,
useFactory: (config) => { useFactory: (config) => {
const redis = new Redis({ const redis = new Redis.Redis({
port: config.redisForPubsub.port, port: config.redisForPubsub.port,
host: config.redisForPubsub.host, host: config.redisForPubsub.host,
family: config.redisForPubsub.family == null ? 0 : config.redisForPubsub.family, family: config.redisForPubsub.family == null ? 0 : config.redisForPubsub.family,

View File

@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis'; import * as Redis from 'ioredis';
import type { Antenna } from '@/models/entities/Antenna.js'; import type { Antenna } from '@/models/entities/Antenna.js';
import type { Note } from '@/models/entities/Note.js'; import type { Note } from '@/models/entities/Note.js';
import type { User } from '@/models/entities/User.js'; import type { User } from '@/models/entities/User.js';
@ -91,16 +91,26 @@ export class AntennaService implements OnApplicationShutdown {
} }
@bindThis @bindThis
public async addNoteToAntenna(antenna: Antenna, note: Note, noteUser: { id: User['id']; }): Promise<void> { public async addNoteToAntennas(note: Note, noteUser: { id: User['id']; username: string; host: string | null; }): Promise<void> {
this.redisClient.xadd( const antennas = await this.getAntennas();
const antennasWithMatchResult = await Promise.all(antennas.map(antenna => this.checkHitAntenna(antenna, note, noteUser).then(hit => [antenna, hit] as const)));
const matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna);
const redisPipeline = this.redisClient.pipeline();
for (const antenna of matchedAntennas) {
redisPipeline.xadd(
`antennaTimeline:${antenna.id}`, `antennaTimeline:${antenna.id}`,
'MAXLEN', '~', '200', 'MAXLEN', '~', '200',
`${this.idService.parse(note.id).date.getTime()}-*`, '*',
'note', note.id); 'note', note.id);
this.globalEventService.publishAntennaStream(antenna.id, 'note', note); this.globalEventService.publishAntennaStream(antenna.id, 'note', note);
} }
redisPipeline.exec();
}
// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている // NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
@bindThis @bindThis

View File

@ -1,7 +1,7 @@
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import redisLock from 'redis-lock'; import redisLock from 'redis-lock';
import Redis from 'ioredis'; import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';

View File

@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis'; import * as Redis from 'ioredis';
import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, UserProfile, UserProfilesRepository, UsersRepository } from '@/models/index.js'; import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, UserProfile, UserProfilesRepository, UsersRepository } from '@/models/index.js';
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
import type { LocalUser, User } from '@/models/entities/User.js'; import type { LocalUser, User } from '@/models/entities/User.js';

View File

@ -1,6 +1,6 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { DataSource, In, IsNull } from 'typeorm'; import { DataSource, In, IsNull } from 'typeorm';
import Redis from 'ioredis'; import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
@ -13,6 +13,7 @@ import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { query } from '@/misc/prelude/url.js'; import { query } from '@/misc/prelude/url.js';
import type { Serialized } from '@/server/api/stream/types.js';
@Injectable() @Injectable()
export class CustomEmojiService { export class CustomEmojiService {
@ -43,12 +44,14 @@ export class CustomEmojiService {
lifetime: 1000 * 60 * 30, // 30m lifetime: 1000 * 60 * 30, // 30m
memoryCacheLifetime: 1000 * 60 * 3, // 3m memoryCacheLifetime: 1000 * 60 * 3, // 3m
fetcher: () => this.emojisRepository.find({ where: { host: IsNull() } }).then(emojis => new Map(emojis.map(emoji => [emoji.name, emoji]))), fetcher: () => this.emojisRepository.find({ where: { host: IsNull() } }).then(emojis => new Map(emojis.map(emoji => [emoji.name, emoji]))),
toRedisConverter: (value) => JSON.stringify(value.values()), toRedisConverter: (value) => JSON.stringify(Array.from(value.values())),
fromRedisConverter: (value) => { fromRedisConverter: (value) => {
// 原因不明だが配列以外が入ってくることがあるため if (!Array.isArray(JSON.parse(value))) return undefined; // 古いバージョンの壊れたキャッシュが残っていることがある(そのうち消す)
if (!Array.isArray(JSON.parse(value))) return undefined; return new Map(JSON.parse(value).map((x: Serialized<Emoji>) => [x.name, {
return new Map(JSON.parse(value).map((x: Emoji) => [x.name, x])); ...x,
}, // TODO: Date型の変換 updatedAt: x.updatedAt ? new Date(x.updatedAt) : null,
}]));
},
}); });
} }
@ -271,16 +274,7 @@ export class CustomEmojiService {
const emoji = await this.cache.fetch(`${name} ${host}`, queryOrNull); const emoji = await this.cache.fetch(`${name} ${host}`, queryOrNull);
if (emoji == null) return null; if (emoji == null) return null;
return emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
const isLocal = emoji.host == null;
const emojiUrl = emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
const url = isLocal
? emojiUrl
: this.config.proxyRemoteFiles
? `${this.config.mediaProxy}/emoji.webp?${query({ url: emojiUrl })}`
: emojiUrl;
return url;
} }
/** /**

View File

@ -86,10 +86,14 @@ export class DownloadService {
const contentDisposition = res.headers['content-disposition']; const contentDisposition = res.headers['content-disposition'];
if (contentDisposition != null) { if (contentDisposition != null) {
try {
const parsed = parse(contentDisposition); const parsed = parse(contentDisposition);
if (parsed.parameters.filename) { if (parsed.parameters.filename) {
filename = parsed.parameters.filename; filename = parsed.parameters.filename;
} }
} catch (e) {
this.logger.warn(`Failed to parse content-disposition: ${contentDisposition}`, { stack: e });
}
} }
}).on('downloadProgress', (progress: Got.Progress) => { }).on('downloadProgress', (progress: Got.Progress) => {
if (progress.transferred > maxSize) { if (progress.transferred > maxSize) {

View File

@ -59,6 +59,8 @@ type AddFileArgs = {
uri?: string | null; uri?: string | null;
/** Mark file as sensitive */ /** Mark file as sensitive */
sensitive?: boolean | null; sensitive?: boolean | null;
/** Extension to force */
ext?: string | null;
requestIp?: string | null; requestIp?: string | null;
requestHeaders?: Record<string, string> | null; requestHeaders?: Record<string, string> | null;
@ -125,7 +127,7 @@ export class DriveService {
/*** /***
* Save file * Save file
* @param path Path for original * @param path Path for original
* @param name Name for original * @param name Name for original (should be extention corrected)
* @param type Content-Type for original * @param type Content-Type for original
* @param hash Hash for original * @param hash Hash for original
* @param size Size for original * @param size Size for original
@ -151,7 +153,7 @@ export class DriveService {
} }
// 拡張子からContent-Typeを設定してそうな挙動を示すオブジェクトストレージ (upcloud?) も存在するので、 // 拡張子からContent-Typeを設定してそうな挙動を示すオブジェクトストレージ (upcloud?) も存在するので、
// 許可されているファイル形式でしか拡張子をつけない // 許可されているファイル形式でしかURLに拡張子をつけない
if (!FILE_TYPE_BROWSERSAFE.includes(type)) { if (!FILE_TYPE_BROWSERSAFE.includes(type)) {
ext = ''; ext = '';
} }
@ -173,7 +175,7 @@ export class DriveService {
//#region Uploads //#region Uploads
this.registerLogger.info(`uploading original: ${key}`); this.registerLogger.info(`uploading original: ${key}`);
const uploads = [ const uploads = [
this.upload(key, fs.createReadStream(path), type, ext, name), this.upload(key, fs.createReadStream(path), type, null, name),
]; ];
if (alts.webpublic) { if (alts.webpublic) {
@ -189,7 +191,7 @@ export class DriveService {
thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`; thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`;
this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`); this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`);
uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type, alts.thumbnail.ext)); uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type, alts.thumbnail.ext, `${name}.thumbnail`));
} }
await Promise.all(uploads); await Promise.all(uploads);
@ -396,8 +398,9 @@ export class DriveService {
); );
} }
// Expire oldest file (without avatar or banner) of remote user
@bindThis @bindThis
private async deleteOldFile(user: RemoteUser) { private async expireOldFile(user: RemoteUser, driveCapacity: number) {
const q = this.driveFilesRepository.createQueryBuilder('file') const q = this.driveFilesRepository.createQueryBuilder('file')
.where('file.userId = :userId', { userId: user.id }) .where('file.userId = :userId', { userId: user.id })
.andWhere('file.isLink = FALSE'); .andWhere('file.isLink = FALSE');
@ -410,12 +413,17 @@ export class DriveService {
q.andWhere('file.id != :bannerId', { bannerId: user.bannerId }); q.andWhere('file.id != :bannerId', { bannerId: user.bannerId });
} }
//This selete is hard coded, be careful if change database schema
q.addSelect('SUM("file"."size") OVER (ORDER BY "file"."id" DESC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)', 'acc_usage');
q.orderBy('file.id', 'ASC'); q.orderBy('file.id', 'ASC');
const oldFile = await q.getOne(); const fileList = await q.getRawMany();
const exceedFileIds = fileList.filter((x: any) => x.acc_usage > driveCapacity).map((x: any) => x.file_id);
if (oldFile) { for (const fileId of exceedFileIds) {
this.deleteFile(oldFile, true); const file = await this.driveFilesRepository.findOneBy({ id: fileId });
if (file == null) continue;
this.deleteFile(file, true);
} }
} }
@ -437,6 +445,7 @@ export class DriveService {
sensitive = null, sensitive = null,
requestIp = null, requestIp = null,
requestHeaders = null, requestHeaders = null,
ext = null,
}: AddFileArgs): Promise<DriveFile> { }: AddFileArgs): Promise<DriveFile> {
let skipNsfwCheck = false; let skipNsfwCheck = false;
const instance = await this.metaService.fetch(); const instance = await this.metaService.fetch();
@ -468,7 +477,7 @@ export class DriveService {
// DriveFile.nameは256文字, validateFileNameは200文字制限であるため、 // DriveFile.nameは256文字, validateFileNameは200文字制限であるため、
// extを付加してデータベースの文字数制限に当たることはまずない // extを付加してデータベースの文字数制限に当たることはまずない
(name && this.driveFileEntityService.validateFileName(name)) ? name : 'untitled', (name && this.driveFileEntityService.validateFileName(name)) ? name : 'untitled',
info.type.ext, ext ?? info.type.ext,
); );
if (user && !force) { if (user && !force) {
@ -489,22 +498,19 @@ export class DriveService {
//#region Check drive usage //#region Check drive usage
if (user && !isLink) { if (user && !isLink) {
const usage = await this.driveFileEntityService.calcDriveUsageOf(user); const usage = await this.driveFileEntityService.calcDriveUsageOf(user);
const isLocalUser = this.userEntityService.isLocalUser(user);
const policies = await this.roleService.getUserPolicies(user.id); const policies = await this.roleService.getUserPolicies(user.id);
const driveCapacity = 1024 * 1024 * policies.driveCapacityMb; const driveCapacity = 1024 * 1024 * policies.driveCapacityMb;
this.registerLogger.debug('drive capacity override applied'); this.registerLogger.debug('drive capacity override applied');
this.registerLogger.debug(`overrideCap: ${driveCapacity}bytes, usage: ${usage}bytes, u+s: ${usage + info.size}bytes`); this.registerLogger.debug(`overrideCap: ${driveCapacity}bytes, usage: ${usage}bytes, u+s: ${usage + info.size}bytes`);
this.registerLogger.debug(`drive usage is ${usage} (max: ${driveCapacity})`);
// If usage limit exceeded // If usage limit exceeded
if (usage + info.size > driveCapacity) { if (driveCapacity < usage + info.size) {
if (this.userEntityService.isLocalUser(user)) { if (isLocalUser) {
throw new IdentifiableError('c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6', 'No free space.'); throw new IdentifiableError('c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6', 'No free space.');
} else {
// (アバターまたはバナーを含まず)最も古いファイルを削除する
this.deleteOldFile(await this.usersRepository.findOneByOrFail({ id: user.id }) as RemoteUser);
} }
await this.expireOldFile(await this.usersRepository.findOneByOrFail({ id: user.id }) as RemoteUser, driveCapacity - info.size);
} }
} }
//#endregion //#endregion

View File

@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis'; import * as Redis from 'ioredis';
import type { InstancesRepository } from '@/models/index.js'; import type { InstancesRepository } from '@/models/index.js';
import type { Instance } from '@/models/entities/Instance.js'; import type { Instance } from '@/models/entities/Instance.js';
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';

View File

@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis'; import * as Redis from 'ioredis';
import type { User } from '@/models/entities/User.js'; import type { User } from '@/models/entities/User.js';
import type { Note } from '@/models/entities/Note.js'; import type { Note } from '@/models/entities/Note.js';
import type { UserList } from '@/models/entities/UserList.js'; import type { UserList } from '@/models/entities/UserList.js';
@ -14,11 +14,13 @@ import type {
MainStreamTypes, MainStreamTypes,
NoteStreamTypes, NoteStreamTypes,
UserListStreamTypes, UserListStreamTypes,
RoleTimelineStreamTypes,
} from '@/server/api/stream/types.js'; } from '@/server/api/stream/types.js';
import type { Packed } from '@/misc/json-schema.js'; import type { Packed } from '@/misc/json-schema.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { Role } from '@/models';
@Injectable() @Injectable()
export class GlobalEventService { export class GlobalEventService {
@ -81,6 +83,11 @@ export class GlobalEventService {
this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value); this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value);
} }
@bindThis
public publishRoleTimelineStream<K extends keyof RoleTimelineStreamTypes>(roleId: Role['id'], type: K, value?: RoleTimelineStreamTypes[K]): void {
this.publish(`roleTimelineStream:${roleId}`, type, typeof value === 'undefined' ? null : value);
}
@bindThis @bindThis
public publishNotesStream(note: Packed<'Note'>): void { public publishNotesStream(note: Packed<'Note'>): void {
this.publish('notesStream', null, note); this.publish('notesStream', null, note);

View File

@ -1,6 +1,6 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import Redis from 'ioredis'; import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { Meta } from '@/models/entities/Meta.js'; import { Meta } from '@/models/entities/Meta.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';

View File

@ -1,7 +1,7 @@
import { setImmediate } from 'node:timers/promises'; import { setImmediate } from 'node:timers/promises';
import * as mfm from 'mfm-js'; import * as mfm from 'mfm-js';
import { In, DataSource } from 'typeorm'; import { In, DataSource } from 'typeorm';
import Redis from 'ioredis'; import * as Redis from 'ioredis';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import { extractMentions } from '@/misc/extract-mentions.js'; import { extractMentions } from '@/misc/extract-mentions.js';
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
@ -329,7 +329,7 @@ export class NoteCreateService implements OnApplicationShutdown {
this.redisClient.xadd( this.redisClient.xadd(
`channelTimeline:${data.channel.id}`, `channelTimeline:${data.channel.id}`,
'MAXLEN', '~', '1000', 'MAXLEN', '~', '1000',
`${this.idService.parse(note.id).date.getTime()}-*`, '*',
'note', note.id); 'note', note.id);
} }
@ -493,14 +493,7 @@ export class NoteCreateService implements OnApplicationShutdown {
} }
}); });
// Antenna this.antennaService.addNoteToAntennas(note, user);
for (const antenna of (await this.antennaService.getAntennas())) {
this.antennaService.checkHitAntenna(antenna, note, user).then(hit => {
if (hit) {
this.antennaService.addNoteToAntenna(antenna, note, user);
}
});
}
if (data.reply) { if (data.reply) {
this.saveReply(data.reply, note); this.saveReply(data.reply, note);
@ -554,6 +547,8 @@ export class NoteCreateService implements OnApplicationShutdown {
this.globalEventService.publishNotesStream(noteObj); this.globalEventService.publishNotesStream(noteObj);
this.roleService.addNoteToRoleTimeline(noteObj);
this.webhookService.getActiveWebhooks().then(webhooks => { this.webhookService.getActiveWebhooks().then(webhooks => {
webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note')); webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note'));
for (const webhook of webhooks) { for (const webhook of webhooks) {

View File

@ -1,5 +1,5 @@
import { setTimeout } from 'node:timers/promises'; import { setTimeout } from 'node:timers/promises';
import Redis from 'ioredis'; import * as Redis from 'ioredis';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import { In } from 'typeorm'; import { In } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
@ -66,6 +66,7 @@ export class NotificationService implements OnApplicationShutdown {
@bindThis @bindThis
private postReadAllNotifications(userId: User['id']) { private postReadAllNotifications(userId: User['id']) {
this.globalEventService.publishMainStream(userId, 'readAllNotifications'); this.globalEventService.publishMainStream(userId, 'readAllNotifications');
this.pushNotificationService.pushNotification(userId, 'readAllNotifications', undefined);
} }
@bindThis @bindThis
@ -99,7 +100,7 @@ export class NotificationService implements OnApplicationShutdown {
const redisIdPromise = this.redisClient.xadd( const redisIdPromise = this.redisClient.xadd(
`notificationTimeline:${notifieeId}`, `notificationTimeline:${notifieeId}`,
'MAXLEN', '~', '300', 'MAXLEN', '~', '300',
`${this.idService.parse(notification.id).date.getTime()}-*`, '*',
'data', JSON.stringify(notification)); 'data', JSON.stringify(notification));
const packed = await this.notificationEntityService.pack(notification, notifieeId, {}); const packed = await this.notificationEntityService.pack(notification, notifieeId, {});
@ -110,7 +111,7 @@ export class NotificationService implements OnApplicationShutdown {
// 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する // 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する
setTimeout(2000, 'unread notification', { signal: this.#shutdownController.signal }).then(async () => { setTimeout(2000, 'unread notification', { signal: this.#shutdownController.signal }).then(async () => {
const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${notifieeId}`); const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${notifieeId}`);
if (latestReadNotificationId && (latestReadNotificationId >= await redisIdPromise)) return; if (latestReadNotificationId && (latestReadNotificationId >= (await redisIdPromise)!)) return;
this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed); this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed);
this.pushNotificationService.pushNotification(notifieeId, 'notification', packed); this.pushNotificationService.pushNotification(notifieeId, 'notification', packed);

View File

@ -1,12 +1,14 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import push from 'web-push'; import push from 'web-push';
import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import type { Packed } from '@/misc/json-schema'; import type { Packed } from '@/misc/json-schema';
import { getNoteSummary } from '@/misc/get-note-summary.js'; import { getNoteSummary } from '@/misc/get-note-summary.js';
import type { SwSubscriptionsRepository } from '@/models/index.js'; import type { SwSubscription, SwSubscriptionsRepository } from '@/models/index.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { RedisKVCache } from '@/misc/cache.js';
// Defined also packages/sw/types.ts#L13 // Defined also packages/sw/types.ts#L13
type PushNotificationsTypes = { type PushNotificationsTypes = {
@ -15,6 +17,7 @@ type PushNotificationsTypes = {
antenna: { id: string, name: string }; antenna: { id: string, name: string };
note: Packed<'Note'>; note: Packed<'Note'>;
}; };
'readAllNotifications': undefined;
}; };
// Reduce length because push message servers have character limits // Reduce length because push message servers have character limits
@ -40,15 +43,27 @@ function truncateBody<T extends keyof PushNotificationsTypes>(type: T, body: Pus
@Injectable() @Injectable()
export class PushNotificationService { export class PushNotificationService {
private subscriptionsCache: RedisKVCache<SwSubscription[]>;
constructor( constructor(
@Inject(DI.config) @Inject(DI.config)
private config: Config, private config: Config,
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.swSubscriptionsRepository) @Inject(DI.swSubscriptionsRepository)
private swSubscriptionsRepository: SwSubscriptionsRepository, private swSubscriptionsRepository: SwSubscriptionsRepository,
private metaService: MetaService, private metaService: MetaService,
) { ) {
this.subscriptionsCache = new RedisKVCache<SwSubscription[]>(this.redisClient, 'userSwSubscriptions', {
lifetime: 1000 * 60 * 60 * 1, // 1h
memoryCacheLifetime: 1000 * 60 * 3, // 3m
fetcher: (key) => this.swSubscriptionsRepository.findBy({ userId: key }),
toRedisConverter: (value) => JSON.stringify(value),
fromRedisConverter: (value) => JSON.parse(value),
});
} }
@bindThis @bindThis
@ -62,12 +77,13 @@ export class PushNotificationService {
meta.swPublicKey, meta.swPublicKey,
meta.swPrivateKey); meta.swPrivateKey);
// Fetch const subscriptions = await this.subscriptionsCache.fetch(userId);
const subscriptions = await this.swSubscriptionsRepository.findBy({
userId: userId,
});
for (const subscription of subscriptions) { for (const subscription of subscriptions) {
if ([
'readAllNotifications',
].includes(type) && !subscription.sendReadMessage) continue;
const pushSubscription = { const pushSubscription = {
endpoint: subscription.endpoint, endpoint: subscription.endpoint,
keys: { keys: {

View File

@ -3,7 +3,7 @@ import Bull from 'bull';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import type { Provider } from '@nestjs/common'; import type { Provider } from '@nestjs/common';
import type { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, EndedPollNotificationJobData, WebhookDeliverJobData } from '../queue/types.js'; import type { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, EndedPollNotificationJobData, WebhookDeliverJobData, RelationshipJobData, DbJobMap } from '../queue/types.js';
function q<T>(config: Config, name: string, limitPerSec = -1) { function q<T>(config: Config, name: string, limitPerSec = -1) {
return new Bull<T>(name, { return new Bull<T>(name, {
@ -41,7 +41,8 @@ export type SystemQueue = Bull.Queue<Record<string, unknown>>;
export type EndedPollNotificationQueue = Bull.Queue<EndedPollNotificationJobData>; export type EndedPollNotificationQueue = Bull.Queue<EndedPollNotificationJobData>;
export type DeliverQueue = Bull.Queue<DeliverJobData>; export type DeliverQueue = Bull.Queue<DeliverJobData>;
export type InboxQueue = Bull.Queue<InboxJobData>; export type InboxQueue = Bull.Queue<InboxJobData>;
export type DbQueue = Bull.Queue<DbJobData>; export type DbQueue = Bull.Queue<DbJobData<keyof DbJobMap>>;
export type RelationshipQueue = Bull.Queue<RelationshipJobData>;
export type ObjectStorageQueue = Bull.Queue<ObjectStorageJobData>; export type ObjectStorageQueue = Bull.Queue<ObjectStorageJobData>;
export type WebhookDeliverQueue = Bull.Queue<WebhookDeliverJobData>; export type WebhookDeliverQueue = Bull.Queue<WebhookDeliverJobData>;
@ -75,6 +76,12 @@ const $db: Provider = {
inject: [DI.config], inject: [DI.config],
}; };
const $relationship: Provider = {
provide: 'queue:relationship',
useFactory: (config: Config) => q(config, 'relationship'),
inject: [DI.config],
};
const $objectStorage: Provider = { const $objectStorage: Provider = {
provide: 'queue:objectStorage', provide: 'queue:objectStorage',
useFactory: (config: Config) => q(config, 'objectStorage'), useFactory: (config: Config) => q(config, 'objectStorage'),
@ -96,6 +103,7 @@ const $webhookDeliver: Provider = {
$deliver, $deliver,
$inbox, $inbox,
$db, $db,
$relationship,
$objectStorage, $objectStorage,
$webhookDeliver, $webhookDeliver,
], ],
@ -105,6 +113,7 @@ const $webhookDeliver: Provider = {
$deliver, $deliver,
$inbox, $inbox,
$db, $db,
$relationship,
$objectStorage, $objectStorage,
$webhookDeliver, $webhookDeliver,
], ],

View File

@ -6,9 +6,10 @@ import type { Webhook, webhookEventTypes } from '@/models/entities/Webhook.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js'; import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, RelationshipQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js';
import type { ThinUser } from '../queue/types.js'; import type { DbJobData, RelationshipJobData, ThinUser } from '../queue/types.js';
import type httpSignature from '@peertube/http-signature'; import type httpSignature from '@peertube/http-signature';
import Bull from 'bull';
@Injectable() @Injectable()
export class QueueService { export class QueueService {
@ -21,6 +22,7 @@ export class QueueService {
@Inject('queue:deliver') public deliverQueue: DeliverQueue, @Inject('queue:deliver') public deliverQueue: DeliverQueue,
@Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:inbox') public inboxQueue: InboxQueue,
@Inject('queue:db') public dbQueue: DbQueue, @Inject('queue:db') public dbQueue: DbQueue,
@Inject('queue:relationship') public relationshipQueue: RelationshipQueue,
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
@Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, @Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue,
) {} ) {}
@ -71,7 +73,7 @@ export class QueueService {
@bindThis @bindThis
public createDeleteDriveFilesJob(user: ThinUser) { public createDeleteDriveFilesJob(user: ThinUser) {
return this.dbQueue.add('deleteDriveFiles', { return this.dbQueue.add('deleteDriveFiles', {
user: user, user: { id: user.id },
}, { }, {
removeOnComplete: true, removeOnComplete: true,
removeOnFail: true, removeOnFail: true,
@ -81,7 +83,7 @@ export class QueueService {
@bindThis @bindThis
public createExportCustomEmojisJob(user: ThinUser) { public createExportCustomEmojisJob(user: ThinUser) {
return this.dbQueue.add('exportCustomEmojis', { return this.dbQueue.add('exportCustomEmojis', {
user: user, user: { id: user.id },
}, { }, {
removeOnComplete: true, removeOnComplete: true,
removeOnFail: true, removeOnFail: true,
@ -91,7 +93,7 @@ export class QueueService {
@bindThis @bindThis
public createExportNotesJob(user: ThinUser) { public createExportNotesJob(user: ThinUser) {
return this.dbQueue.add('exportNotes', { return this.dbQueue.add('exportNotes', {
user: user, user: { id: user.id },
}, { }, {
removeOnComplete: true, removeOnComplete: true,
removeOnFail: true, removeOnFail: true,
@ -101,7 +103,7 @@ export class QueueService {
@bindThis @bindThis
public createExportFavoritesJob(user: ThinUser) { public createExportFavoritesJob(user: ThinUser) {
return this.dbQueue.add('exportFavorites', { return this.dbQueue.add('exportFavorites', {
user: user, user: { id: user.id },
}, { }, {
removeOnComplete: true, removeOnComplete: true,
removeOnFail: true, removeOnFail: true,
@ -111,7 +113,7 @@ export class QueueService {
@bindThis @bindThis
public createExportFollowingJob(user: ThinUser, excludeMuting = false, excludeInactive = false) { public createExportFollowingJob(user: ThinUser, excludeMuting = false, excludeInactive = false) {
return this.dbQueue.add('exportFollowing', { return this.dbQueue.add('exportFollowing', {
user: user, user: { id: user.id },
excludeMuting, excludeMuting,
excludeInactive, excludeInactive,
}, { }, {
@ -123,7 +125,7 @@ export class QueueService {
@bindThis @bindThis
public createExportMuteJob(user: ThinUser) { public createExportMuteJob(user: ThinUser) {
return this.dbQueue.add('exportMuting', { return this.dbQueue.add('exportMuting', {
user: user, user: { id: user.id },
}, { }, {
removeOnComplete: true, removeOnComplete: true,
removeOnFail: true, removeOnFail: true,
@ -133,7 +135,7 @@ export class QueueService {
@bindThis @bindThis
public createExportBlockingJob(user: ThinUser) { public createExportBlockingJob(user: ThinUser) {
return this.dbQueue.add('exportBlocking', { return this.dbQueue.add('exportBlocking', {
user: user, user: { id: user.id },
}, { }, {
removeOnComplete: true, removeOnComplete: true,
removeOnFail: true, removeOnFail: true,
@ -143,7 +145,7 @@ export class QueueService {
@bindThis @bindThis
public createExportUserListsJob(user: ThinUser) { public createExportUserListsJob(user: ThinUser) {
return this.dbQueue.add('exportUserLists', { return this.dbQueue.add('exportUserLists', {
user: user, user: { id: user.id },
}, { }, {
removeOnComplete: true, removeOnComplete: true,
removeOnFail: true, removeOnFail: true,
@ -153,7 +155,7 @@ export class QueueService {
@bindThis @bindThis
public createImportFollowingJob(user: ThinUser, fileId: DriveFile['id']) { public createImportFollowingJob(user: ThinUser, fileId: DriveFile['id']) {
return this.dbQueue.add('importFollowing', { return this.dbQueue.add('importFollowing', {
user: user, user: { id: user.id },
fileId: fileId, fileId: fileId,
}, { }, {
removeOnComplete: true, removeOnComplete: true,
@ -161,10 +163,16 @@ export class QueueService {
}); });
} }
@bindThis
public createImportFollowingToDbJob(user: ThinUser, targets: string[]) {
const jobs = targets.map(rel => this.generateToDbJobData('importFollowingToDb', { user, target: rel }));
return this.dbQueue.addBulk(jobs);
}
@bindThis @bindThis
public createImportMutingJob(user: ThinUser, fileId: DriveFile['id']) { public createImportMutingJob(user: ThinUser, fileId: DriveFile['id']) {
return this.dbQueue.add('importMuting', { return this.dbQueue.add('importMuting', {
user: user, user: { id: user.id },
fileId: fileId, fileId: fileId,
}, { }, {
removeOnComplete: true, removeOnComplete: true,
@ -175,7 +183,7 @@ export class QueueService {
@bindThis @bindThis
public createImportBlockingJob(user: ThinUser, fileId: DriveFile['id']) { public createImportBlockingJob(user: ThinUser, fileId: DriveFile['id']) {
return this.dbQueue.add('importBlocking', { return this.dbQueue.add('importBlocking', {
user: user, user: { id: user.id },
fileId: fileId, fileId: fileId,
}, { }, {
removeOnComplete: true, removeOnComplete: true,
@ -183,10 +191,32 @@ export class QueueService {
}); });
} }
@bindThis
public createImportBlockingToDbJob(user: ThinUser, targets: string[]) {
const jobs = targets.map(rel => this.generateToDbJobData('importBlockingToDb', { user, target: rel }));
return this.dbQueue.addBulk(jobs);
}
@bindThis
private generateToDbJobData<T extends 'importFollowingToDb' | 'importBlockingToDb', D extends DbJobData<T>>(name: T, data: D): {
name: string,
data: D,
opts: Bull.JobOptions,
} {
return {
name,
data,
opts: {
removeOnComplete: true,
removeOnFail: true,
},
};
}
@bindThis @bindThis
public createImportUserListsJob(user: ThinUser, fileId: DriveFile['id']) { public createImportUserListsJob(user: ThinUser, fileId: DriveFile['id']) {
return this.dbQueue.add('importUserLists', { return this.dbQueue.add('importUserLists', {
user: user, user: { id: user.id },
fileId: fileId, fileId: fileId,
}, { }, {
removeOnComplete: true, removeOnComplete: true,
@ -197,7 +227,7 @@ export class QueueService {
@bindThis @bindThis
public createImportCustomEmojisJob(user: ThinUser, fileId: DriveFile['id']) { public createImportCustomEmojisJob(user: ThinUser, fileId: DriveFile['id']) {
return this.dbQueue.add('importCustomEmojis', { return this.dbQueue.add('importCustomEmojis', {
user: user, user: { id: user.id },
fileId: fileId, fileId: fileId,
}, { }, {
removeOnComplete: true, removeOnComplete: true,
@ -208,7 +238,7 @@ export class QueueService {
@bindThis @bindThis
public createDeleteAccountJob(user: ThinUser, opts: { soft?: boolean; } = {}) { public createDeleteAccountJob(user: ThinUser, opts: { soft?: boolean; } = {}) {
return this.dbQueue.add('deleteAccount', { return this.dbQueue.add('deleteAccount', {
user: user, user: { id: user.id },
soft: opts.soft, soft: opts.soft,
}, { }, {
removeOnComplete: true, removeOnComplete: true,
@ -216,6 +246,51 @@ export class QueueService {
}); });
} }
@bindThis
public createFollowJob(followings: { from: ThinUser, to: ThinUser, requestId?: string, silent?: boolean }[]) {
const jobs = followings.map(rel => this.generateRelationshipJobData('follow', rel));
return this.relationshipQueue.addBulk(jobs);
}
@bindThis
public createUnfollowJob(followings: { from: ThinUser, to: ThinUser, requestId?: string }[]) {
const jobs = followings.map(rel => this.generateRelationshipJobData('unfollow', rel));
return this.relationshipQueue.addBulk(jobs);
}
@bindThis
public createBlockJob(blockings: { from: ThinUser, to: ThinUser, silent?: boolean }[]) {
const jobs = blockings.map(rel => this.generateRelationshipJobData('block', rel));
return this.relationshipQueue.addBulk(jobs);
}
@bindThis
public createUnblockJob(blockings: { from: ThinUser, to: ThinUser, silent?: boolean }[]) {
const jobs = blockings.map(rel => this.generateRelationshipJobData('unblock', rel));
return this.relationshipQueue.addBulk(jobs);
}
@bindThis
private generateRelationshipJobData(name: 'follow' | 'unfollow' | 'block' | 'unblock', data: RelationshipJobData): {
name: string,
data: RelationshipJobData,
opts: Bull.JobOptions,
} {
return {
name,
data: {
from: { id: data.from.id },
to: { id: data.to.id },
silent: data.silent,
requestId: data.requestId,
},
opts: {
removeOnComplete: true,
removeOnFail: true,
},
};
}
@bindThis @bindThis
public createDeleteObjectStorageFileJob(key: string) { public createDeleteObjectStorageFileJob(key: string) {
return this.objectStorageQueue.add('deleteFile', { return this.objectStorageQueue.add('deleteFile', {

View File

@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis'; import * as Redis from 'ioredis';
import { In } from 'typeorm'; import { In } from 'typeorm';
import type { Role, RoleAssignment, RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js'; import type { Role, RoleAssignment, RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js';
import { MemoryKVCache, MemorySingleCache } from '@/misc/cache.js'; import { MemoryKVCache, MemorySingleCache } from '@/misc/cache.js';
@ -13,6 +13,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { StreamMessages } from '@/server/api/stream/types.js'; import { StreamMessages } from '@/server/api/stream/types.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import type { Packed } from '@/misc/json-schema';
import type { OnApplicationShutdown } from '@nestjs/common'; import type { OnApplicationShutdown } from '@nestjs/common';
export type RolePolicies = { export type RolePolicies = {
@ -64,6 +65,9 @@ export class RoleService implements OnApplicationShutdown {
public static NotAssignedError = class extends Error {}; public static NotAssignedError = class extends Error {};
constructor( constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.redisForSub) @Inject(DI.redisForSub)
private redisForSub: Redis.Redis, private redisForSub: Redis.Redis,
@ -398,6 +402,25 @@ export class RoleService implements OnApplicationShutdown {
this.globalEventService.publishInternalEvent('userRoleUnassigned', existing); this.globalEventService.publishInternalEvent('userRoleUnassigned', existing);
} }
@bindThis
public async addNoteToRoleTimeline(note: Packed<'Note'>): Promise<void> {
const roles = await this.getUserRoles(note.userId);
const redisPipeline = this.redisClient.pipeline();
for (const role of roles) {
redisPipeline.xadd(
`roleTimeline:${role.id}`,
'MAXLEN', '~', '1000',
'*',
'note', note.id);
this.globalEventService.publishRoleTimelineStream(role.id, 'note', note);
}
redisPipeline.exec();
}
@bindThis @bindThis
public onApplicationShutdown(signal?: string | undefined) { public onApplicationShutdown(signal?: string | undefined) {
this.redisForSub.off('message', this.onMessage); this.redisForSub.off('message', this.onMessage);

View File

@ -54,12 +54,12 @@ export class UserBlockingService implements OnModuleInit {
} }
@bindThis @bindThis
public async block(blocker: User, blockee: User) { public async block(blocker: User, blockee: User, silent = false) {
await Promise.all([ await Promise.all([
this.cancelRequest(blocker, blockee), this.cancelRequest(blocker, blockee, silent),
this.cancelRequest(blockee, blocker), this.cancelRequest(blockee, blocker, silent),
this.userFollowingService.unfollow(blocker, blockee), this.userFollowingService.unfollow(blocker, blockee, silent),
this.userFollowingService.unfollow(blockee, blocker), this.userFollowingService.unfollow(blockee, blocker, silent),
this.removeFromList(blockee, blocker), this.removeFromList(blockee, blocker),
]); ]);
@ -89,7 +89,7 @@ export class UserBlockingService implements OnModuleInit {
} }
@bindThis @bindThis
private async cancelRequest(follower: User, followee: User) { private async cancelRequest(follower: User, followee: User, silent = false) {
const request = await this.followRequestsRepository.findOneBy({ const request = await this.followRequestsRepository.findOneBy({
followeeId: followee.id, followeeId: followee.id,
followerId: follower.id, followerId: follower.id,
@ -110,7 +110,7 @@ export class UserBlockingService implements OnModuleInit {
}).then(packed => this.globalEventService.publishMainStream(followee.id, 'meUpdated', packed)); }).then(packed => this.globalEventService.publishMainStream(followee.id, 'meUpdated', packed));
} }
if (this.userEntityService.isLocalUser(follower)) { if (this.userEntityService.isLocalUser(follower) && !silent) {
this.userEntityService.pack(followee, follower, { this.userEntityService.pack(followee, follower, {
detail: true, detail: true,
}).then(async packed => { }).then(async packed => {

View File

@ -20,6 +20,7 @@ import { bindThis } from '@/decorators.js';
import { UserBlockingService } from '@/core/UserBlockingService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import type { Config } from '@/config.js';
import Logger from '../logger.js'; import Logger from '../logger.js';
const logger = new Logger('following/create'); const logger = new Logger('following/create');
@ -44,6 +45,9 @@ export class UserFollowingService implements OnModuleInit {
constructor( constructor(
private moduleRef: ModuleRef, private moduleRef: ModuleRef,
@Inject(DI.config)
private config: Config,
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@ -79,7 +83,7 @@ export class UserFollowingService implements OnModuleInit {
} }
@bindThis @bindThis
public async follow(_follower: { id: User['id'] }, _followee: { id: User['id'] }, requestId?: string): Promise<void> { public async follow(_follower: { id: User['id'] }, _followee: { id: User['id'] }, requestId?: string, silent = false): Promise<void> {
const [follower, followee] = await Promise.all([ const [follower, followee] = await Promise.all([
this.usersRepository.findOneByOrFail({ id: _follower.id }), this.usersRepository.findOneByOrFail({ id: _follower.id }),
this.usersRepository.findOneByOrFail({ id: _followee.id }), this.usersRepository.findOneByOrFail({ id: _followee.id }),
@ -139,7 +143,7 @@ export class UserFollowingService implements OnModuleInit {
} }
} }
await this.insertFollowingDoc(followee, follower); await this.insertFollowingDoc(followee, follower, silent);
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee)); const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee));
@ -155,6 +159,7 @@ export class UserFollowingService implements OnModuleInit {
follower: { follower: {
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'] id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']
}, },
silent = false,
): Promise<void> { ): Promise<void> {
if (follower.id === followee.id) return; if (follower.id === followee.id) return;
@ -233,7 +238,7 @@ export class UserFollowingService implements OnModuleInit {
this.perUserFollowingChart.update(follower, followee, true); this.perUserFollowingChart.update(follower, followee, true);
// Publish follow event // Publish follow event
if (this.userEntityService.isLocalUser(follower)) { if (this.userEntityService.isLocalUser(follower) && !silent) {
this.userEntityService.pack(followee.id, follower, { this.userEntityService.pack(followee.id, follower, {
detail: true, detail: true,
}).then(async packed => { }).then(async packed => {
@ -410,7 +415,7 @@ export class UserFollowingService implements OnModuleInit {
} }
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee)); const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee, requestId ?? `${this.config.url}/follows/${followRequest.id}`));
this.queueService.deliver(follower, content, followee.inbox, false); this.queueService.deliver(follower, content, followee.inbox, false);
} }
} }

View File

@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis'; import * as Redis from 'ioredis';
import type { User } from '@/models/entities/User.js'; import type { User } from '@/models/entities/User.js';
import type { UserKeypairsRepository } from '@/models/index.js'; import type { UserKeypairsRepository } from '@/models/index.js';
import { RedisKVCache } from '@/misc/cache.js'; import { RedisKVCache } from '@/misc/cache.js';

View File

@ -11,6 +11,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ProxyAccountService } from '@/core/ProxyAccountService.js'; import { ProxyAccountService } from '@/core/ProxyAccountService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { QueueService } from '@/core/QueueService.js';
@Injectable() @Injectable()
export class UserListService { export class UserListService {
@ -29,6 +30,7 @@ export class UserListService {
private roleService: RoleService, private roleService: RoleService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private proxyAccountService: ProxyAccountService, private proxyAccountService: ProxyAccountService,
private queueService: QueueService,
) { ) {
} }
@ -54,7 +56,7 @@ export class UserListService {
if (this.userEntityService.isRemoteUser(target)) { if (this.userEntityService.isRemoteUser(target)) {
const proxy = await this.proxyAccountService.fetch(); const proxy = await this.proxyAccountService.fetch();
if (proxy) { if (proxy) {
this.userFollowingService.follow(proxy, target); this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: target.id } }]);
} }
} }
} }

View File

@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis'; import * as Redis from 'ioredis';
import type { WebhooksRepository } from '@/models/index.js'; import type { WebhooksRepository } from '@/models/index.js';
import type { Webhook } from '@/models/entities/Webhook.js'; import type { Webhook } from '@/models/entities/Webhook.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';

View File

@ -71,7 +71,7 @@ export class ApNoteService {
} }
@bindThis @bindThis
public validateNote(object: any, uri: string) { public validateNote(object: IObject, uri: string) {
const expectHost = this.utilityService.extractDbHost(uri); const expectHost = this.utilityService.extractDbHost(uri);
if (object == null) { if (object == null) {
@ -86,8 +86,9 @@ export class ApNoteService {
return new Error(`invalid Note: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`); return new Error(`invalid Note: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`);
} }
if (object.attributedTo && this.utilityService.extractDbHost(getOneApId(object.attributedTo)) !== expectHost) { const actualHost = object.attributedTo && this.utilityService.extractDbHost(getOneApId(object.attributedTo));
return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.attributedTo)}`); if (object.attributedTo && actualHost !== expectHost) {
return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`);
} }
return null; return null;

View File

@ -84,7 +84,7 @@ export class ChannelEntityService {
} : {}), } : {}),
...(detailed ? { ...(detailed ? {
pinnedNotes: await this.noteEntityService.packMany(pinnedNotes, me), pinnedNotes: (await this.noteEntityService.packMany(pinnedNotes, me)).sort((a, b) => channel.pinnedNoteIds.indexOf(a.id) - channel.pinnedNoteIds.indexOf(b.id)),
} : {}), } : {}),
}; };
} }

View File

@ -1,6 +1,6 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { In, Not } from 'typeorm'; import { In, Not } from 'typeorm';
import Redis from 'ioredis'; import * as Redis from 'ioredis';
import Ajv from 'ajv'; import Ajv from 'ajv';
import { ModuleRef } from '@nestjs/core'; import { ModuleRef } from '@nestjs/core';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
@ -12,7 +12,7 @@ import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js';
import type { Instance } from '@/models/entities/Instance.js'; import type { Instance } from '@/models/entities/Instance.js';
import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js';
import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js'; import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js';
import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, PagesRepository, UserProfile, RenoteMutingsRepository } from '@/models/index.js'; import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, PagesRepository, UserProfile, RenoteMutingsRepository, UserMemoRepository } from '@/models/index.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
@ -114,6 +114,9 @@ export class UserEntityService implements OnModuleInit {
@Inject(DI.pagesRepository) @Inject(DI.pagesRepository)
private pagesRepository: PagesRepository, private pagesRepository: PagesRepository,
@Inject(DI.userMemosRepository)
private userMemosRepository: UserMemoRepository,
//private noteEntityService: NoteEntityService, //private noteEntityService: NoteEntityService,
//private driveFileEntityService: DriveFileEntityService, //private driveFileEntityService: DriveFileEntityService,
//private pageEntityService: PageEntityService, //private pageEntityService: PageEntityService,
@ -409,6 +412,10 @@ export class UserEntityService implements OnModuleInit {
isAdministrator: role.isAdministrator, isAdministrator: role.isAdministrator,
displayOrder: role.displayOrder, displayOrder: role.displayOrder,
}))), }))),
memo: meId == null ? null : await this.userMemosRepository.findOneBy({
userId: meId,
targetUserId: user.id,
}).then(row => row?.memo ?? null),
} : {}), } : {}),
...(opts.detail && isMe ? { ...(opts.detail && isMe ? {

View File

@ -70,5 +70,6 @@ export const DI = {
roleAssignmentsRepository: Symbol('roleAssignmentsRepository'), roleAssignmentsRepository: Symbol('roleAssignmentsRepository'),
flashsRepository: Symbol('flashsRepository'), flashsRepository: Symbol('flashsRepository'),
flashLikesRepository: Symbol('flashLikesRepository'), flashLikesRepository: Symbol('flashLikesRepository'),
userMemosRepository: Symbol('userMemosRepository'),
//#endregion //#endregion
}; };

View File

@ -1,4 +1,4 @@
import Redis from 'ioredis'; import * as Redis from 'ioredis';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
export class RedisKVCache<T> { export class RedisKVCache<T> {
@ -8,7 +8,7 @@ export class RedisKVCache<T> {
private memoryCache: MemoryKVCache<T>; private memoryCache: MemoryKVCache<T>;
private fetcher: (key: string) => Promise<T>; private fetcher: (key: string) => Promise<T>;
private toRedisConverter: (value: T) => string; private toRedisConverter: (value: T) => string;
private fromRedisConverter: (value: string) => T | undefined; // undefined means no cache private fromRedisConverter: (value: string) => T | undefined;
constructor(redisClient: RedisKVCache<T>['redisClient'], name: RedisKVCache<T>['name'], opts: { constructor(redisClient: RedisKVCache<T>['redisClient'], name: RedisKVCache<T>['name'], opts: {
lifetime: RedisKVCache<T>['lifetime']; lifetime: RedisKVCache<T>['lifetime'];
@ -38,7 +38,7 @@ export class RedisKVCache<T> {
await this.redisClient.set( await this.redisClient.set(
`kvcache:${this.name}:${key}`, `kvcache:${this.name}:${key}`,
this.toRedisConverter(value), this.toRedisConverter(value),
'ex', Math.round(this.lifetime / 1000), 'EX', Math.round(this.lifetime / 1000),
); );
} }
} }
@ -92,7 +92,7 @@ export class RedisSingleCache<T> {
private memoryCache: MemorySingleCache<T>; private memoryCache: MemorySingleCache<T>;
private fetcher: () => Promise<T>; private fetcher: () => Promise<T>;
private toRedisConverter: (value: T) => string; private toRedisConverter: (value: T) => string;
private fromRedisConverter: (value: string) => T | undefined; // undefined means no cache private fromRedisConverter: (value: string) => T | undefined;
constructor(redisClient: RedisSingleCache<T>['redisClient'], name: RedisSingleCache<T>['name'], opts: { constructor(redisClient: RedisSingleCache<T>['redisClient'], name: RedisSingleCache<T>['name'], opts: {
lifetime: RedisSingleCache<T>['lifetime']; lifetime: RedisSingleCache<T>['lifetime'];
@ -122,7 +122,7 @@ export class RedisSingleCache<T> {
await this.redisClient.set( await this.redisClient.set(
`singlecache:${this.name}`, `singlecache:${this.name}`,
this.toRedisConverter(value), this.toRedisConverter(value),
'ex', Math.round(this.lifetime / 1000), 'EX', Math.round(this.lifetime / 1000),
); );
} }
} }

View File

@ -1,6 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite } from './index.js'; import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite, UserMemo } from './index.js';
import type { DataSource } from 'typeorm'; import type { DataSource } from 'typeorm';
import type { Provider } from '@nestjs/common'; import type { Provider } from '@nestjs/common';
@ -388,6 +388,12 @@ const $roleAssignmentsRepository: Provider = {
inject: [DI.db], inject: [DI.db],
}; };
const $userMemosRepository: Provider = {
provide: DI.userMemosRepository,
useFactory: (db: DataSource) => db.getRepository(UserMemo),
inject: [DI.db],
};
@Module({ @Module({
imports: [ imports: [
], ],
@ -456,6 +462,7 @@ const $roleAssignmentsRepository: Provider = {
$roleAssignmentsRepository, $roleAssignmentsRepository,
$flashsRepository, $flashsRepository,
$flashLikesRepository, $flashLikesRepository,
$userMemosRepository,
], ],
exports: [ exports: [
$usersRepository, $usersRepository,
@ -522,6 +529,7 @@ const $roleAssignmentsRepository: Provider = {
$roleAssignmentsRepository, $roleAssignmentsRepository,
$flashsRepository, $flashsRepository,
$flashLikesRepository, $flashLikesRepository,
$userMemosRepository,
], ],
}) })
export class RepositoryModule {} export class RepositoryModule {}

View File

@ -0,0 +1,42 @@
import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm';
import { id } from '../id.js';
import { User } from './User.js';
@Entity()
@Index(['userId', 'targetUserId'], { unique: true })
export class UserMemo {
@PrimaryColumn(id())
public id: string;
@Index()
@Column({
...id(),
comment: 'The ID of author.',
})
public userId: User['id'];
@ManyToOne(type => User, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user: User | null;
@Index()
@Column({
...id(),
comment: 'The ID of target user.',
})
public targetUserId: User['id'];
@ManyToOne(type => User, {
onDelete: 'CASCADE',
})
@JoinColumn()
public targetUser: User | null;
@Column('varchar', {
length: 2048,
comment: 'Memo.',
})
public memo: string;
}

View File

@ -55,6 +55,7 @@ import { UserPending } from '@/models/entities/UserPending.js';
import { UserProfile } from '@/models/entities/UserProfile.js'; import { UserProfile } from '@/models/entities/UserProfile.js';
import { UserPublickey } from '@/models/entities/UserPublickey.js'; import { UserPublickey } from '@/models/entities/UserPublickey.js';
import { UserSecurityKey } from '@/models/entities/UserSecurityKey.js'; import { UserSecurityKey } from '@/models/entities/UserSecurityKey.js';
import { UserMemo } from '@/models/entities/UserMemo.js';
import { Webhook } from '@/models/entities/Webhook.js'; import { Webhook } from '@/models/entities/Webhook.js';
import { Channel } from '@/models/entities/Channel.js'; import { Channel } from '@/models/entities/Channel.js';
import { RetentionAggregation } from '@/models/entities/RetentionAggregation.js'; import { RetentionAggregation } from '@/models/entities/RetentionAggregation.js';
@ -129,6 +130,7 @@ export {
RoleAssignment, RoleAssignment,
Flash, Flash,
FlashLike, FlashLike,
UserMemo,
}; };
export type AbuseUserReportsRepository = Repository<AbuseUserReport>; export type AbuseUserReportsRepository = Repository<AbuseUserReport>;
@ -195,3 +197,4 @@ export type RolesRepository = Repository<Role>;
export type RoleAssignmentsRepository = Repository<RoleAssignment>; export type RoleAssignmentsRepository = Repository<RoleAssignment>;
export type FlashsRepository = Repository<Flash>; export type FlashsRepository = Repository<Flash>;
export type FlashLikesRepository = Repository<FlashLike>; export type FlashLikesRepository = Repository<FlashLike>;
export type UserMemoRepository = Repository<UserMemo>;

View File

@ -250,6 +250,10 @@ export const packedUserDetailedNotMeOnlySchema = {
type: 'boolean', type: 'boolean',
nullable: false, optional: true, nullable: false, optional: true,
}, },
memo: {
type: 'string',
nullable: false, optional: true,
},
//#endregion //#endregion
}, },
} as const; } as const;

View File

@ -70,6 +70,7 @@ import { Role } from '@/models/entities/Role.js';
import { RoleAssignment } from '@/models/entities/RoleAssignment.js'; import { RoleAssignment } from '@/models/entities/RoleAssignment.js';
import { Flash } from '@/models/entities/Flash.js'; import { Flash } from '@/models/entities/Flash.js';
import { FlashLike } from '@/models/entities/FlashLike.js'; import { FlashLike } from '@/models/entities/FlashLike.js';
import { UserMemo } from '@/models/entities/UserMemo.js';
import { Config } from '@/config.js'; import { Config } from '@/config.js';
import MisskeyLogger from '@/logger.js'; import MisskeyLogger from '@/logger.js';
@ -183,6 +184,7 @@ export const entities = [
RoleAssignment, RoleAssignment,
Flash, Flash,
FlashLike, FlashLike,
UserMemo,
...charts, ...charts,
]; ];

View File

@ -52,8 +52,10 @@ export class DbQueueProcessorsService {
q.process('exportBlocking', (job, done) => this.exportBlockingProcessorService.process(job, done)); q.process('exportBlocking', (job, done) => this.exportBlockingProcessorService.process(job, done));
q.process('exportUserLists', (job, done) => this.exportUserListsProcessorService.process(job, done)); q.process('exportUserLists', (job, done) => this.exportUserListsProcessorService.process(job, done));
q.process('importFollowing', (job, done) => this.importFollowingProcessorService.process(job, done)); q.process('importFollowing', (job, done) => this.importFollowingProcessorService.process(job, done));
q.process('importFollowingToDb', (job) => this.importFollowingProcessorService.processDb(job));
q.process('importMuting', (job, done) => this.importMutingProcessorService.process(job, done)); q.process('importMuting', (job, done) => this.importMutingProcessorService.process(job, done));
q.process('importBlocking', (job, done) => this.importBlockingProcessorService.process(job, done)); q.process('importBlocking', (job, done) => this.importBlockingProcessorService.process(job, done));
q.process('importBlockingToDb', (job) => this.importBlockingProcessorService.processDb(job));
q.process('importUserLists', (job, done) => this.importUserListsProcessorService.process(job, done)); q.process('importUserLists', (job, done) => this.importUserListsProcessorService.process(job, done));
q.process('importCustomEmojis', (job, done) => this.importCustomEmojisProcessorService.process(job, done)); q.process('importCustomEmojis', (job, done) => this.importCustomEmojisProcessorService.process(job, done));
q.process('deleteAccount', (job) => this.deleteAccountProcessorService.process(job)); q.process('deleteAccount', (job) => this.deleteAccountProcessorService.process(job));

View File

@ -4,6 +4,7 @@ import { GlobalModule } from '@/GlobalModule.js';
import { QueueLoggerService } from './QueueLoggerService.js'; import { QueueLoggerService } from './QueueLoggerService.js';
import { QueueProcessorService } from './QueueProcessorService.js'; import { QueueProcessorService } from './QueueProcessorService.js';
import { DbQueueProcessorsService } from './DbQueueProcessorsService.js'; import { DbQueueProcessorsService } from './DbQueueProcessorsService.js';
import { RelationshipQueueProcessorsService } from './RelationshipQueueProcessorsService.js';
import { ObjectStorageQueueProcessorsService } from './ObjectStorageQueueProcessorsService.js'; import { ObjectStorageQueueProcessorsService } from './ObjectStorageQueueProcessorsService.js';
import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
@ -32,6 +33,7 @@ import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessor
import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js'; import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js';
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js'; import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js';
import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js';
@Module({ @Module({
imports: [ imports: [
@ -61,9 +63,11 @@ import { ExportFavoritesProcessorService } from './processors/ExportFavoritesPro
DeleteAccountProcessorService, DeleteAccountProcessorService,
DeleteFileProcessorService, DeleteFileProcessorService,
CleanRemoteFilesProcessorService, CleanRemoteFilesProcessorService,
RelationshipProcessorService,
SystemQueueProcessorsService, SystemQueueProcessorsService,
ObjectStorageQueueProcessorsService, ObjectStorageQueueProcessorsService,
DbQueueProcessorsService, DbQueueProcessorsService,
RelationshipQueueProcessorsService,
WebhookDeliverProcessorService, WebhookDeliverProcessorService,
EndedPollNotificationProcessorService, EndedPollNotificationProcessorService,
DeliverProcessorService, DeliverProcessorService,

View File

@ -13,6 +13,7 @@ import { EndedPollNotificationProcessorService } from './processors/EndedPollNot
import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
import { InboxProcessorService } from './processors/InboxProcessorService.js'; import { InboxProcessorService } from './processors/InboxProcessorService.js';
import { QueueLoggerService } from './QueueLoggerService.js'; import { QueueLoggerService } from './QueueLoggerService.js';
import { RelationshipQueueProcessorsService } from './RelationshipQueueProcessorsService.js';
@Injectable() @Injectable()
export class QueueProcessorService { export class QueueProcessorService {
@ -27,6 +28,7 @@ export class QueueProcessorService {
private systemQueueProcessorsService: SystemQueueProcessorsService, private systemQueueProcessorsService: SystemQueueProcessorsService,
private objectStorageQueueProcessorsService: ObjectStorageQueueProcessorsService, private objectStorageQueueProcessorsService: ObjectStorageQueueProcessorsService,
private dbQueueProcessorsService: DbQueueProcessorsService, private dbQueueProcessorsService: DbQueueProcessorsService,
private relationshipQueueProcessorsService: RelationshipQueueProcessorsService,
private webhookDeliverProcessorService: WebhookDeliverProcessorService, private webhookDeliverProcessorService: WebhookDeliverProcessorService,
private endedPollNotificationProcessorService: EndedPollNotificationProcessorService, private endedPollNotificationProcessorService: EndedPollNotificationProcessorService,
private deliverProcessorService: DeliverProcessorService, private deliverProcessorService: DeliverProcessorService,
@ -58,6 +60,7 @@ export class QueueProcessorService {
const webhookLogger = this.logger.createSubLogger('webhook'); const webhookLogger = this.logger.createSubLogger('webhook');
const inboxLogger = this.logger.createSubLogger('inbox'); const inboxLogger = this.logger.createSubLogger('inbox');
const dbLogger = this.logger.createSubLogger('db'); const dbLogger = this.logger.createSubLogger('db');
const relationshipLogger = this.logger.createSubLogger('relationship');
const objectStorageLogger = this.logger.createSubLogger('objectStorage'); const objectStorageLogger = this.logger.createSubLogger('objectStorage');
this.queueService.systemQueue this.queueService.systemQueue
@ -92,6 +95,14 @@ export class QueueProcessorService {
.on('error', (job: any, err: Error) => dbLogger.error(`error ${err}`, { job, e: renderError(err) })) .on('error', (job: any, err: Error) => dbLogger.error(`error ${err}`, { job, e: renderError(err) }))
.on('stalled', (job) => dbLogger.warn(`stalled id=${job.id}`)); .on('stalled', (job) => dbLogger.warn(`stalled id=${job.id}`));
this.queueService.relationshipQueue
.on('waiting', (jobId) => relationshipLogger.debug(`waiting id=${jobId}`))
.on('active', (job) => relationshipLogger.debug(`active id=${job.id}`))
.on('completed', (job, result) => relationshipLogger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err) => relationshipLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) }))
.on('error', (job: any, err: Error) => relationshipLogger.error(`error ${err}`, { job, e: renderError(err) }))
.on('stalled', (job) => relationshipLogger.warn(`stalled id=${job.id}`));
this.queueService.objectStorageQueue this.queueService.objectStorageQueue
.on('waiting', (jobId) => objectStorageLogger.debug(`waiting id=${jobId}`)) .on('waiting', (jobId) => objectStorageLogger.debug(`waiting id=${jobId}`))
.on('active', (job) => objectStorageLogger.debug(`active id=${job.id}`)) .on('active', (job) => objectStorageLogger.debug(`active id=${job.id}`))
@ -113,6 +124,7 @@ export class QueueProcessorService {
this.queueService.endedPollNotificationQueue.process((job, done) => this.endedPollNotificationProcessorService.process(job, done)); this.queueService.endedPollNotificationQueue.process((job, done) => this.endedPollNotificationProcessorService.process(job, done));
this.queueService.webhookDeliverQueue.process(64, (job) => this.webhookDeliverProcessorService.process(job)); this.queueService.webhookDeliverQueue.process(64, (job) => this.webhookDeliverProcessorService.process(job));
this.dbQueueProcessorsService.start(this.queueService.dbQueue); this.dbQueueProcessorsService.start(this.queueService.dbQueue);
this.relationshipQueueProcessorsService.start(this.queueService.relationshipQueue);
this.objectStorageQueueProcessorsService.start(this.queueService.objectStorageQueue); this.objectStorageQueueProcessorsService.start(this.queueService.objectStorageQueue);
this.queueService.systemQueue.add('tickCharts', { this.queueService.systemQueue.add('tickCharts', {

View File

@ -0,0 +1,26 @@
import { Inject, Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js';
import type Bull from 'bull';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
@Injectable()
export class RelationshipQueueProcessorsService {
constructor(
@Inject(DI.config)
private config: Config,
private relationshipProcessorService: RelationshipProcessorService,
) {
}
@bindThis
public start(q: Bull.Queue): void {
const maxJobs = (this.config.deliverJobConcurrency ?? 128) / 4; // conservative?
q.process('follow', maxJobs, (job) => this.relationshipProcessorService.processFollow(job));
q.process('unfollow', maxJobs, (job) => this.relationshipProcessorService.processUnfollow(job));
q.process('block', maxJobs, (job) => this.relationshipProcessorService.processBlock(job));
q.process('unblock', maxJobs, (job) => this.relationshipProcessorService.processUnblock(job));
}
}

View File

@ -7,7 +7,7 @@ import type Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js'; import { DriveService } from '@/core/DriveService.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull'; import type Bull from 'bull';
import type { DbUserJobData } from '../types.js'; import type { DbJobDataWithUser } from '../types.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
@Injectable() @Injectable()
@ -31,7 +31,7 @@ export class DeleteDriveFilesProcessorService {
} }
@bindThis @bindThis
public async process(job: Bull.Job<DbUserJobData>, done: () => void): Promise<void> { public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
this.logger.info(`Deleting drive files of ${job.data.user.id} ...`); this.logger.info(`Deleting drive files of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); const user = await this.usersRepository.findOneBy({ id: job.data.user.id });

View File

@ -11,7 +11,7 @@ import { createTemp } from '@/misc/create-temp.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull'; import type Bull from 'bull';
import type { DbUserJobData } from '../types.js'; import type { DbJobDataWithUser } from '../types.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
@Injectable() @Injectable()
@ -36,7 +36,7 @@ export class ExportBlockingProcessorService {
} }
@bindThis @bindThis
public async process(job: Bull.Job<DbUserJobData>, done: () => void): Promise<void> { public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
this.logger.info(`Exporting blocking of ${job.data.user.id} ...`); this.logger.info(`Exporting blocking of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
@ -106,7 +106,7 @@ export class ExportBlockingProcessorService {
this.logger.succ(`Exported to: ${path}`); this.logger.succ(`Exported to: ${path}`);
const fileName = 'blocking-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; const fileName = 'blocking-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv';
const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' });
this.logger.succ(`Exported to: ${driveFile.id}`); this.logger.succ(`Exported to: ${driveFile.id}`);
} finally { } finally {

View File

@ -13,7 +13,7 @@ import type { Note } from '@/models/entities/Note.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull'; import type Bull from 'bull';
import type { DbUserJobData } from '../types.js'; import type { DbJobDataWithUser } from '../types.js';
@Injectable() @Injectable()
export class ExportFavoritesProcessorService { export class ExportFavoritesProcessorService {
@ -42,7 +42,7 @@ export class ExportFavoritesProcessorService {
} }
@bindThis @bindThis
public async process(job: Bull.Job<DbUserJobData>, done: () => void): Promise<void> { public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
this.logger.info(`Exporting favorites of ${job.data.user.id} ...`); this.logger.info(`Exporting favorites of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
@ -121,7 +121,7 @@ export class ExportFavoritesProcessorService {
this.logger.succ(`Exported to: ${path}`); this.logger.succ(`Exported to: ${path}`);
const fileName = 'favorites-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json'; const fileName = 'favorites-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json';
const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' });
this.logger.succ(`Exported to: ${driveFile.id}`); this.logger.succ(`Exported to: ${driveFile.id}`);
} finally { } finally {

View File

@ -12,7 +12,7 @@ import type { Following } from '@/models/entities/Following.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull'; import type Bull from 'bull';
import type { DbUserJobData } from '../types.js'; import type { DbExportFollowingData } from '../types.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
@Injectable() @Injectable()
@ -40,7 +40,7 @@ export class ExportFollowingProcessorService {
} }
@bindThis @bindThis
public async process(job: Bull.Job<DbUserJobData>, done: () => void): Promise<void> { public async process(job: Bull.Job<DbExportFollowingData>, done: () => void): Promise<void> {
this.logger.info(`Exporting following of ${job.data.user.id} ...`); this.logger.info(`Exporting following of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
@ -110,7 +110,7 @@ export class ExportFollowingProcessorService {
this.logger.succ(`Exported to: ${path}`); this.logger.succ(`Exported to: ${path}`);
const fileName = 'following-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; const fileName = 'following-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv';
const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' });
this.logger.succ(`Exported to: ${driveFile.id}`); this.logger.succ(`Exported to: ${driveFile.id}`);
} finally { } finally {

View File

@ -11,7 +11,7 @@ import { createTemp } from '@/misc/create-temp.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull'; import type Bull from 'bull';
import type { DbUserJobData } from '../types.js'; import type { DbJobDataWithUser } from '../types.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
@Injectable() @Injectable()
@ -39,7 +39,7 @@ export class ExportMutingProcessorService {
} }
@bindThis @bindThis
public async process(job: Bull.Job<DbUserJobData>, done: () => void): Promise<void> { public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
this.logger.info(`Exporting muting of ${job.data.user.id} ...`); this.logger.info(`Exporting muting of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
@ -110,7 +110,7 @@ export class ExportMutingProcessorService {
this.logger.succ(`Exported to: ${path}`); this.logger.succ(`Exported to: ${path}`);
const fileName = 'mute-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; const fileName = 'mute-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv';
const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' });
this.logger.succ(`Exported to: ${driveFile.id}`); this.logger.succ(`Exported to: ${driveFile.id}`);
} finally { } finally {

View File

@ -13,7 +13,7 @@ import type { Note } from '@/models/entities/Note.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull'; import type Bull from 'bull';
import type { DbUserJobData } from '../types.js'; import type { DbJobDataWithUser } from '../types.js';
@Injectable() @Injectable()
export class ExportNotesProcessorService { export class ExportNotesProcessorService {
@ -39,7 +39,7 @@ export class ExportNotesProcessorService {
} }
@bindThis @bindThis
public async process(job: Bull.Job<DbUserJobData>, done: () => void): Promise<void> { public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
this.logger.info(`Exporting notes of ${job.data.user.id} ...`); this.logger.info(`Exporting notes of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
@ -117,7 +117,7 @@ export class ExportNotesProcessorService {
this.logger.succ(`Exported to: ${path}`); this.logger.succ(`Exported to: ${path}`);
const fileName = 'notes-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json'; const fileName = 'notes-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json';
const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' });
this.logger.succ(`Exported to: ${driveFile.id}`); this.logger.succ(`Exported to: ${driveFile.id}`);
} finally { } finally {

View File

@ -11,7 +11,7 @@ import { createTemp } from '@/misc/create-temp.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull'; import type Bull from 'bull';
import type { DbUserJobData } from '../types.js'; import type { DbJobDataWithUser } from '../types.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
@Injectable() @Injectable()
@ -39,7 +39,7 @@ export class ExportUserListsProcessorService {
} }
@bindThis @bindThis
public async process(job: Bull.Job<DbUserJobData>, done: () => void): Promise<void> { public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
this.logger.info(`Exporting user lists of ${job.data.user.id} ...`); this.logger.info(`Exporting user lists of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
@ -86,7 +86,7 @@ export class ExportUserListsProcessorService {
this.logger.succ(`Exported to: ${path}`); this.logger.succ(`Exported to: ${path}`);
const fileName = 'user-lists-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; const fileName = 'user-lists-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv';
const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' });
this.logger.succ(`Exported to: ${driveFile.id}`); this.logger.succ(`Exported to: ${driveFile.id}`);
} finally { } finally {

View File

@ -1,38 +1,31 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { IsNull } from 'typeorm'; import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { UsersRepository, BlockingsRepository, DriveFilesRepository } from '@/models/index.js'; import type { UsersRepository, DriveFilesRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import * as Acct from '@/misc/acct.js'; import * as Acct from '@/misc/acct.js';
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
import { UserBlockingService } from '@/core/UserBlockingService.js';
import { DownloadService } from '@/core/DownloadService.js'; import { DownloadService } from '@/core/DownloadService.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull'; import type Bull from 'bull';
import type { DbUserImportJobData } from '../types.js'; import type { DbUserImportJobData, DbUserImportToDbJobData } from '../types.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { QueueService } from '@/core/QueueService.js';
@Injectable() @Injectable()
export class ImportBlockingProcessorService { export class ImportBlockingProcessorService {
private logger: Logger; private logger: Logger;
constructor( constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
@Inject(DI.driveFilesRepository) @Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository, private driveFilesRepository: DriveFilesRepository,
private queueService: QueueService,
private utilityService: UtilityService, private utilityService: UtilityService,
private userBlockingService: UserBlockingService,
private remoteUserResolveService: RemoteUserResolveService, private remoteUserResolveService: RemoteUserResolveService,
private downloadService: DownloadService, private downloadService: DownloadService,
private queueLoggerService: QueueLoggerService, private queueLoggerService: QueueLoggerService,
@ -59,46 +52,50 @@ export class ImportBlockingProcessorService {
} }
const csv = await this.downloadService.downloadTextFile(file.url); const csv = await this.downloadService.downloadTextFile(file.url);
const targets = csv.trim().split('\n');
this.queueService.createImportBlockingToDbJob({ id: user.id }, targets);
let linenum = 0; this.logger.succ('Import jobs created');
done();
}
for (const line of csv.trim().split('\n')) { @bindThis
linenum++; public async processDb(job: Bull.Job<DbUserImportToDbJobData>): Promise<void> {
const line = job.data.target;
const user = job.data.user;
try { try {
const acct = line.split(',')[0].trim(); const acct = line.split(',')[0].trim();
const { username, host } = Acct.parse(acct); const { username, host } = Acct.parse(acct);
let target = this.utilityService.isSelfHost(host!) ? await this.usersRepository.findOneBy({ if (!host) return;
let target = this.utilityService.isSelfHost(host) ? await this.usersRepository.findOneBy({
host: IsNull(), host: IsNull(),
usernameLower: username.toLowerCase(), usernameLower: username.toLowerCase(),
}) : await this.usersRepository.findOneBy({ }) : await this.usersRepository.findOneBy({
host: this.utilityService.toPuny(host!), host: this.utilityService.toPuny(host),
usernameLower: username.toLowerCase(), usernameLower: username.toLowerCase(),
}); });
if (host == null && target == null) continue; if (host == null && target == null) return;
if (target == null) { if (target == null) {
target = await this.remoteUserResolveService.resolveUser(username, host); target = await this.remoteUserResolveService.resolveUser(username, host);
} }
if (target == null) { if (target == null) {
throw `cannot resolve user: @${username}@${host}`; throw `Unable to resolve user: @${username}@${host}`;
} }
// skip myself // skip myself
if (target.id === job.data.user.id) continue; if (target.id === job.data.user.id) return;
this.logger.info(`Block[${linenum}] ${target.id} ...`); this.logger.info(`Block ${target.id} ...`);
await this.userBlockingService.block(user, target); this.queueService.createBlockJob([{ from: { id: user.id }, to: { id: target.id }, silent: true }]);
} catch (e) { } catch (e) {
this.logger.warn(`Error in line:${linenum} ${e}`); this.logger.warn(`Error: ${e}`);
} }
} }
this.logger.succ('Imported');
done();
}
} }

View File

@ -2,34 +2,30 @@ import { Inject, Injectable } from '@nestjs/common';
import { IsNull } from 'typeorm'; import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { UsersRepository, DriveFilesRepository } from '@/models/index.js'; import type { UsersRepository, DriveFilesRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import * as Acct from '@/misc/acct.js'; import * as Acct from '@/misc/acct.js';
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
import { DownloadService } from '@/core/DownloadService.js'; import { DownloadService } from '@/core/DownloadService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull'; import type Bull from 'bull';
import type { DbUserImportJobData } from '../types.js'; import type { DbUserImportJobData, DbUserImportToDbJobData } from '../types.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { QueueService } from '@/core/QueueService.js';
@Injectable() @Injectable()
export class ImportFollowingProcessorService { export class ImportFollowingProcessorService {
private logger: Logger; private logger: Logger;
constructor( constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@Inject(DI.driveFilesRepository) @Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository, private driveFilesRepository: DriveFilesRepository,
private queueService: QueueService,
private utilityService: UtilityService, private utilityService: UtilityService,
private userFollowingService: UserFollowingService,
private remoteUserResolveService: RemoteUserResolveService, private remoteUserResolveService: RemoteUserResolveService,
private downloadService: DownloadService, private downloadService: DownloadService,
private queueLoggerService: QueueLoggerService, private queueLoggerService: QueueLoggerService,
@ -56,46 +52,50 @@ export class ImportFollowingProcessorService {
} }
const csv = await this.downloadService.downloadTextFile(file.url); const csv = await this.downloadService.downloadTextFile(file.url);
const targets = csv.trim().split('\n');
this.queueService.createImportFollowingToDbJob({ id: user.id }, targets);
let linenum = 0; this.logger.succ('Import jobs created');
done();
}
for (const line of csv.trim().split('\n')) { @bindThis
linenum++; public async processDb(job: Bull.Job<DbUserImportToDbJobData>): Promise<void> {
const line = job.data.target;
const user = job.data.user;
try { try {
const acct = line.split(',')[0].trim(); const acct = line.split(',')[0].trim();
const { username, host } = Acct.parse(acct); const { username, host } = Acct.parse(acct);
let target = this.utilityService.isSelfHost(host!) ? await this.usersRepository.findOneBy({ if (!host) return;
let target = this.utilityService.isSelfHost(host) ? await this.usersRepository.findOneBy({
host: IsNull(), host: IsNull(),
usernameLower: username.toLowerCase(), usernameLower: username.toLowerCase(),
}) : await this.usersRepository.findOneBy({ }) : await this.usersRepository.findOneBy({
host: this.utilityService.toPuny(host!), host: this.utilityService.toPuny(host),
usernameLower: username.toLowerCase(), usernameLower: username.toLowerCase(),
}); });
if (host == null && target == null) continue; if (host == null && target == null) return;
if (target == null) { if (target == null) {
target = await this.remoteUserResolveService.resolveUser(username, host); target = await this.remoteUserResolveService.resolveUser(username, host);
} }
if (target == null) { if (target == null) {
throw `cannot resolve user: @${username}@${host}`; throw `Unable to resolve user: @${username}@${host}`;
} }
// skip myself // skip myself
if (target.id === job.data.user.id) continue; if (target.id === job.data.user.id) return;
this.logger.info(`Follow[${linenum}] ${target.id} ...`); this.logger.info(`Follow ${target.id} ...`);
this.userFollowingService.follow(user, target); this.queueService.createFollowJob([{ from: user, to: { id: target.id }, silent: true }]);
} catch (e) { } catch (e) {
this.logger.warn(`Error in line:${linenum} ${e}`); this.logger.warn(`Error: ${e}`);
} }
} }
this.logger.succ('Imported');
done();
}
} }

View File

@ -66,11 +66,13 @@ export class ImportMutingProcessorService {
const acct = line.split(',')[0].trim(); const acct = line.split(',')[0].trim();
const { username, host } = Acct.parse(acct); const { username, host } = Acct.parse(acct);
let target = this.utilityService.isSelfHost(host!) ? await this.usersRepository.findOneBy({ if (!host) continue;
let target = this.utilityService.isSelfHost(host) ? await this.usersRepository.findOneBy({
host: IsNull(), host: IsNull(),
usernameLower: username.toLowerCase(), usernameLower: username.toLowerCase(),
}) : await this.usersRepository.findOneBy({ }) : await this.usersRepository.findOneBy({
host: this.utilityService.toPuny(host!), host: this.utilityService.toPuny(host),
usernameLower: username.toLowerCase(), usernameLower: username.toLowerCase(),
}); });

View File

@ -0,0 +1,68 @@
import { Inject, Injectable } from '@nestjs/common';
import type Bull from 'bull';
import { UserFollowingService } from '@/core/UserFollowingService.js';
import { UserBlockingService } from '@/core/UserBlockingService.js';
import { bindThis } from '@/decorators.js';
import type Logger from '@/logger.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import { RelationshipJobData } from '../types.js';
import type { UsersRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
@Injectable()
export class RelationshipProcessorService {
private logger: Logger;
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private queueLoggerService: QueueLoggerService,
private userFollowingService: UserFollowingService,
private userBlockingService: UserBlockingService,
) {
this.logger = this.queueLoggerService.logger.createSubLogger('follow-block');
}
@bindThis
public async processFollow(job: Bull.Job<RelationshipJobData>): Promise<string> {
this.logger.info(`${job.data.from.id} is trying to follow ${job.data.to.id}`);
await this.userFollowingService.follow(job.data.from, job.data.to, job.data.requestId, job.data.silent);
return 'ok';
}
@bindThis
public async processUnfollow(job: Bull.Job<RelationshipJobData>): Promise<string> {
this.logger.info(`${job.data.from.id} is trying to unfollow ${job.data.to.id}`);
const [follower, followee] = await Promise.all([
this.usersRepository.findOneByOrFail({ id: job.data.from.id }),
this.usersRepository.findOneByOrFail({ id: job.data.to.id }),
]);
await this.userFollowingService.unfollow(follower, followee, job.data.silent);
return 'ok';
}
@bindThis
public async processBlock(job: Bull.Job<RelationshipJobData>): Promise<string> {
this.logger.info(`${job.data.from.id} is trying to block ${job.data.to.id}`);
const [blockee, blocker] = await Promise.all([
this.usersRepository.findOneByOrFail({ id: job.data.from.id }),
this.usersRepository.findOneByOrFail({ id: job.data.to.id }),
]);
await this.userBlockingService.block(blockee, blocker, job.data.silent);
return 'ok';
}
@bindThis
public async processUnblock(job: Bull.Job<RelationshipJobData>): Promise<string> {
this.logger.info(`${job.data.from.id} is trying to unblock ${job.data.to.id}`);
const [blockee, blocker] = await Promise.all([
this.usersRepository.findOneByOrFail({ id: job.data.from.id }),
this.usersRepository.findOneByOrFail({ id: job.data.to.id }),
]);
await this.userBlockingService.unblock(blockee, blocker);
return 'ok';
}
}

View File

@ -21,9 +21,39 @@ export type InboxJobData = {
signature: httpSignature.IParsedSignature; signature: httpSignature.IParsedSignature;
}; };
export type DbJobData = DbUserJobData | DbUserImportJobData | DbUserDeleteJobData; export type RelationshipJobData = {
from: ThinUser;
to: ThinUser;
silent?: boolean;
requestId?: string;
}
export type DbUserJobData = { export type DbJobData<T extends keyof DbJobMap> = DbJobMap[T];
export type DbJobMap = {
deleteDriveFiles: DbJobDataWithUser;
exportCustomEmojis: DbJobDataWithUser;
exportNotes: DbJobDataWithUser;
exportFavorites: DbJobDataWithUser;
exportFollowing: DbExportFollowingData;
exportMuting: DbJobDataWithUser;
exportBlocking: DbJobDataWithUser;
exportUserLists: DbJobDataWithUser;
importFollowing: DbUserImportJobData;
importFollowingToDb: DbUserImportToDbJobData;
importMuting: DbUserImportJobData;
importBlocking: DbUserImportJobData;
importBlockingToDb: DbUserImportToDbJobData;
importUserLists: DbUserImportJobData;
importCustomEmojis: DbUserImportJobData;
deleteAccount: DbUserDeleteJobData;
}
export type DbJobDataWithUser = {
user: ThinUser;
}
export type DbExportFollowingData = {
user: ThinUser; user: ThinUser;
excludeMuting: boolean; excludeMuting: boolean;
excludeInactive: boolean; excludeInactive: boolean;
@ -39,6 +69,11 @@ export type DbUserImportJobData = {
fileId: DriveFile['id']; fileId: DriveFile['id'];
}; };
export type DbUserImportToDbJobData = {
user: ThinUser;
target: string;
};
export type ObjectStorageJobData = ObjectStorageFileJobData | Record<string, unknown>; export type ObjectStorageJobData = ObjectStorageFileJobData | Record<string, unknown>;
export type ObjectStorageFileJobData = { export type ObjectStorageFileJobData = {

View File

@ -6,7 +6,7 @@ import { Brackets, In, IsNull, LessThan, Not } from 'typeorm';
import accepts from 'accepts'; import accepts from 'accepts';
import vary from 'vary'; import vary from 'vary';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository } from '@/models/index.js'; import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository, FollowRequestsRepository } from '@/models/index.js';
import * as url from '@/misc/prelude/url.js'; import * as url from '@/misc/prelude/url.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
@ -54,6 +54,9 @@ export class ActivityPubServerService {
@Inject(DI.followingsRepository) @Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository, private followingsRepository: FollowingsRepository,
@Inject(DI.followRequestsRepository)
private followRequestsRepository: FollowRequestsRepository,
private utilityService: UtilityService, private utilityService: UtilityService,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
@ -639,6 +642,41 @@ export class ActivityPubServerService {
return (this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee))); return (this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee)));
}); });
// follow
fastify.get<{ Params: { followRequestId: string ; } }>('/follows/:followRequestId', async (request, reply) => {
// This may be used before the follow is completed, so we do not
// check if the following exists and only check if the follow request exists.
const followRequest = await this.followRequestsRepository.findOneBy({
id: request.params.followRequestId,
});
if (followRequest == null) {
reply.code(404);
return;
}
const [follower, followee] = await Promise.all([
this.usersRepository.findOneBy({
id: followRequest.followerId,
host: IsNull(),
}),
this.usersRepository.findOneBy({
id: followRequest.followeeId,
host: Not(IsNull()),
}),
]);
if (follower == null || followee == null) {
reply.code(404);
return;
}
reply.header('Cache-Control', 'public, max-age=180');
this.setResponseType(request, reply);
return (this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee)));
});
done(); done();
} }
} }

View File

@ -34,6 +34,8 @@ import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js';
import { ServerStatsChannelService } from './api/stream/channels/server-stats.js'; import { ServerStatsChannelService } from './api/stream/channels/server-stats.js';
import { UserListChannelService } from './api/stream/channels/user-list.js'; import { UserListChannelService } from './api/stream/channels/user-list.js';
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
import { ClientLoggerService } from './web/ClientLoggerService.js';
import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
@Module({ @Module({
imports: [ imports: [
@ -42,6 +44,7 @@ import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
], ],
providers: [ providers: [
ClientServerService, ClientServerService,
ClientLoggerService,
FeedService, FeedService,
UrlPreviewService, UrlPreviewService,
ActivityPubServerService, ActivityPubServerService,
@ -67,6 +70,7 @@ import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
DriveChannelService, DriveChannelService,
GlobalTimelineChannelService, GlobalTimelineChannelService,
HashtagChannelService, HashtagChannelService,
RoleTimelineChannelService,
HomeTimelineChannelService, HomeTimelineChannelService,
HybridTimelineChannelService, HybridTimelineChannelService,
LocalTimelineChannelService, LocalTimelineChannelService,

View File

@ -89,7 +89,7 @@ export class ApiServerService {
Params: { endpoint: string; }, Params: { endpoint: string; },
Body: Record<string, unknown>, Body: Record<string, unknown>,
Querystring: Record<string, unknown>, Querystring: Record<string, unknown>,
}>('/' + endpoint.name, { bodyLimit: 1024 * 32 }, async (request, reply) => { }>('/' + endpoint.name, { bodyLimit: 1024 * 1024 }, async (request, reply) => {
if (request.method === 'GET' && !endpoint.meta.allowGet) { if (request.method === 'GET' && !endpoint.meta.allowGet) {
reply.code(405); reply.code(405);
reply.send(); reply.send();

View File

@ -98,6 +98,7 @@ import * as ep___channels_update from './endpoints/channels/update.js';
import * as ep___channels_favorite from './endpoints/channels/favorite.js'; import * as ep___channels_favorite from './endpoints/channels/favorite.js';
import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js'; import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js';
import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js'; import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js';
import * as ep___channels_search from './endpoints/channels/search.js';
import * as ep___charts_activeUsers from './endpoints/charts/active-users.js'; import * as ep___charts_activeUsers from './endpoints/charts/active-users.js';
import * as ep___charts_apRequest from './endpoints/charts/ap-request.js'; import * as ep___charts_apRequest from './endpoints/charts/ap-request.js';
import * as ep___charts_drive from './endpoints/charts/drive.js'; import * as ep___charts_drive from './endpoints/charts/drive.js';
@ -293,6 +294,7 @@ import * as ep___promo_read from './endpoints/promo/read.js';
import * as ep___roles_list from './endpoints/roles/list.js'; import * as ep___roles_list from './endpoints/roles/list.js';
import * as ep___roles_show from './endpoints/roles/show.js'; import * as ep___roles_show from './endpoints/roles/show.js';
import * as ep___roles_users from './endpoints/roles/users.js'; import * as ep___roles_users from './endpoints/roles/users.js';
import * as ep___roles_notes from './endpoints/roles/notes.js';
import * as ep___requestResetPassword from './endpoints/request-reset-password.js'; import * as ep___requestResetPassword from './endpoints/request-reset-password.js';
import * as ep___resetDb from './endpoints/reset-db.js'; import * as ep___resetDb from './endpoints/reset-db.js';
import * as ep___resetPassword from './endpoints/reset-password.js'; import * as ep___resetPassword from './endpoints/reset-password.js';
@ -328,6 +330,7 @@ import * as ep___users_search from './endpoints/users/search.js';
import * as ep___users_show from './endpoints/users/show.js'; import * as ep___users_show from './endpoints/users/show.js';
import * as ep___users_stats from './endpoints/users/stats.js'; import * as ep___users_stats from './endpoints/users/stats.js';
import * as ep___users_achievements from './endpoints/users/achievements.js'; import * as ep___users_achievements from './endpoints/users/achievements.js';
import * as ep___users_updateMemo from './endpoints/users/update-memo.js';
import * as ep___fetchRss from './endpoints/fetch-rss.js'; import * as ep___fetchRss from './endpoints/fetch-rss.js';
import * as ep___retention from './endpoints/retention.js'; import * as ep___retention from './endpoints/retention.js';
import { GetterService } from './GetterService.js'; import { GetterService } from './GetterService.js';
@ -431,6 +434,7 @@ const $channels_update: Provider = { provide: 'ep:channels/update', useClass: ep
const $channels_favorite: Provider = { provide: 'ep:channels/favorite', useClass: ep___channels_favorite.default }; const $channels_favorite: Provider = { provide: 'ep:channels/favorite', useClass: ep___channels_favorite.default };
const $channels_unfavorite: Provider = { provide: 'ep:channels/unfavorite', useClass: ep___channels_unfavorite.default }; const $channels_unfavorite: Provider = { provide: 'ep:channels/unfavorite', useClass: ep___channels_unfavorite.default };
const $channels_myFavorites: Provider = { provide: 'ep:channels/my-favorites', useClass: ep___channels_myFavorites.default }; const $channels_myFavorites: Provider = { provide: 'ep:channels/my-favorites', useClass: ep___channels_myFavorites.default };
const $channels_search: Provider = { provide: 'ep:channels/search', useClass: ep___channels_search.default };
const $charts_activeUsers: Provider = { provide: 'ep:charts/active-users', useClass: ep___charts_activeUsers.default }; const $charts_activeUsers: Provider = { provide: 'ep:charts/active-users', useClass: ep___charts_activeUsers.default };
const $charts_apRequest: Provider = { provide: 'ep:charts/ap-request', useClass: ep___charts_apRequest.default }; const $charts_apRequest: Provider = { provide: 'ep:charts/ap-request', useClass: ep___charts_apRequest.default };
const $charts_drive: Provider = { provide: 'ep:charts/drive', useClass: ep___charts_drive.default }; const $charts_drive: Provider = { provide: 'ep:charts/drive', useClass: ep___charts_drive.default };
@ -626,6 +630,7 @@ const $promo_read: Provider = { provide: 'ep:promo/read', useClass: ep___promo_r
const $roles_list: Provider = { provide: 'ep:roles/list', useClass: ep___roles_list.default }; const $roles_list: Provider = { provide: 'ep:roles/list', useClass: ep___roles_list.default };
const $roles_show: Provider = { provide: 'ep:roles/show', useClass: ep___roles_show.default }; const $roles_show: Provider = { provide: 'ep:roles/show', useClass: ep___roles_show.default };
const $roles_users: Provider = { provide: 'ep:roles/users', useClass: ep___roles_users.default }; const $roles_users: Provider = { provide: 'ep:roles/users', useClass: ep___roles_users.default };
const $roles_notes: Provider = { provide: 'ep:roles/notes', useClass: ep___roles_notes.default };
const $requestResetPassword: Provider = { provide: 'ep:request-reset-password', useClass: ep___requestResetPassword.default }; const $requestResetPassword: Provider = { provide: 'ep:request-reset-password', useClass: ep___requestResetPassword.default };
const $resetDb: Provider = { provide: 'ep:reset-db', useClass: ep___resetDb.default }; const $resetDb: Provider = { provide: 'ep:reset-db', useClass: ep___resetDb.default };
const $resetPassword: Provider = { provide: 'ep:reset-password', useClass: ep___resetPassword.default }; const $resetPassword: Provider = { provide: 'ep:reset-password', useClass: ep___resetPassword.default };
@ -661,6 +666,7 @@ const $users_search: Provider = { provide: 'ep:users/search', useClass: ep___use
const $users_show: Provider = { provide: 'ep:users/show', useClass: ep___users_show.default }; const $users_show: Provider = { provide: 'ep:users/show', useClass: ep___users_show.default };
const $users_stats: Provider = { provide: 'ep:users/stats', useClass: ep___users_stats.default }; const $users_stats: Provider = { provide: 'ep:users/stats', useClass: ep___users_stats.default };
const $users_achievements: Provider = { provide: 'ep:users/achievements', useClass: ep___users_achievements.default }; const $users_achievements: Provider = { provide: 'ep:users/achievements', useClass: ep___users_achievements.default };
const $users_updateMemo: Provider = { provide: 'ep:users/update-memo', useClass: ep___users_updateMemo.default };
const $fetchRss: Provider = { provide: 'ep:fetch-rss', useClass: ep___fetchRss.default }; const $fetchRss: Provider = { provide: 'ep:fetch-rss', useClass: ep___fetchRss.default };
const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention.default }; const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention.default };
@ -768,6 +774,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$channels_favorite, $channels_favorite,
$channels_unfavorite, $channels_unfavorite,
$channels_myFavorites, $channels_myFavorites,
$channels_search,
$charts_activeUsers, $charts_activeUsers,
$charts_apRequest, $charts_apRequest,
$charts_drive, $charts_drive,
@ -963,6 +970,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$roles_list, $roles_list,
$roles_show, $roles_show,
$roles_users, $roles_users,
$roles_notes,
$requestResetPassword, $requestResetPassword,
$resetDb, $resetDb,
$resetPassword, $resetPassword,
@ -998,6 +1006,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$users_show, $users_show,
$users_stats, $users_stats,
$users_achievements, $users_achievements,
$users_updateMemo,
$fetchRss, $fetchRss,
$retention, $retention,
], ],
@ -1099,6 +1108,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$channels_favorite, $channels_favorite,
$channels_unfavorite, $channels_unfavorite,
$channels_myFavorites, $channels_myFavorites,
$channels_search,
$charts_activeUsers, $charts_activeUsers,
$charts_apRequest, $charts_apRequest,
$charts_drive, $charts_drive,
@ -1294,6 +1304,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$roles_list, $roles_list,
$roles_show, $roles_show,
$roles_users, $roles_users,
$roles_notes,
$requestResetPassword, $requestResetPassword,
$resetDb, $resetDb,
$resetPassword, $resetPassword,
@ -1327,6 +1338,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$users_show, $users_show,
$users_stats, $users_stats,
$users_achievements, $users_achievements,
$users_updateMemo,
$fetchRss, $fetchRss,
$retention, $retention,
], ],

View File

@ -1,6 +1,6 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import Limiter from 'ratelimiter'; import Limiter from 'ratelimiter';
import Redis from 'ioredis'; import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';

View File

@ -1,6 +1,6 @@
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis'; import * as Redis from 'ioredis';
import * as websocket from 'websocket'; import * as websocket from 'websocket';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { UsersRepository, BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, RenoteMutingsRepository } from '@/models/index.js'; import type { UsersRepository, BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, RenoteMutingsRepository } from '@/models/index.js';

View File

@ -98,6 +98,7 @@ import * as ep___channels_update from './endpoints/channels/update.js';
import * as ep___channels_favorite from './endpoints/channels/favorite.js'; import * as ep___channels_favorite from './endpoints/channels/favorite.js';
import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js'; import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js';
import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js'; import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js';
import * as ep___channels_search from './endpoints/channels/search.js';
import * as ep___charts_activeUsers from './endpoints/charts/active-users.js'; import * as ep___charts_activeUsers from './endpoints/charts/active-users.js';
import * as ep___charts_apRequest from './endpoints/charts/ap-request.js'; import * as ep___charts_apRequest from './endpoints/charts/ap-request.js';
import * as ep___charts_drive from './endpoints/charts/drive.js'; import * as ep___charts_drive from './endpoints/charts/drive.js';
@ -293,6 +294,7 @@ import * as ep___promo_read from './endpoints/promo/read.js';
import * as ep___roles_list from './endpoints/roles/list.js'; import * as ep___roles_list from './endpoints/roles/list.js';
import * as ep___roles_show from './endpoints/roles/show.js'; import * as ep___roles_show from './endpoints/roles/show.js';
import * as ep___roles_users from './endpoints/roles/users.js'; import * as ep___roles_users from './endpoints/roles/users.js';
import * as ep___roles_notes from './endpoints/roles/notes.js';
import * as ep___requestResetPassword from './endpoints/request-reset-password.js'; import * as ep___requestResetPassword from './endpoints/request-reset-password.js';
import * as ep___resetDb from './endpoints/reset-db.js'; import * as ep___resetDb from './endpoints/reset-db.js';
import * as ep___resetPassword from './endpoints/reset-password.js'; import * as ep___resetPassword from './endpoints/reset-password.js';
@ -328,6 +330,7 @@ import * as ep___users_search from './endpoints/users/search.js';
import * as ep___users_show from './endpoints/users/show.js'; import * as ep___users_show from './endpoints/users/show.js';
import * as ep___users_stats from './endpoints/users/stats.js'; import * as ep___users_stats from './endpoints/users/stats.js';
import * as ep___users_achievements from './endpoints/users/achievements.js'; import * as ep___users_achievements from './endpoints/users/achievements.js';
import * as ep___users_updateMemo from './endpoints/users/update-memo.js';
import * as ep___fetchRss from './endpoints/fetch-rss.js'; import * as ep___fetchRss from './endpoints/fetch-rss.js';
import * as ep___retention from './endpoints/retention.js'; import * as ep___retention from './endpoints/retention.js';
@ -429,6 +432,7 @@ const eps = [
['channels/favorite', ep___channels_favorite], ['channels/favorite', ep___channels_favorite],
['channels/unfavorite', ep___channels_unfavorite], ['channels/unfavorite', ep___channels_unfavorite],
['channels/my-favorites', ep___channels_myFavorites], ['channels/my-favorites', ep___channels_myFavorites],
['channels/search', ep___channels_search],
['charts/active-users', ep___charts_activeUsers], ['charts/active-users', ep___charts_activeUsers],
['charts/ap-request', ep___charts_apRequest], ['charts/ap-request', ep___charts_apRequest],
['charts/drive', ep___charts_drive], ['charts/drive', ep___charts_drive],
@ -624,6 +628,7 @@ const eps = [
['roles/list', ep___roles_list], ['roles/list', ep___roles_list],
['roles/show', ep___roles_show], ['roles/show', ep___roles_show],
['roles/users', ep___roles_users], ['roles/users', ep___roles_users],
['roles/notes', ep___roles_notes],
['request-reset-password', ep___requestResetPassword], ['request-reset-password', ep___requestResetPassword],
['reset-db', ep___resetDb], ['reset-db', ep___resetDb],
['reset-password', ep___resetPassword], ['reset-password', ep___resetPassword],
@ -659,6 +664,7 @@ const eps = [
['users/show', ep___users_show], ['users/show', ep___users_show],
['users/stats', ep___users_stats], ['users/stats', ep___users_stats],
['users/achievements', ep___users_achievements], ['users/achievements', ep___users_achievements],
['users/update-memo', ep___users_updateMemo],
['fetch-rss', ep___fetchRss], ['fetch-rss', ep___fetchRss],
['retention', ep___retention], ['retention', ep___retention],
]; ];

View File

@ -87,12 +87,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
//const emojis = await q.take(ps.limit).getMany(); //const emojis = await q.take(ps.limit).getMany();
emojis = await q.getMany(); emojis = await q.getMany();
const queryarry = ps.query.match(/\:([a-z0-9_]*)\:/g);
if (queryarry) {
emojis = emojis.filter(emoji =>
queryarry.includes(`:${emoji.name}:`)
);
} else {
emojis = emojis.filter(emoji => emojis = emojis.filter(emoji =>
emoji.name.includes(ps.query!) || emoji.name.includes(ps.query!) ||
emoji.aliases.some(a => a.includes(ps.query!)) || emoji.aliases.some(a => a.includes(ps.query!)) ||
emoji.category?.includes(ps.query!)); emoji.category?.includes(ps.query!));
}
emojis.splice(ps.limit + 1); emojis.splice(ps.limit + 1);
} else { } else {
emojis = await q.take(ps.limit).getMany(); emojis = await q.take(ps.limit).getMany();

View File

@ -1,8 +1,8 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import type { FollowingsRepository, UsersRepository } from '@/models/index.js'; import type { FollowingsRepository, UsersRepository } from '@/models/index.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { QueueService } from '@/core/QueueService.js';
export const meta = { export const meta = {
tags: ['admin'], tags: ['admin'],
@ -29,7 +29,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private followingsRepository: FollowingsRepository, private followingsRepository: FollowingsRepository,
private userFollowingService: UserFollowingService, private queueService: QueueService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const followings = await this.followingsRepository.findBy({ const followings = await this.followingsRepository.findBy({
@ -41,9 +41,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
this.usersRepository.findOneByOrFail({ id: f.followeeId }), this.usersRepository.findOneByOrFail({ id: f.followeeId }),
]))); ])));
for (const pair of pairs) { this.queueService.createUnfollowJob(pairs.map(p => ({ to: p[0], from: p[1], silent: true })));
this.userFollowingService.unfollow(pair[0], pair[1]);
}
}); });
} }
} }

View File

@ -2,7 +2,7 @@ import * as os from 'node:os';
import si from 'systeminformation'; import si from 'systeminformation';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import Redis from 'ioredis'; import * as Redis from 'ioredis';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';

View File

@ -1,15 +1,15 @@
import { IsNull, Not } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UsersRepository, FollowingsRepository } from '@/models/index.js'; import type { UsersRepository, FollowingsRepository } from '@/models/index.js';
import type { User } from '@/models/entities/User.js'; import type { User } from '@/models/entities/User.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { RelationshipJobData } from '@/queue/types.js';
import { ModerationLogService } from '@/core/ModerationLogService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js';
import { UserSuspendService } from '@/core/UserSuspendService.js'; import { UserSuspendService } from '@/core/UserSuspendService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { QueueService } from '@/core/QueueService.js';
export const meta = { export const meta = {
tags: ['admin'], tags: ['admin'],
@ -36,12 +36,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.followingsRepository) @Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository, private followingsRepository: FollowingsRepository,
private userEntityService: UserEntityService,
private userFollowingService: UserFollowingService,
private userSuspendService: UserSuspendService, private userSuspendService: UserSuspendService,
private roleService: RoleService, private roleService: RoleService,
private moderationLogService: ModerationLogService, private moderationLogService: ModerationLogService,
private globalEventService: GlobalEventService, private queueService: QueueService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const user = await this.usersRepository.findOneBy({ id: ps.userId }); const user = await this.usersRepository.findOneBy({ id: ps.userId });
@ -71,20 +69,23 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@bindThis @bindThis
private async unFollowAll(follower: User) { private async unFollowAll(follower: User) {
const followings = await this.followingsRepository.findBy({ const followings = await this.followingsRepository.find({
where: {
followerId: follower.id, followerId: follower.id,
followeeId: Not(IsNull()),
},
}); });
const jobs: RelationshipJobData[] = [];
for (const following of followings) { for (const following of followings) {
const followee = await this.usersRepository.findOneBy({ if (following.followeeId && following.followerId) {
id: following.followeeId, jobs.push({
from: { id: following.followerId },
to: { id: following.followeeId },
silent: true,
}); });
if (followee == null) {
throw `Cant find followee ${following.followeeId}`;
}
await this.userFollowingService.unfollow(follower, followee, true);
} }
} }
this.queueService.createUnfollowJob(jobs);
}
} }

View File

@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis'; import * as Redis from 'ioredis';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import type { NotesRepository, AntennasRepository } from '@/models/index.js'; import type { NotesRepository, AntennasRepository } from '@/models/index.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
@ -76,17 +76,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new ApiError(meta.errors.noSuchAntenna); throw new ApiError(meta.errors.noSuchAntenna);
} }
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
const noteIdsRes = await this.redisClient.xrevrange( const noteIdsRes = await this.redisClient.xrevrange(
`antennaTimeline:${antenna.id}`, `antennaTimeline:${antenna.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
'-', ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1 'COUNT', limit);
if (noteIdsRes.length === 0) { if (noteIdsRes.length === 0) {
return []; return [];
} }
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
if (noteIds.length === 0) { if (noteIds.length === 0) {
return []; return [];

View File

@ -0,0 +1,67 @@
import { Inject, Injectable } from '@nestjs/common';
import { Brackets } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import type { ChannelsRepository } from '@/models/index.js';
import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js';
import { DI } from '@/di-symbols.js';
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
export const meta = {
tags: ['channels'],
requireCredential: false,
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'Channel',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
query: { type: 'string' },
type: { type: 'string', enum: ['nameAndDescription', 'nameOnly'], default: 'nameAndDescription' },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 5 },
},
required: ['query'],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.channelsRepository)
private channelsRepository: ChannelsRepository,
private channelEntityService: ChannelEntityService,
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder('channel'), ps.sinceId, ps.untilId);
if (ps.type === 'nameAndDescription') {
query.andWhere(new Brackets(qb => { qb
.where('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` })
.orWhere('channel.description ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });
}));
} else {
query.andWhere('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });
}
const channels = await query
.take(ps.limit)
.getMany();
return await Promise.all(channels.map(x => this.channelEntityService.pack(x, me)));
});
}
}

View File

@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis'; import * as Redis from 'ioredis';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import type { ChannelsRepository, Note, NotesRepository } from '@/models/index.js'; import type { ChannelsRepository, Note, NotesRepository } from '@/models/index.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';

View File

@ -1,5 +1,5 @@
import { Brackets, In } from 'typeorm'; import { Brackets, In } from 'typeorm';
import Redis from 'ioredis'; import * as Redis from 'ioredis';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { UsersRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, NotesRepository } from '@/models/index.js'; import type { UsersRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, NotesRepository } from '@/models/index.js';
import { obsoleteNotificationTypes, notificationTypes } from '@/types.js'; import { obsoleteNotificationTypes, notificationTypes } from '@/types.js';
@ -91,11 +91,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
const notificationsRes = await this.redisClient.xrevrange( const notificationsRes = await this.redisClient.xrevrange(
`notificationTimeline:${me.id}`, `notificationTimeline:${me.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+',
'-', '-',
'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1 'COUNT', limit);
if (notificationsRes.length === 0) { if (notificationsRes.length === 0) {
return []; return [];

View File

@ -221,6 +221,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
updates.avatarId = avatar.id; updates.avatarId = avatar.id;
updates.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar'); updates.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar');
updates.avatarBlurhash = avatar.blurhash; updates.avatarBlurhash = avatar.blurhash;
} else if (ps.avatarId === null) {
updates.avatarId = null;
updates.avatarUrl = null;
updates.avatarBlurhash = null;
} }
if (ps.bannerId) { if (ps.bannerId) {
@ -232,6 +236,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
updates.bannerId = banner.id; updates.bannerId = banner.id;
updates.bannerUrl = this.driveFileEntityService.getPublicUrl(banner); updates.bannerUrl = this.driveFileEntityService.getPublicUrl(banner);
updates.bannerBlurhash = banner.blurhash; updates.bannerBlurhash = banner.blurhash;
} else if (ps.bannerId === null) {
updates.bannerId = null;
updates.bannerUrl = null;
updates.bannerBlurhash = null;
} }
if (ps.pinnedPageId) { if (ps.pinnedPageId) {

View File

@ -1,6 +1,6 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import Redis from 'ioredis'; import * as Redis from 'ioredis';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { resetDb } from '@/misc/reset-db.js'; import { resetDb } from '@/misc/reset-db.js';

View File

@ -0,0 +1,109 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { NotesRepository, RolesRepository } from '@/models/index.js';
import { QueryService } from '@/core/QueryService.js';
import { DI } from '@/di-symbols.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { IdService } from '@/core/IdService.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['role', 'notes'],
requireCredential: true,
errors: {
noSuchRole: {
message: 'No such role.',
code: 'NO_SUCH_ROLE',
id: 'eb70323a-df61-4dd4-ad90-89c83c7cf26e',
},
},
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'Note',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
roleId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
sinceDate: { type: 'integer' },
untilDate: { type: 'integer' },
},
required: ['roleId'],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.rolesRepository)
private rolesRepository: RolesRepository,
private idService: IdService,
private noteEntityService: NoteEntityService,
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
const role = await this.rolesRepository.findOneBy({
id: ps.roleId,
});
if (role == null) {
throw new ApiError(meta.errors.noSuchRole);
}
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
const noteIdsRes = await this.redisClient.xrevrange(
`roleTimeline:${role.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
'COUNT', limit);
if (noteIdsRes.length === 0) {
return [];
}
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
if (noteIds.length === 0) {
return [];
}
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
const notes = await query.getMany();
notes.sort((a, b) => a.id > b.id ? -1 : 1);
return await this.noteEntityService.packMany(notes, me);
});
}
}

View File

@ -41,8 +41,6 @@ export const paramDef = {
], ],
} as const; } as const;
// TODO: avatar,bannerをJOINしたいけどエラーになる
// eslint-disable-next-line import/no-default-export // eslint-disable-next-line import/no-default-export
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { export default class extends Endpoint<typeof meta, typeof paramDef> {

View File

@ -0,0 +1,85 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { IdService } from '@/core/IdService.js';
import type { UserMemoRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { GetterService } from '@/server/api/GetterService.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['account'],
requireCredential: true,
kind: 'write:account',
errors: {
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: '6fef56f3-e765-4957-88e5-c6f65329b8a5',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
memo: {
type: 'string',
nullable: true,
description: 'A personal memo for the target user. If null or empty, delete the memo.',
},
},
required: ['userId', 'memo'],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.userMemosRepository)
private userMemosRepository: UserMemoRepository,
private getterService: GetterService,
private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
// Get target
const target = await this.getterService.getUser(ps.userId).catch(err => {
if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw err;
});
// 引数がnullか空文字であれば、パーソナルメモを削除する
if (ps.memo === '' || ps.memo == null) {
await this.userMemosRepository.delete({
userId: me.id,
targetUserId: target.id,
});
return;
}
// 以前に作成されたパーソナルメモがあるかどうか確認
const previousMemo = await this.userMemosRepository.findOneBy({
userId: me.id,
targetUserId: target.id,
});
if (!previousMemo) {
await this.userMemosRepository.insert({
id: this.idService.genId(),
userId: me.id,
targetUserId: target.id,
memo: ps.memo,
});
} else {
await this.userMemosRepository.update(previousMemo.id, {
userId: me.id,
targetUserId: target.id,
memo: ps.memo,
});
}
});
}
}

View File

@ -13,6 +13,7 @@ import { UserListChannelService } from './channels/user-list.js';
import { AntennaChannelService } from './channels/antenna.js'; import { AntennaChannelService } from './channels/antenna.js';
import { DriveChannelService } from './channels/drive.js'; import { DriveChannelService } from './channels/drive.js';
import { HashtagChannelService } from './channels/hashtag.js'; import { HashtagChannelService } from './channels/hashtag.js';
import { RoleTimelineChannelService } from './channels/role-timeline.js';
@Injectable() @Injectable()
export class ChannelsService { export class ChannelsService {
@ -24,6 +25,7 @@ export class ChannelsService {
private globalTimelineChannelService: GlobalTimelineChannelService, private globalTimelineChannelService: GlobalTimelineChannelService,
private userListChannelService: UserListChannelService, private userListChannelService: UserListChannelService,
private hashtagChannelService: HashtagChannelService, private hashtagChannelService: HashtagChannelService,
private roleTimelineChannelService: RoleTimelineChannelService,
private antennaChannelService: AntennaChannelService, private antennaChannelService: AntennaChannelService,
private channelChannelService: ChannelChannelService, private channelChannelService: ChannelChannelService,
private driveChannelService: DriveChannelService, private driveChannelService: DriveChannelService,
@ -43,6 +45,7 @@ export class ChannelsService {
case 'globalTimeline': return this.globalTimelineChannelService; case 'globalTimeline': return this.globalTimelineChannelService;
case 'userList': return this.userListChannelService; case 'userList': return this.userListChannelService;
case 'hashtag': return this.hashtagChannelService; case 'hashtag': return this.hashtagChannelService;
case 'roleTimeline': return this.roleTimelineChannelService;
case 'antenna': return this.antennaChannelService; case 'antenna': return this.antennaChannelService;
case 'channel': return this.channelChannelService; case 'channel': return this.channelChannelService;
case 'drive': return this.driveChannelService; case 'drive': return this.driveChannelService;

View File

@ -0,0 +1,75 @@
import { Injectable } from '@nestjs/common';
import { isUserRelated } from '@/misc/is-user-related.js';
import type { Packed } from '@/misc/json-schema.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import Channel from '../channel.js';
import { StreamMessages } from '../types.js';
class RoleTimelineChannel extends Channel {
public readonly chName = 'roleTimeline';
public static shouldShare = false;
public static requireCredential = false;
private roleId: string;
constructor(
private noteEntityService: NoteEntityService,
id: string,
connection: Channel['connection'],
) {
super(id, connection);
//this.onNote = this.onNote.bind(this);
}
@bindThis
public async init(params: any) {
this.roleId = params.roleId as string;
this.subscriber.on(`roleTimelineStream:${this.roleId}`, this.onEvent);
}
@bindThis
private async onEvent(data: StreamMessages['roleTimeline']['payload']) {
if (data.type === 'note') {
const note = data.body;
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoBlockingMe)) return;
if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return;
this.send('note', note);
} else {
this.send(data.type, data.body);
}
}
@bindThis
public dispose() {
// Unsubscribe events
this.subscriber.off(`roleTimelineStream:${this.roleId}`, this.onEvent);
}
}
@Injectable()
export class RoleTimelineChannelService {
public readonly shouldShare = RoleTimelineChannel.shouldShare;
public readonly requireCredential = RoleTimelineChannel.requireCredential;
constructor(
private noteEntityService: NoteEntityService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): RoleTimelineChannel {
return new RoleTimelineChannel(
this.noteEntityService,
id,
connection,
);
}
}

View File

@ -148,6 +148,10 @@ export interface AntennaStreamTypes {
note: Note; note: Note;
} }
export interface RoleTimelineStreamTypes {
note: Packed<'Note'>;
}
export interface AdminStreamTypes { export interface AdminStreamTypes {
newAbuseUserReport: { newAbuseUserReport: {
id: AbuseUserReport['id']; id: AbuseUserReport['id'];
@ -168,7 +172,7 @@ type EventUnionFromDictionary<
> = U[keyof U]; > = U[keyof U];
// redis通すとDateのインスタンスはstringに変換されるので // redis通すとDateのインスタンスはstringに変換されるので
type Serialized<T> = { export type Serialized<T> = {
[K in keyof T]: [K in keyof T]:
T[K] extends Date T[K] extends Date
? string ? string
@ -209,6 +213,10 @@ export type StreamMessages = {
name: `userListStream:${UserList['id']}`; name: `userListStream:${UserList['id']}`;
payload: EventUnionFromDictionary<SerializedAll<UserListStreamTypes>>; payload: EventUnionFromDictionary<SerializedAll<UserListStreamTypes>>;
}; };
roleTimeline: {
name: `roleTimelineStream:${Role['id']}`;
payload: EventUnionFromDictionary<SerializedAll<RoleTimelineStreamTypes>>;
};
antenna: { antenna: {
name: `antennaStream:${Antenna['id']}`; name: `antennaStream:${Antenna['id']}`;
payload: EventUnionFromDictionary<SerializedAll<AntennaStreamTypes>>; payload: EventUnionFromDictionary<SerializedAll<AntennaStreamTypes>>;

View File

@ -0,0 +1,14 @@
import { Injectable } from '@nestjs/common';
import type Logger from '@/logger.js';
import { LoggerService } from '@/core/LoggerService.js';
@Injectable()
export class ClientLoggerService {
public logger: Logger;
constructor(
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('client');
}
}

View File

@ -1,6 +1,7 @@
import { dirname } from 'node:path'; import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { v4 as uuid } from 'uuid';
import { createBullBoard } from '@bull-board/api'; import { createBullBoard } from '@bull-board/api';
import { BullAdapter } from '@bull-board/api/bullAdapter.js'; import { BullAdapter } from '@bull-board/api/bullAdapter.js';
import { FastifyAdapter } from '@bull-board/fastify'; import { FastifyAdapter } from '@bull-board/fastify';
@ -26,6 +27,7 @@ import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityServi
import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; import { ClipEntityService } from '@/core/entities/ClipEntityService.js';
import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js';
import type { ChannelsRepository, ClipsRepository, FlashsRepository, GalleryPostsRepository, NotesRepository, PagesRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; import type { ChannelsRepository, ClipsRepository, FlashsRepository, GalleryPostsRepository, NotesRepository, PagesRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
import type Logger from '@/logger.js';
import { deepClone } from '@/misc/clone.js'; import { deepClone } from '@/misc/clone.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { FlashEntityService } from '@/core/entities/FlashEntityService.js'; import { FlashEntityService } from '@/core/entities/FlashEntityService.js';
@ -34,6 +36,7 @@ import manifest from './manifest.json' assert { type: 'json' };
import { FeedService } from './FeedService.js'; import { FeedService } from './FeedService.js';
import { UrlPreviewService } from './UrlPreviewService.js'; import { UrlPreviewService } from './UrlPreviewService.js';
import type { FastifyInstance, FastifyPluginOptions, FastifyReply } from 'fastify'; import type { FastifyInstance, FastifyPluginOptions, FastifyReply } from 'fastify';
import { ClientLoggerService } from './ClientLoggerService.js';
const _filename = fileURLToPath(import.meta.url); const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename); const _dirname = dirname(_filename);
@ -46,6 +49,8 @@ const viteOut = `${_dirname}/../../../../../built/_vite_/`;
@Injectable() @Injectable()
export class ClientServerService { export class ClientServerService {
private logger: Logger;
constructor( constructor(
@Inject(DI.config) @Inject(DI.config)
private config: Config, private config: Config,
@ -85,6 +90,7 @@ export class ClientServerService {
private urlPreviewService: UrlPreviewService, private urlPreviewService: UrlPreviewService,
private feedService: FeedService, private feedService: FeedService,
private roleService: RoleService, private roleService: RoleService,
private clientLoggerService: ClientLoggerService,
@Inject('queue:system') public systemQueue: SystemQueue, @Inject('queue:system') public systemQueue: SystemQueue,
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
@ -689,6 +695,24 @@ export class ClientServerService {
return await renderBase(reply); return await renderBase(reply);
}); });
fastify.setErrorHandler(async (error, request, reply) => {
const errId = uuid();
this.clientLoggerService.logger.error(`Internal error occured in ${request.routerPath}: ${error.message}`, {
path: request.routerPath,
params: request.params,
query: request.query,
code: error.name,
stack: error.stack,
id: errId,
});
reply.code(500);
reply.header('Cache-Control', 'max-age=10, must-revalidate');
return await reply.view('error', {
code: error.code,
id: errId,
});
});
done(); done();
} }
} }

View File

@ -0,0 +1,110 @@
* {
font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif;
}
#misskey_app,
#splash {
display: none !important;
}
body,
html {
background-color: #222;
color: #dfddcc;
justify-content: center;
margin: auto;
padding: 10px;
text-align: center;
}
button {
border-radius: 999px;
padding: 0px 12px 0px 12px;
border: none;
cursor: pointer;
margin-bottom: 12px;
}
.button-big {
background: linear-gradient(90deg, rgb(134, 179, 0), rgb(74, 179, 0));
line-height: 50px;
}
.button-big:hover {
background: rgb(153, 204, 0);
}
.button-small {
background: #444;
line-height: 40px;
}
.button-small:hover {
background: #555;
}
.button-label-big {
color: #222;
font-weight: bold;
font-size: 20px;
padding: 12px;
}
.button-label-small {
color: rgb(153, 204, 0);
font-size: 16px;
padding: 12px;
}
a {
color: rgb(134, 179, 0);
text-decoration: none;
}
p,
li {
font-size: 16px;
}
.dont-worry,
#msg {
font-size: 18px;
}
.icon-warning {
color: #dec340;
height: 4rem;
padding-top: 2rem;
}
h1 {
font-size: 32px;
}
code {
display: block;
font-family: Fira, FiraCode, monospace;
background: #333;
padding: 0.5rem 1rem;
max-width: 40rem;
border-radius: 10px;
justify-content: center;
margin: auto;
white-space: pre-wrap;
word-break: break-word;
}
summary {
cursor: pointer;
}
summary > * {
display: inline;
white-space: pre-wrap;
}
@media screen and (max-width: 500px) {
details {
width: 50%;
}
}

View File

@ -0,0 +1,65 @@
doctype html
//
-
_____ _ _
| |_|___ ___| |_ ___ _ _
| | | | |_ -|_ -| '_| -_| | |
|_|_|_|_|___|___|_,_|___|_ |
|___|
Thank you for using Misskey!
If you are reading this message... how about joining the development?
https://github.com/misskey-dev/misskey
html
head
meta(charset='utf-8')
meta(name='viewport' content='width=device-width, initial-scale=1')
meta(name='application-name' content='Misskey')
meta(name='referrer' content='origin')
title
block title
= 'An error has occurred... | Misskey'
style
include ../error.css
body
svg.icon-warning(xmlns="http://www.w3.org/2000/svg", viewBox="0 0 24 24", stroke-width="2", stroke="currentColor", fill="none", stroke-linecap="round", stroke-linejoin="round")
path(stroke="none", d="M0 0h24v24H0z", fill="none")
path(d="M12 9v2m0 4v.01")
path(d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75")
h1 An error has occurred!
button.button-big(onclick="location.reload();")
span.button-label-big Refresh
p.dont-worry Don't worry, it's (probably) not your fault.
p If reloading after a period of time does not resolve the problem, contact the server administrator with the following ERROR ID.
div#errors
code.
ERROR CODE: #{code}
ERROR ID: #{id}
p You may also try the following options:
p Update your os and browser.
p Disable an adblocker.
a(href="/flush")
button.button-small
span.button-label-small Clear preferences and cache
br
a(href="/cli")
button.button-small
span.button-label-small Start the simple client
br
a(href="/bios")
button.button-small
span.button-label-small Start the repair tool

View File

@ -849,4 +849,85 @@ describe('Endpoints', () => {
assert.strictEqual(res.body.error.code, 'URL_PREVIEW_FAILED'); assert.strictEqual(res.body.error.code, 'URL_PREVIEW_FAILED');
}); });
}); });
describe('パーソナルメモ機能のテスト', () => {
test('他者に関するメモを更新できる', async () => {
const memo = '10月まで低浮上とのこと。';
const res1 = await api('/users/update-memo', {
memo,
userId: bob.id,
}, alice);
const res2 = await api('/users/show', {
userId: bob.id,
}, alice);
assert.strictEqual(res1.status, 204);
assert.strictEqual(res2.body?.memo, memo);
});
test('自分に関するメモを更新できる', async () => {
const memo = 'チケットを月末までに買う。';
const res1 = await api('/users/update-memo', {
memo,
userId: alice.id,
}, alice);
const res2 = await api('/users/show', {
userId: alice.id,
}, alice);
assert.strictEqual(res1.status, 204);
assert.strictEqual(res2.body?.memo, memo);
});
test('メモを削除できる', async () => {
const memo = '10月まで低浮上とのこと。';
await api('/users/update-memo', {
memo,
userId: bob.id,
}, alice);
await api('/users/update-memo', {
memo: '',
userId: bob.id,
}, alice);
const res = await api('/users/show', {
userId: bob.id,
}, alice);
// memoには常に文字列かnullが入っている(5cac151)
assert.strictEqual(res.body.memo, null);
});
test('メモは個人ごとに独立して保存される', async () => {
const memoAliceToBob = '10月まで低浮上とのこと。';
const memoCarolToBob = '例の件について今度問いただす。';
await Promise.all([
api('/users/update-memo', {
memo: memoAliceToBob,
userId: bob.id,
}, alice),
api('/users/update-memo', {
memo: memoCarolToBob,
userId: bob.id,
}, carol),
]);
const [resAlice, resCarol] = await Promise.all([
api('/users/show', {
userId: bob.id,
}, alice),
api('/users/show', {
userId: bob.id,
}, carol),
]);
assert.strictEqual(resAlice.body.memo, memoAliceToBob);
assert.strictEqual(resCarol.body.memo, memoCarolToBob);
});
});
}); });

View File

@ -0,0 +1,887 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { inspect } from 'node:util';
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
import type { Packed } from '@/misc/json-schema.js';
import {
signup,
post,
page,
role,
startServer,
api,
successfulApiCall,
failedApiCall,
uploadFile,
} from '../utils.js';
import type * as misskey from 'misskey-js';
import type { INestApplicationContext } from '@nestjs/common';
describe('ユーザー', () => {
// エンティティとしてのユーザーを主眼においたテストを記述する
// (Userを返すエンドポイントとUserエンティティを書き換えるエンドポイントをテストする)
const stripUndefined = <T extends { [key: string]: any }, >(orig: T): Partial<T> => {
return Object.entries({ ...orig })
.filter(([, value]) => value !== undefined)
.reduce((obj: Partial<T>, [key, value]) => {
obj[key as keyof T] = value;
return obj;
}, {});
};
// BUG misskey-jsとjson-schemaと実際に返ってくるデータが全部違う
type UserLite = misskey.entities.UserLite & {
badgeRoles: any[],
};
type UserDetailedNotMe = UserLite &
misskey.entities.UserDetailed & {
roles: any[],
};
type MeDetailed = UserDetailedNotMe &
misskey.entities.MeDetailed & {
showTimelineReplies: boolean,
achievements: object[],
loggedInDays: number,
policies: object,
};
type User = MeDetailed & { token: string };
const show = async (id: string, me = alice): Promise<MeDetailed | UserDetailedNotMe> => {
return successfulApiCall({ endpoint: 'users/show', parameters: { userId: id }, user: me }) as any;
};
// UserLiteのキーが過不足なく入っている
const userLite = (user: User): Partial<UserLite> => {
return stripUndefined({
id: user.id,
name: user.name,
username: user.username,
host: user.host,
avatarUrl: user.avatarUrl,
avatarBlurhash: user.avatarBlurhash,
isBot: user.isBot,
isCat: user.isCat,
instance: user.instance,
emojis: user.emojis,
onlineStatus: user.onlineStatus,
badgeRoles: user.badgeRoles,
// BUG isAdmin/isModeratorはUserLiteではなくMeDetailedOnlyに含まれる。
isAdmin: undefined,
isModerator: undefined,
});
};
// UserDetailedNotMeのキーが過不足なく入っている
const userDetailedNotMe = (user: User): Partial<UserDetailedNotMe> => {
return stripUndefined({
...userLite(user),
url: user.url,
uri: user.uri,
movedToUri: user.movedToUri,
alsoKnownAs: user.alsoKnownAs,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
lastFetchedAt: user.lastFetchedAt,
bannerUrl: user.bannerUrl,
bannerBlurhash: user.bannerBlurhash,
isLocked: user.isLocked,
isSilenced: user.isSilenced,
isSuspended: user.isSuspended,
description: user.description,
location: user.location,
birthday: user.birthday,
lang: user.lang,
fields: user.fields,
followersCount: user.followersCount,
followingCount: user.followingCount,
notesCount: user.notesCount,
pinnedNoteIds: user.pinnedNoteIds,
pinnedNotes: user.pinnedNotes,
pinnedPageId: user.pinnedPageId,
pinnedPage: user.pinnedPage,
publicReactions: user.publicReactions,
ffVisibility: user.ffVisibility,
twoFactorEnabled: user.twoFactorEnabled,
usePasswordLessLogin: user.usePasswordLessLogin,
securityKeys: user.securityKeys,
roles: user.roles,
memo: user.memo,
});
};
// Relations関連のキーが過不足なく入っている
const userDetailedNotMeWithRelations = (user: User): Partial<UserDetailedNotMe> => {
return stripUndefined({
...userDetailedNotMe(user),
isFollowing: user.isFollowing ?? false,
isFollowed: user.isFollowed ?? false,
hasPendingFollowRequestFromYou: user.hasPendingFollowRequestFromYou ?? false,
hasPendingFollowRequestToYou: user.hasPendingFollowRequestToYou ?? false,
isBlocking: user.isBlocking ?? false,
isBlocked: user.isBlocked ?? false,
isMuted: user.isMuted ?? false,
isRenoteMuted: user.isRenoteMuted ?? false,
});
};
// MeDetailedのキーが過不足なく入っている
const meDetailed = (user: User, security = false): Partial<MeDetailed> => {
return stripUndefined({
...userDetailedNotMe(user),
avatarId: user.avatarId,
bannerId: user.bannerId,
isModerator: user.isModerator,
isAdmin: user.isAdmin,
injectFeaturedNote: user.injectFeaturedNote,
receiveAnnouncementEmail: user.receiveAnnouncementEmail,
alwaysMarkNsfw: user.alwaysMarkNsfw,
autoSensitive: user.autoSensitive,
carefulBot: user.carefulBot,
autoAcceptFollowed: user.autoAcceptFollowed,
noCrawle: user.noCrawle,
isExplorable: user.isExplorable,
isDeleted: user.isDeleted,
hideOnlineStatus: user.hideOnlineStatus,
hasUnreadSpecifiedNotes: user.hasUnreadSpecifiedNotes,
hasUnreadMentions: user.hasUnreadMentions,
hasUnreadAnnouncement: user.hasUnreadAnnouncement,
hasUnreadAntenna: user.hasUnreadAntenna,
hasUnreadChannel: user.hasUnreadChannel,
hasUnreadNotification: user.hasUnreadNotification,
hasPendingReceivedFollowRequest: user.hasPendingReceivedFollowRequest,
mutedWords: user.mutedWords,
mutedInstances: user.mutedInstances,
mutingNotificationTypes: user.mutingNotificationTypes,
emailNotificationTypes: user.emailNotificationTypes,
showTimelineReplies: user.showTimelineReplies,
achievements: user.achievements,
loggedInDays: user.loggedInDays,
policies: user.policies,
...(security ? {
email: user.email,
emailVerified: user.emailVerified,
securityKeysList: user.securityKeysList,
} : {}),
});
};
let app: INestApplicationContext;
let root: User;
let alice: User;
let aliceNote: misskey.entities.Note;
let alicePage: misskey.entities.Page;
let aliceList: misskey.entities.UserList;
let bob: User;
let bobNote: misskey.entities.Note;
let carol: User;
let dave: User;
let ellen: User;
let frank: User;
let usersReplying: User[];
let userNoNote: User;
let userNotExplorable: User;
let userLocking: User;
let userAdmin: User;
let roleAdmin: any;
let userModerator: User;
let roleModerator: any;
let userRolePublic: User;
let rolePublic: any;
let userRoleBadge: User;
let roleBadge: any;
let userSilenced: User;
let roleSilenced: any;
let userSuspended: User;
let userDeletedBySelf: User;
let userDeletedByAdmin: User;
let userFollowingAlice: User;
let userFollowedByAlice: User;
let userBlockingAlice: User;
let userBlockedByAlice: User;
let userMutingAlice: User;
let userMutedByAlice: User;
let userRnMutingAlice: User;
let userRnMutedByAlice: User;
let userFollowRequesting: User;
let userFollowRequested: User;
beforeAll(async () => {
app = await startServer();
}, 1000 * 60 * 2);
beforeAll(async () => {
root = await signup({ username: 'alice' });
alice = root;
aliceNote = await post(alice, { text: 'test' }) as any;
alicePage = await page(alice);
aliceList = (await api('users/list/create', { name: 'aliceList' }, alice)).body;
bob = await signup({ username: 'bob' });
bobNote = await post(bob, { text: 'test' }) as any;
carol = await signup({ username: 'carol' });
dave = await signup({ username: 'dave' });
ellen = await signup({ username: 'ellen' });
frank = await signup({ username: 'frank' });
// @alice -> @replyingへのリプライ。Promise.allで一気に作るとtimeoutしてしまうのでreduceで一つ一つawaitする
usersReplying = await [...Array(10)].map((_, i) => i).reduce(async (acc, i) => {
const u = await signup({ username: `replying${i}` });
for (let j = 0; j < 10 - i; j++) {
const p = await post(u, { text: `test${j}` });
await post(alice, { text: `@${u.username} test${j}`, replyId: p.id });
}
return (await acc).concat(u);
}, Promise.resolve([] as User[]));
userNoNote = await signup({ username: 'userNoNote' });
userNotExplorable = await signup({ username: 'userNotExplorable' });
await post(userNotExplorable, { text: 'test' });
await api('i/update', { isExplorable: false }, userNotExplorable);
userLocking = await signup({ username: 'userLocking' });
await post(userLocking, { text: 'test' });
await api('i/update', { isLocked: true }, userLocking);
userAdmin = await signup({ username: 'userAdmin' });
roleAdmin = await role(root, { isAdministrator: true, name: 'Admin Role' });
await api('admin/roles/assign', { userId: userAdmin.id, roleId: roleAdmin.id }, root);
userModerator = await signup({ username: 'userModerator' });
roleModerator = await role(root, { isModerator: true, name: 'Moderator Role' });
await api('admin/roles/assign', { userId: userModerator.id, roleId: roleModerator.id }, root);
userRolePublic = await signup({ username: 'userRolePublic' });
rolePublic = await role(root, { isPublic: true, name: 'Public Role' });
await api('admin/roles/assign', { userId: userRolePublic.id, roleId: rolePublic.id }, root);
userRoleBadge = await signup({ username: 'userRoleBadge' });
roleBadge = await role(root, { asBadge: true, name: 'Badge Role' });
await api('admin/roles/assign', { userId: userRoleBadge.id, roleId: roleBadge.id }, root);
userSilenced = await signup({ username: 'userSilenced' });
await post(userSilenced, { text: 'test' });
roleSilenced = await role(root, {}, { canPublicNote: { priority: 0, useDefault: false, value: false } });
await api('admin/roles/assign', { userId: userSilenced.id, roleId: roleSilenced.id }, root);
userSuspended = await signup({ username: 'userSuspended' });
await post(userSuspended, { text: 'test' });
await successfulApiCall({ endpoint: 'i/update', parameters: { description: '#user_testuserSuspended' }, user: userSuspended });
await api('admin/suspend-user', { userId: userSuspended.id }, root);
userDeletedBySelf = await signup({ username: 'userDeletedBySelf', password: 'userDeletedBySelf' });
await post(userDeletedBySelf, { text: 'test' });
await api('i/delete-account', { password: 'userDeletedBySelf' }, userDeletedBySelf);
userDeletedByAdmin = await signup({ username: 'userDeletedByAdmin' });
await post(userDeletedByAdmin, { text: 'test' });
await api('admin/delete-account', { userId: userDeletedByAdmin.id }, root);
userFollowingAlice = await signup({ username: 'userFollowingAlice' });
await post(userFollowingAlice, { text: 'test' });
await api('following/create', { userId: alice.id }, userFollowingAlice);
userFollowedByAlice = await signup({ username: 'userFollowedByAlice' });
await post(userFollowedByAlice, { text: 'test' });
await api('following/create', { userId: userFollowedByAlice.id }, alice);
userBlockingAlice = await signup({ username: 'userBlockingAlice' });
await post(userBlockingAlice, { text: 'test' });
await api('blocking/create', { userId: alice.id }, userBlockingAlice);
userBlockedByAlice = await signup({ username: 'userBlockedByAlice' });
await post(userBlockedByAlice, { text: 'test' });
await api('blocking/create', { userId: userBlockedByAlice.id }, alice);
userMutingAlice = await signup({ username: 'userMutingAlice' });
await post(userMutingAlice, { text: 'test' });
await api('mute/create', { userId: alice.id }, userMutingAlice);
userMutedByAlice = await signup({ username: 'userMutedByAlice' });
await post(userMutedByAlice, { text: 'test' });
await api('mute/create', { userId: userMutedByAlice.id }, alice);
userRnMutingAlice = await signup({ username: 'userRnMutingAlice' });
await post(userRnMutingAlice, { text: 'test' });
await api('renote-mute/create', { userId: alice.id }, userRnMutingAlice);
userRnMutedByAlice = await signup({ username: 'userRnMutedByAlice' });
await post(userRnMutedByAlice, { text: 'test' });
await api('renote-mute/create', { userId: userRnMutedByAlice.id }, alice);
userFollowRequesting = await signup({ username: 'userFollowRequesting' });
await post(userFollowRequesting, { text: 'test' });
userFollowRequested = userLocking;
await api('following/create', { userId: userFollowRequested.id }, userFollowRequesting);
}, 1000 * 60 * 10);
afterAll(async () => {
await app.close();
});
beforeEach(async () => {
alice = {
...alice,
...await successfulApiCall({ endpoint: 'i', parameters: {}, user: alice }) as any,
};
aliceNote = await successfulApiCall({ endpoint: 'notes/show', parameters: { noteId: aliceNote.id }, user: alice });
});
//#region サインアップ(signup)
test('が作れる。(作りたての状態で自分のユーザー情報が取れる)', async () => {
// SignupApiService.ts
const response = await successfulApiCall({
endpoint: 'signup',
parameters: { username: 'zoe', password: 'password' },
user: undefined,
}) as unknown as User; // BUG MeDetailedに足りないキーがある
// signupの時はtokenが含まれる特別なMeDetailedが返ってくる
assert.match(response.token, /[a-zA-Z0-9]{16}/);
// UserLite
assert.match(response.id, /[0-9a-z]{10}/);
assert.strictEqual(response.name, null);
assert.strictEqual(response.username, 'zoe');
assert.strictEqual(response.host, null);
assert.match(response.avatarUrl, /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/);
assert.strictEqual(response.avatarBlurhash, null);
assert.strictEqual(response.isBot, false);
assert.strictEqual(response.isCat, false);
assert.strictEqual(response.instance, undefined);
assert.deepStrictEqual(response.emojis, {});
assert.strictEqual(response.onlineStatus, 'unknown');
assert.deepStrictEqual(response.badgeRoles, []);
// UserDetailedNotMeOnly
assert.strictEqual(response.url, null);
assert.strictEqual(response.uri, null);
assert.strictEqual(response.movedToUri, null);
assert.strictEqual(response.alsoKnownAs, null);
assert.strictEqual(response.createdAt, new Date(response.createdAt).toISOString());
assert.strictEqual(response.updatedAt, null);
assert.strictEqual(response.lastFetchedAt, null);
assert.strictEqual(response.bannerUrl, null);
assert.strictEqual(response.bannerBlurhash, null);
assert.strictEqual(response.isLocked, false);
assert.strictEqual(response.isSilenced, false);
assert.strictEqual(response.isSuspended, false);
assert.strictEqual(response.description, null);
assert.strictEqual(response.location, null);
assert.strictEqual(response.birthday, null);
assert.strictEqual(response.lang, null);
assert.deepStrictEqual(response.fields, []);
assert.strictEqual(response.followersCount, 0);
assert.strictEqual(response.followingCount, 0);
assert.strictEqual(response.notesCount, 0);
assert.deepStrictEqual(response.pinnedNoteIds, []);
assert.deepStrictEqual(response.pinnedNotes, []);
assert.strictEqual(response.pinnedPageId, null);
assert.strictEqual(response.pinnedPage, null);
assert.strictEqual(response.publicReactions, false);
assert.strictEqual(response.ffVisibility, 'public');
assert.strictEqual(response.twoFactorEnabled, false);
assert.strictEqual(response.usePasswordLessLogin, false);
assert.strictEqual(response.securityKeys, false);
assert.deepStrictEqual(response.roles, []);
assert.strictEqual(response.memo, null);
// MeDetailedOnly
assert.strictEqual(response.avatarId, null);
assert.strictEqual(response.bannerId, null);
assert.strictEqual(response.isModerator, false);
assert.strictEqual(response.isAdmin, false);
assert.strictEqual(response.injectFeaturedNote, true);
assert.strictEqual(response.receiveAnnouncementEmail, true);
assert.strictEqual(response.alwaysMarkNsfw, false);
assert.strictEqual(response.autoSensitive, false);
assert.strictEqual(response.carefulBot, false);
assert.strictEqual(response.autoAcceptFollowed, true);
assert.strictEqual(response.noCrawle, false);
assert.strictEqual(response.isExplorable, true);
assert.strictEqual(response.isDeleted, false);
assert.strictEqual(response.hideOnlineStatus, false);
assert.strictEqual(response.hasUnreadSpecifiedNotes, false);
assert.strictEqual(response.hasUnreadMentions, false);
assert.strictEqual(response.hasUnreadAnnouncement, false);
assert.strictEqual(response.hasUnreadAntenna, false);
assert.strictEqual(response.hasUnreadChannel, false);
assert.strictEqual(response.hasUnreadNotification, false);
assert.strictEqual(response.hasPendingReceivedFollowRequest, false);
assert.deepStrictEqual(response.mutedWords, []);
assert.deepStrictEqual(response.mutedInstances, []);
assert.deepStrictEqual(response.mutingNotificationTypes, []);
assert.deepStrictEqual(response.emailNotificationTypes, ['follow', 'receiveFollowRequest']);
assert.strictEqual(response.showTimelineReplies, false);
assert.deepStrictEqual(response.achievements, []);
assert.deepStrictEqual(response.loggedInDays, 0);
assert.deepStrictEqual(response.policies, DEFAULT_POLICIES);
assert.notStrictEqual(response.email, undefined);
assert.strictEqual(response.emailVerified, false);
assert.deepStrictEqual(response.securityKeysList, []);
});
//#endregion
//#region 自分の情報(i)
test('を読み取ることができること(自分)、キーが過不足なく入っていること。', async () => {
const response = await successfulApiCall({
endpoint: 'i',
parameters: {},
user: userNoNote,
});
const expected = meDetailed(userNoNote, true);
expected.loggedInDays = 1; // iはloggedInDaysを更新する
assert.deepStrictEqual(response, expected);
});
//#endregion
//#region 自分の情報の更新(i/update)
test.each([
{ parameters: (): object => ({ name: null }) },
{ parameters: (): object => ({ name: 'x'.repeat(50) }) },
{ parameters: (): object => ({ name: 'x' }) },
{ parameters: (): object => ({ name: 'My name' }) },
{ parameters: (): object => ({ description: null }) },
{ parameters: (): object => ({ description: 'x'.repeat(1500) }) },
{ parameters: (): object => ({ description: 'x' }) },
{ parameters: (): object => ({ description: 'My description' }) },
{ parameters: (): object => ({ location: null }) },
{ parameters: (): object => ({ location: 'x'.repeat(50) }) },
{ parameters: (): object => ({ location: 'x' }) },
{ parameters: (): object => ({ location: 'My location' }) },
{ parameters: (): object => ({ birthday: '0000-00-00' }) },
{ parameters: (): object => ({ birthday: '9999-99-99' }) },
{ parameters: (): object => ({ lang: 'en-US' }) },
{ parameters: (): object => ({ fields: [] }) },
{ parameters: (): object => ({ fields: [{ name: 'x', value: 'x' }] }) },
{ parameters: (): object => ({ fields: [{ name: 'x'.repeat(3000), value: 'x'.repeat(3000) }] }) }, // BUG? fieldには制限がない
{ parameters: (): object => ({ fields: Array(16).fill({ name: 'x', value: 'y' }) }) },
{ parameters: (): object => ({ isLocked: true }) },
{ parameters: (): object => ({ isLocked: false }) },
{ parameters: (): object => ({ isExplorable: false }) },
{ parameters: (): object => ({ isExplorable: true }) },
{ parameters: (): object => ({ hideOnlineStatus: true }) },
{ parameters: (): object => ({ hideOnlineStatus: false }) },
{ parameters: (): object => ({ publicReactions: false }) },
{ parameters: (): object => ({ publicReactions: true }) },
{ parameters: (): object => ({ autoAcceptFollowed: true }) },
{ parameters: (): object => ({ autoAcceptFollowed: false }) },
{ parameters: (): object => ({ noCrawle: true }) },
{ parameters: (): object => ({ noCrawle: false }) },
{ parameters: (): object => ({ isBot: true }) },
{ parameters: (): object => ({ isBot: false }) },
{ parameters: (): object => ({ isCat: true }) },
{ parameters: (): object => ({ isCat: false }) },
{ parameters: (): object => ({ showTimelineReplies: true }) },
{ parameters: (): object => ({ showTimelineReplies: false }) },
{ parameters: (): object => ({ injectFeaturedNote: true }) },
{ parameters: (): object => ({ injectFeaturedNote: false }) },
{ parameters: (): object => ({ receiveAnnouncementEmail: true }) },
{ parameters: (): object => ({ receiveAnnouncementEmail: false }) },
{ parameters: (): object => ({ alwaysMarkNsfw: true }) },
{ parameters: (): object => ({ alwaysMarkNsfw: false }) },
{ parameters: (): object => ({ autoSensitive: true }) },
{ parameters: (): object => ({ autoSensitive: false }) },
{ parameters: (): object => ({ ffVisibility: 'private' }) },
{ parameters: (): object => ({ ffVisibility: 'followers' }) },
{ parameters: (): object => ({ ffVisibility: 'public' }) },
{ parameters: (): object => ({ mutedWords: Array(19).fill(['xxxxx']) }) },
{ parameters: (): object => ({ mutedWords: [['x'.repeat(194)]] }) },
{ parameters: (): object => ({ mutedWords: [] }) },
{ parameters: (): object => ({ mutedInstances: ['xxxx.xxxxx'] }) },
{ parameters: (): object => ({ mutedInstances: [] }) },
{ parameters: (): object => ({ mutingNotificationTypes: ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'achievementEarned', 'app'] }) },
{ parameters: (): object => ({ mutingNotificationTypes: [] }) },
{ parameters: (): object => ({ emailNotificationTypes: ['mention', 'reply', 'quote', 'follow', 'receiveFollowRequest'] }) },
{ parameters: (): object => ({ emailNotificationTypes: [] }) },
] as const)('を書き換えることができる($#)', async ({ parameters }) => {
const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters(), user: alice });
const expected = { ...meDetailed(alice, true), ...parameters() };
assert.deepStrictEqual(response, expected, inspect(parameters()));
});
test('を書き換えることができる(Avatar)', async () => {
const aliceFile = (await uploadFile(alice)).body;
const parameters = { avatarId: aliceFile.id };
const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters, user: alice });
assert.match(response.avatarUrl ?? '.', /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/);
assert.match(response.avatarBlurhash ?? '.', /[ -~]{54}/);
const expected = {
...meDetailed(alice, true),
avatarId: aliceFile.id,
avatarBlurhash: response.avatarBlurhash,
avatarUrl: response.avatarUrl,
};
assert.deepStrictEqual(response, expected, inspect(parameters));
const parameters2 = { avatarId: null };
const response2 = await successfulApiCall({ endpoint: 'i/update', parameters: parameters2, user: alice });
const expected2 = {
...meDetailed(alice, true),
avatarId: null,
avatarBlurhash: null,
avatarUrl: alice.avatarUrl, // 解除した場合、identiconになる
};
assert.deepStrictEqual(response2, expected2, inspect(parameters));
});
test('を書き換えることができる(Banner)', async () => {
const aliceFile = (await uploadFile(alice)).body;
const parameters = { bannerId: aliceFile.id };
const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters, user: alice });
assert.match(response.bannerUrl ?? '.', /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/);
assert.match(response.bannerBlurhash ?? '.', /[ -~]{54}/);
const expected = {
...meDetailed(alice, true),
bannerId: aliceFile.id,
bannerBlurhash: response.bannerBlurhash,
bannerUrl: response.bannerUrl,
};
assert.deepStrictEqual(response, expected, inspect(parameters));
const parameters2 = { bannerId: null };
const response2 = await successfulApiCall({ endpoint: 'i/update', parameters: parameters2, user: alice });
const expected2 = {
...meDetailed(alice, true),
bannerId: null,
bannerBlurhash: null,
bannerUrl: null,
};
assert.deepStrictEqual(response2, expected2, inspect(parameters));
});
//#endregion
//#region 自分の情報の更新(i/pin, i/unpin)
test('を書き換えることができる(ピン止めノート)', async () => {
const parameters = { noteId: aliceNote.id };
const response = await successfulApiCall({ endpoint: 'i/pin', parameters, user: alice });
const expected = { ...meDetailed(alice, false), pinnedNoteIds: [aliceNote.id], pinnedNotes: [aliceNote] };
assert.deepStrictEqual(response, expected);
const response2 = await successfulApiCall({ endpoint: 'i/unpin', parameters, user: alice });
const expected2 = meDetailed(alice, false);
assert.deepStrictEqual(response2, expected2);
});
//#endregion
//#region メモの更新(users/update-memo)
test.each([
{ label: '最大長', memo: 'x'.repeat(2048) },
{ label: '空文字', memo: '', expects: null },
{ label: 'null', memo: null },
])('を書き換えることができる(メモを$labelに)', async ({ memo, expects }) => {
const expected = { ...await show(bob.id), memo: expects === undefined ? memo : expects };
const parameters = { userId: bob.id, memo };
await successfulApiCall({ endpoint: 'users/update-memo', parameters, user: alice });
const response = await show(bob.id);
assert.deepStrictEqual(response, expected);
});
//#endregion
//#region ユーザー(users)
test.each([
{ label: 'ID昇順', parameters: { limit: 5 }, selector: (u: UserLite): string => u.id },
{ label: 'フォロワー昇順', parameters: { sort: '+follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) },
{ label: 'フォロワー降順', parameters: { sort: '-follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) },
{ label: '登録日時昇順', parameters: { sort: '+createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt },
{ label: '登録日時降順', parameters: { sort: '-createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt },
{ label: '投稿日時昇順', parameters: { sort: '+updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) },
{ label: '投稿日時降順', parameters: { sort: '-updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) },
] as const)('をリスト形式で取得することができる($label', async ({ parameters, selector }) => {
const response = await successfulApiCall({ endpoint: 'users', parameters, user: alice });
// 結果の並びを事前にアサートするのは困難なので返ってきたidに対応するユーザーが返っており、ソート順が正しいことだけを検証する
const users = await Promise.all(response.map(u => show(u.id)));
const expected = users.sort((x, y) => {
const index = (selector(x) < selector(y)) ? -1 : (selector(x) > selector(y)) ? 1 : 0;
return index * (parameters.sort?.startsWith('+') ? -1 : 1);
});
assert.deepStrictEqual(response, expected);
});
test.each([
{ label: '「見つけやすくする」がOFFのユーザーが含まれない', user: (): User => userNotExplorable, excluded: true },
{ label: 'ミュートユーザーが含まれない', user: (): User => userMutedByAlice, excluded: true },
{ label: 'ブロックされているユーザーが含まれない', user: (): User => userBlockedByAlice, excluded: true },
{ label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice, excluded: true },
{ label: '承認制ユーザーが含まれる', user: (): User => userLocking },
{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
{ label: 'サスペンドユーザーが含まれる', user: (): User => userSuspended },
{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
] as const)('をリスト形式で取得することができ、結果に$label', async ({ user, excluded }) => {
const parameters = { limit: 100 };
const response = await successfulApiCall({ endpoint: 'users', parameters, user: alice });
const expected = (excluded ?? false) ? [] : [await show(user().id)];
assert.deepStrictEqual(response.filter((u) => u.id === user().id), expected);
});
test.todo('をリスト形式で取得することができる(リモート, hostname指定');
test.todo('をリスト形式で取得することができるpagenation');
//#endregion
//#region ユーザー情報(users/show)
test.each([
{ label: 'ID指定で自分自身を', parameters: (): object => ({ userId: alice.id }), user: (): User => alice, type: meDetailed },
{ label: 'ID指定で他人を', parameters: (): object => ({ userId: alice.id }), user: (): User => bob, type: userDetailedNotMeWithRelations },
{ label: 'ID指定かつ未認証', parameters: (): object => ({ userId: alice.id }), user: undefined, type: userDetailedNotMe },
{ label: '@指定で自分自身を', parameters: (): object => ({ username: alice.username }), user: (): User => alice, type: meDetailed },
{ label: '@指定で他人を', parameters: (): object => ({ username: alice.username }), user: (): User => bob, type: userDetailedNotMeWithRelations },
{ label: '@指定かつ未認証', parameters: (): object => ({ username: alice.username }), user: undefined, type: userDetailedNotMe },
] as const)('を取得することができる($label', async ({ parameters, user, type }) => {
const response = await successfulApiCall({ endpoint: 'users/show', parameters: parameters(), user: user?.() });
const expected = type(alice);
assert.deepStrictEqual(response, expected);
});
test.each([
{ label: 'Administratorになっている', user: (): User => userAdmin, me: (): User => userAdmin, selector: (user: User): unknown => user.isAdmin },
{ label: '自分以外から見たときはAdministratorか判定できない', user: (): User => userAdmin, selector: (user: User): unknown => user.isAdmin, expected: (): undefined => undefined },
{ label: 'Moderatorになっている', user: (): User => userModerator, me: (): User => userModerator, selector: (user: User): unknown => user.isModerator },
{ label: '自分以外から見たときはModeratorか判定できない', user: (): User => userModerator, selector: (user: User): unknown => user.isModerator, expected: (): undefined => undefined },
{ label: 'サイレンスになっている', user: (): User => userSilenced, selector: (user: User): unknown => user.isSilenced },
{ label: 'サスペンドになっている', user: (): User => userSuspended, selector: (user: User): unknown => user.isSuspended },
{ label: '削除済みになっている', user: (): User => userDeletedBySelf, me: (): User => userDeletedBySelf, selector: (user: User): unknown => user.isDeleted },
{ label: '自分以外から見たときは削除済みか判定できない', user: (): User => userDeletedBySelf, selector: (user: User): unknown => user.isDeleted, expected: (): undefined => undefined },
{ label: '削除済み(byAdmin)になっている', user: (): User => userDeletedByAdmin, me: (): User => userDeletedByAdmin, selector: (user: User): unknown => user.isDeleted },
{ label: '自分以外から見たときは削除済み(byAdmin)か判定できない', user: (): User => userDeletedByAdmin, selector: (user: User): unknown => user.isDeleted, expected: (): undefined => undefined },
{ label: 'フォロー中になっている', user: (): User => userFollowedByAlice, selector: (user: User): unknown => user.isFollowing },
{ label: 'フォローされている', user: (): User => userFollowingAlice, selector: (user: User): unknown => user.isFollowed },
{ label: 'ブロック中になっている', user: (): User => userBlockedByAlice, selector: (user: User): unknown => user.isBlocking },
{ label: 'ブロックされている', user: (): User => userBlockingAlice, selector: (user: User): unknown => user.isBlocked },
{ label: 'ミュート中になっている', user: (): User => userMutedByAlice, selector: (user: User): unknown => user.isMuted },
{ label: 'リノートミュート中になっている', user: (): User => userRnMutedByAlice, selector: (user: User): unknown => user.isRenoteMuted },
{ label: 'フォローリクエスト中になっている', user: (): User => userFollowRequested, me: (): User => userFollowRequesting, selector: (user: User): unknown => user.hasPendingFollowRequestFromYou },
{ label: 'フォローリクエストされている', user: (): User => userFollowRequesting, me: (): User => userFollowRequested, selector: (user: User): unknown => user.hasPendingFollowRequestToYou },
] as const)('を取得することができ、$labelこと', async ({ user, me, selector, expected }) => {
const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: user().id }, user: me?.() ?? alice });
assert.strictEqual(selector(response), (expected ?? ((): true => true))());
});
test('を取得することができ、Publicなロールがセットされていること', async () => {
const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: userRolePublic.id }, user: alice });
assert.deepStrictEqual(response.badgeRoles, []);
assert.deepStrictEqual(response.roles, [{
id: rolePublic.id,
name: rolePublic.name,
color: rolePublic.color,
iconUrl: rolePublic.iconUrl,
description: rolePublic.description,
isModerator: rolePublic.isModerator,
isAdministrator: rolePublic.isAdministrator,
displayOrder: rolePublic.displayOrder,
}]);
});
test('を取得することができ、バッヂロールがセットされていること', async () => {
const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: userRoleBadge.id }, user: alice });
assert.deepStrictEqual(response.badgeRoles, [{
name: roleBadge.name,
iconUrl: roleBadge.iconUrl,
displayOrder: roleBadge.displayOrder,
}]);
assert.deepStrictEqual(response.roles, []); // バッヂだからといってrolesが取れるとは限らない
});
test('をID指定のリスト形式で取得することができる', async () => {
const parameters = { userIds: [] };
const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: alice });
const expected: [] = [];
assert.deepStrictEqual(response, expected);
});
test('をID指定のリスト形式で取得することができる', async() => {
const parameters = { userIds: [bob.id, alice.id, carol.id] };
const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: alice });
const expected = [
await successfulApiCall({ endpoint: 'users/show', parameters: { userId: bob.id }, user: alice }),
await successfulApiCall({ endpoint: 'users/show', parameters: { userId: alice.id }, user: alice }),
await successfulApiCall({ endpoint: 'users/show', parameters: { userId: carol.id }, user: alice }),
];
assert.deepStrictEqual(response, expected);
});
test.each([
{ label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable },
{ label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice },
{ label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice },
{ label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice },
{ label: '承認制ユーザーが含まれる', user: (): User => userLocking },
{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
{ label: 'サスペンドユーザーが(モデレーターが見るときは)含まれる', user: (): User => userSuspended, me: (): User => root },
// BUG サスペンドユーザーを一般ユーザーから見るとrootユーザーが返ってくる
//{ label: 'サスペンドユーザーが(一般ユーザーが見るときは)含まれない', user: (): User => userSuspended, me: (): User => bob, excluded: true },
{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
] as const)('をID指定のリスト形式で取得することができ、結果に$label', async ({ user, me, excluded }) => {
const parameters = { userIds: [user().id] };
const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: me?.() ?? alice });
const expected = (excluded ?? false) ? [] : [await show(user().id, me?.() ?? alice)];
assert.deepStrictEqual(response, expected);
});
test.todo('をID指定のリスト形式で取得することができる(リモート)');
//#endregion
//#region 検索(users/search)
test('を検索することができる', async () => {
const parameters = { query: 'carol', limit: 10 };
const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice });
const expected = [await show(carol.id)];
assert.deepStrictEqual(response, expected);
});
test('を検索することができる(UserLite)', async () => {
const parameters = { query: 'carol', detail: false, limit: 10 };
const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice });
const expected = [userLite(await show(carol.id))];
assert.deepStrictEqual(response, expected);
});
test.each([
{ label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable },
{ label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice },
{ label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice },
{ label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice },
{ label: '承認制ユーザーが含まれる', user: (): User => userLocking },
{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
{ label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true },
{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
] as const)('を検索することができ、結果に$labelが含まれる', async ({ user, excluded }) => {
const parameters = { query: user().username, limit: 1 };
const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice });
const expected = (excluded ?? false) ? [] : [await show(user().id)];
assert.deepStrictEqual(response, expected);
});
test.todo('を検索することができる(リモート)');
test.todo('を検索することができる(pagenation)');
//#endregion
//#region ID指定検索(users/search-by-username-and-host)
test.each([
{ label: '自分', parameters: { username: 'alice' }, user: (): User[] => [alice] },
{ label: '自分かつusernameが大文字', parameters: { username: 'ALICE' }, user: (): User[] => [alice] },
{ label: 'ローカルのフォロイーでノートなし', parameters: { username: 'userFollowedByAlice' }, user: (): User[] => [userFollowedByAlice] },
{ label: 'ローカルでノートなしは検索に載らない', parameters: { username: 'userNoNote' }, user: (): User[] => [] },
{ label: 'ローカルの他人1', parameters: { username: 'bob' }, user: (): User[] => [bob] },
{ label: 'ローカルの他人2', parameters: { username: 'bob', host: null }, user: (): User[] => [bob] },
{ label: 'ローカルの他人3', parameters: { username: 'bob', host: '.' }, user: (): User[] => [bob] },
{ label: 'ローカル', parameters: { host: null, limit: 1 }, user: (): User[] => [userFollowedByAlice] },
{ label: 'ローカル', parameters: { host: '.', limit: 1 }, user: (): User[] => [userFollowedByAlice] },
])('をID&ホスト指定で検索できる($label)', async ({ parameters, user }) => {
const response = await successfulApiCall({ endpoint: 'users/search-by-username-and-host', parameters, user: alice });
const expected = await Promise.all(user().map(u => show(u.id)));
assert.deepStrictEqual(response, expected);
});
test.each([
{ label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable },
{ label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice },
{ label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice },
{ label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice },
{ label: '承認制ユーザーが含まれる', user: (): User => userLocking },
{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
{ label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true },
{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
] as const)('をID&ホスト指定で検索でき、結果に$label', async ({ user, excluded }) => {
const parameters = { username: user().username };
const response = await successfulApiCall({ endpoint: 'users/search-by-username-and-host', parameters, user: alice });
const expected = (excluded ?? false) ? [] : [await show(user().id)];
assert.deepStrictEqual(response, expected);
});
test.todo('をID&ホスト指定で検索できる(リモート)');
//#endregion
//#region ID指定検索(users/get-frequently-replied-users)
test('がよくリプライをするユーザーのリストを取得できる', async () => {
const parameters = { userId: alice.id, limit: 5 };
const response = await successfulApiCall({ endpoint: 'users/get-frequently-replied-users', parameters, user: alice });
const expected = await Promise.all(usersReplying.slice(0, parameters.limit).map(async (s, i) => ({
user: await show(s.id),
weight: (usersReplying.length - i) / usersReplying.length,
})));
assert.deepStrictEqual(response, expected);
});
test.each([
{ label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable },
{ label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice },
{ label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice },
{ label: 'ブロックしてきているユーザーが含まれない', user: (): User => userBlockingAlice, excluded: true },
{ label: '承認制ユーザーが含まれる', user: (): User => userLocking },
{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
{ label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended },
{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
] as const)('がよくリプライをするユーザーのリストを取得でき、結果に$label', async ({ user, excluded }) => {
const replyTo = (await successfulApiCall({ endpoint: 'users/notes', parameters: { userId: user().id }, user: undefined }))[0];
await post(alice, { text: `@${user().username} test`, replyId: replyTo.id });
const parameters = { userId: alice.id, limit: 100 };
const response = await successfulApiCall({ endpoint: 'users/get-frequently-replied-users', parameters, user: alice });
const expected = (excluded ?? false) ? [] : [await show(user().id)];
assert.deepStrictEqual(response.map(s => s.user).filter((u) => u.id === user().id), expected);
});
//#endregion
//#region ハッシュタグ(hashtags/users)
test.each([
{ label: 'フォロワー昇順', sort: { sort: '+follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) },
{ label: 'フォロワー降順', sort: { sort: '-follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) },
{ label: '登録日時昇順', sort: { sort: '+createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt },
{ label: '登録日時降順', sort: { sort: '-createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt },
{ label: '投稿日時昇順', sort: { sort: '+updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) },
{ label: '投稿日時降順', sort: { sort: '-updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) },
] as const)('をハッシュタグ指定で取得することができる($label)', async ({ sort, selector }) => {
const hashtag = 'test_hashtag';
await successfulApiCall({ endpoint: 'i/update', parameters: { description: `#${hashtag}` }, user: alice });
const parameters = { tag: hashtag, limit: 5, ...sort };
const response = await successfulApiCall({ endpoint: 'hashtags/users', parameters, user: alice });
const users = await Promise.all(response.map(u => show(u.id)));
const expected = users.sort((x, y) => {
const index = (selector(x) < selector(y)) ? -1 : (selector(x) > selector(y)) ? 1 : 0;
return index * (parameters.sort.startsWith('+') ? -1 : 1);
});
assert.deepStrictEqual(response, expected);
});
test.each([
{ label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable },
{ label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice },
{ label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice },
{ label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice },
{ label: '承認制ユーザーが含まれる', user: (): User => userLocking },
{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
{ label: 'サスペンドユーザーが含まれる', user: (): User => userSuspended },
{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
] as const)('をハッシュタグ指定で取得することができ、結果に$label', async ({ user }) => {
const hashtag = `user_test${user().username}`;
if (user() !== userSuspended) {
// サスペンドユーザーはupdateできない。
await successfulApiCall({ endpoint: 'i/update', parameters: { description: `#${hashtag}` }, user: user() });
}
const parameters = { tag: hashtag, limit: 100, sort: '-follower' } as const;
const response = await successfulApiCall({ endpoint: 'hashtags/users', parameters, user: alice });
const expected = [await show(user().id)];
assert.deepStrictEqual(response, expected);
});
test.todo('をハッシュタグ指定で取得することができる(リモート)');
//#endregion
//#region オススメユーザー(users/recommendation)
// BUG users/recommendationは壊れている > QueryFailedError: missing FROM-clause entry for table "note"
test.skip('のオススメを取得することができる', async () => {
const parameters = {};
const response = await successfulApiCall({ endpoint: 'users/recommendation', parameters, user: alice });
const expected = await Promise.all(response.map(u => show(u.id)));
assert.deepStrictEqual(response, expected);
});
//#endregion
//#region ピン止めユーザー(pinned-users)
test('のピン止めユーザーを取得することができる', async () => {
await successfulApiCall({ endpoint: 'admin/update-meta', parameters: { pinnedUsers: [bob.username, `@${carol.username}`] }, user: root });
const parameters = {} as const;
const response = await successfulApiCall({ endpoint: 'pinned-users', parameters, user: alice });
const expected = await Promise.all([bob, carol].map(u => show(u.id)));
assert.deepStrictEqual(response, expected);
});
//#endregion
test.todo('を管理人として確認することができる(admin/show-user)');
test.todo('を管理人として確認することができる(admin/show-users)');
test.todo('をサーバー向けに取得することができる(federation/users)');
});

View File

@ -6,6 +6,7 @@ import WebSocket from 'ws';
import fetch, { Blob, File, RequestInit } from 'node-fetch'; import fetch, { Blob, File, RequestInit } from 'node-fetch';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { JSDOM } from 'jsdom'; import { JSDOM } from 'jsdom';
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
import { entities } from '../src/postgres.js'; import { entities } from '../src/postgres.js';
import { loadConfig } from '../src/config.js'; import { loadConfig } from '../src/config.js';
import type * as misskey from 'misskey-js'; import type * as misskey from 'misskey-js';
@ -31,12 +32,12 @@ export type ApiRequest = {
}; };
export const successfulApiCall = async <T, >(request: ApiRequest, assertion: { export const successfulApiCall = async <T, >(request: ApiRequest, assertion: {
status: number, status?: number,
} = { status: 200 }): Promise<T> => { } = {}): Promise<T> => {
const { endpoint, parameters, user } = request; const { endpoint, parameters, user } = request;
const { status } = assertion;
const res = await api(endpoint, parameters, user); const res = await api(endpoint, parameters, user);
assert.strictEqual(res.status, status, inspect(res.body)); const status = assertion.status ?? (res.body == null ? 204 : 200);
assert.strictEqual(res.status, status, inspect(res.body, { depth: 5, colors: true }));
return res.body; return res.body;
}; };
@ -188,6 +189,36 @@ export const channel = async (user: any, channel: any = {}): Promise<any> => {
return res.body; return res.body;
}; };
export const role = async (user: any, role: any = {}, policies: any = {}): Promise<any> => {
const res = await api('admin/roles/create', {
asBadge: false,
canEditMembersByModerator: false,
color: null,
condFormula: {
id: 'ebef1684-672d-49b6-ad82-1b3ec3784f85',
type: 'isRemote',
},
description: '',
displayOrder: 0,
iconUrl: null,
isAdministrator: false,
isModerator: false,
isPublic: false,
name: 'New Role',
target: 'manual',
policies: {
...Object.entries(DEFAULT_POLICIES).map(([k, v]) => [k, {
priority: 0,
useDefault: true,
value: v,
}]),
...policies,
},
...role,
}, user);
return res.body;
};
interface UploadOptions { interface UploadOptions {
/** Optional, absolute path or relative from ./resources/ */ /** Optional, absolute path or relative from ./resources/ */
path?: string | URL; path?: string | URL;

Some files were not shown because too many files have changed in this diff Show More