Merge branch 'master' into nyuukyou

This commit is contained in:
ゆめ 2024-10-16 20:22:20 -05:00
commit 80bbdc50a0
424 changed files with 6341 additions and 1859 deletions

2
.github/labeler.yml vendored
View file

@ -6,7 +6,7 @@
'packages/backend:test':
- any:
- changed-files:
- any-glob-to-any-file: ['packages/backend/test/**/*']
- any-glob-to-any-file: ['packages/backend/test/**/*', 'packages/backend/test-federation/**/*']
'packages/frontend':
- any:

59
.github/workflows/test-federation.yml vendored Normal file
View file

@ -0,0 +1,59 @@
name: Test (federation)
on:
push:
branches:
- master
- develop
paths:
- packages/backend/**
- packages/misskey-js/**
- .github/workflows/test-federation.yml
pull_request:
paths:
- packages/backend/**
- packages/misskey-js/**
- .github/workflows/test-federation.yml
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.16.0]
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Install FFmpeg
uses: FedericoCarboni/setup-ffmpeg@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.3
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
- name: Build Misskey
run: |
corepack enable && corepack prepare
pnpm i --frozen-lockfile
pnpm build
- name: Setup
run: |
cd packages/backend/test-federation
bash ./setup.sh
sudo chmod 644 ./certificates/*.test.key
- name: Start servers
# https://github.com/docker/compose/issues/1294#issuecomment-374847206
run: |
cd packages/backend/test-federation
docker compose up -d --scale tester=0
- name: Test
run: |
cd packages/backend/test-federation
docker compose run --no-deps tester
- name: Stop servers
run: |
cd packages/backend/test-federation
docker compose down

2
.gitignore vendored
View file

@ -37,7 +37,7 @@ coverage
!/.config/docker_example.env
!/.config/cypress-devcontainer.yml
docker-compose.yml
compose.yml
./compose.yml
.devcontainer/compose.yml
!/.devcontainer/compose.yml

View file

@ -1,3 +1,26 @@
## 2024.10.1
### Note
- スパム対策として、モデレータ権限を持つユーザのアクティビティが7日以上確認できない場合は自動的に招待制へと切り替えコントロールパネル -> モデレーション -> "誰でも新規登録できるようにする"をオフに変更)るようになりました。 ( #13437 )
- 切り替わった際はモデレーターへお知らせとして通知されます。登録をオープンな状態で継続したい場合は、コントロールパネルから再度設定を行ってください。
### General
- Feat: ユーザーの名前に禁止ワードを設定できるように
### Client
- Enhance: タイムライン表示時のパフォーマンスを向上
- Enhance: アーカイブした個人宛のお知らせを表示・編集できるように
- Enhance: l10nの更新
- Fix: メールアドレス不要でCaptchaが有効な場合にアカウント登録完了後自動でのログインに失敗する問題を修正
### Server
- Feat: モデレータ権限を持つユーザが全員7日間活動しなかった場合は自動的に招待制へと切り替えるように ( #13437 )
- Enhance: 個人宛のお知らせは「わかった」を押すと自動的にアーカイブされるように
- Fix: `admin/emoji/update`エンドポイントのidのみ指定した時不正なエラーが発生するバグを修正
- Fix: RBT有効時、リートのリアクションが反映されない問題を修正
- Fix: キューのエラーログを簡略化するように
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/649)
## 2024.10.0
### Note

View file

@ -181,31 +181,45 @@ MK_DEV_PREFER=backend pnpm dev
- HMR may not work in some environments such as Windows.
## Testing
- Test codes are located in [`/packages/backend/test`](/packages/backend/test).
### Run test
Create a config file.
You can run non-backend tests by executing following commands:
```sh
pnpm --filter frontend test
pnpm --filter misskey-js test
```
Backend tests require manual preparation of servers. See the next section for more on this.
### Backend
There are three types of test codes for the backend:
- Unit tests: [`/packages/backend/test/unit`](/packages/backend/test/unit)
- Single-server E2E tests: [`/packages/backend/test/e2e`](/packages/backend/test/e2e)
- Multiple-server E2E tests: [`/packages/backend/test-federation`](/packages/backend/test-federation)
#### Running Unit Tests or Single-server E2E Tests
1. Create a config file:
```sh
cp .github/misskey/test.yml .config/
```
Prepare DB/Redis for testing.
```
2. Start DB and Redis servers for testing:
```sh
docker compose -f packages/backend/test/compose.yml up
```
Alternatively, prepare an empty (data can be erased) DB and edit `.config/test.yml`.
Instead, you can prepare an empty (data can be erased) DB and edit `.config/test.yml` appropriately.
Run all test.
3. Run all tests:
```sh
pnpm --filter backend test # unit tests
pnpm --filter backend test:e2e # single-server E2E tests
```
pnpm test
If you want to run a specific test, run as a following command:
```sh
pnpm --filter backend test -- packages/backend/test/unit/activitypub.ts
pnpm --filter backend test:e2e -- packages/backend/test/e2e/nodeinfo.ts
```
#### Run specify test
```
pnpm jest -- foo.ts
```
### e2e tests
TODO
#### Running Multiple-server E2E Tests
See [`/packages/backend/test-federation/README.md`](/packages/backend/test-federation/README.md).
## Environment Variable
@ -578,18 +592,18 @@ ESMではディレクトリインポートは廃止されているのと、デ
### Lighten CSS vars
``` css
color: hsl(from var(--accent) h s calc(l + 10));
color: hsl(from var(--MI_THEME-accent) h s calc(l + 10));
```
### Darken CSS vars
``` css
color: hsl(from var(--accent) h s calc(l - 10));
color: hsl(from var(--MI_THEME-accent) h s calc(l - 10));
```
### Add alpha to CSS vars
``` css
color: color(from var(--accent) srgb r g b / 0.5);
color: color(from var(--MI_THEME-accent) srgb r g b / 0.5);
```

View file

@ -34,7 +34,7 @@ defineProps<{
width: 100%;
height: 100%;
cursor: not-allowed;
--color: color(from var(--error) srgb r g b / 0.25);
--color: color(from var(--MI_THEME-error) srgb r g b / 0.25);
background-size: auto auto;
background-image: repeating-linear-gradient(135deg, transparent, transparent 10px, var(--color) 4px, var(--color) 14px);
}

View file

@ -1252,7 +1252,6 @@ _theme:
buttonBg: "خلفية الأزرار"
buttonHoverBg: "خلفية الأزرار (عند التمرير فوقها)"
inputBorder: "حواف حقل الإدخال"
listItemHoverBg: "خلفية عناصر القائمة (عند التمرير فوقها)"
driveFolderBg: "خلفية مجلد قرص التخزين"
messageBg: "خلفية المحادثة"
_sfx:

View file

@ -1017,7 +1017,6 @@ _theme:
buttonBg: "বাটনের পটভূমি"
buttonHoverBg: "বাটনের পটভূমি (হভার)"
inputBorder: "ইনপুট ফিল্ডের বর্ডার"
listItemHoverBg: "লিস্ট আইটেমের পটভূমি (হোভার)"
driveFolderBg: "ড্রাইভ ফোল্ডারের পটভূমি"
wallpaperOverlay: "ওয়ালপেপার ওভারলে"
badge: "ব্যাজ"

View file

@ -453,6 +453,7 @@ totpDescription: "Escriu una contrasenya d'un sol us fent servir l'aplicació d'
moderator: "Moderador/a"
moderation: "Moderació"
moderationNote: "Nota de moderació "
moderationNoteDescription: "Pots escriure notes que es compartiran entre els moderadors."
addModerationNote: "Afegir una nota de moderació "
moderationLogs: "Registre de moderació "
nUsersMentioned: "{n} usuaris mencionats"
@ -1284,6 +1285,15 @@ unknownWebAuthnKey: "Passkey desconeguda"
passkeyVerificationFailed: "La verificació a fallat"
passkeyVerificationSucceededButPasswordlessLoginDisabled: "La verificació de la passkey a estat correcta, però s'ha deshabilitat l'inici de sessió sense contrasenya."
messageToFollower: "Missatge als meus seguidors"
target: "Assumpte "
testCaptchaWarning: "És una característica dissenyada per a la prova de CAPTCHA. <strong>No l'utilitzes en l'entorn real.</strong>"
_abuseUserReport:
forward: "Reenviar "
forwardDescription: "Reenvia l'informe a una altra instància com un compte del sistema anònima."
resolve: "Solució "
accept: "Acceptar "
reject: "Rebutjar"
resolveTutorial: "Si l'informe és legítim selecciona \"Acceptar\" per resoldre'l positivament. Però si l'informe no és legítim selecciona \"Rebutjar\" per resoldre'l negativament."
_delivery:
status: "Estat d'entrega "
stop: "Suspés"
@ -1421,6 +1431,7 @@ _serverSettings:
reactionsBufferingDescription: "Quan s'activa aquesta opció millora bastant el rendiment en recuperar les línies de temps reduint la càrrega de la base. Com a contrapunt, augmentarà l'ús de memòria de Redís. Desactiva aquesta opció en cas de tenir un servidor amb poca memòria o si tens problemes d'inestabilitat."
inquiryUrl: "URL de consulta "
inquiryUrlDescription: "Escriu adreça URL per al formulari de consulta per al mantenidor del servidor o una pàgina web amb el contacte d'informació."
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "Si no es detecta activitat per part del moderador durant un període de temps, aquesta opció es desactiva automàticament per evitar el correu brossa."
_accountMigration:
moveFrom: "Migrar un altre compte a aquest"
moveFromSub: "Crear un àlies per un altre compte"
@ -1974,7 +1985,6 @@ _theme:
buttonBg: "Fons botó "
buttonHoverBg: "Fons botó (en passar-hi per sobre)"
inputBorder: "Contorn del cap d'introducció "
listItemHoverBg: "Fons dels elements d'una llista"
driveFolderBg: "Fons de la carpeta Disc"
wallpaperOverlay: "Superposició del fons de pantalla "
badge: "Insígnia "
@ -2520,6 +2530,8 @@ _moderationLogTypes:
markSensitiveDriveFile: "Fitxer marcat com a sensible"
unmarkSensitiveDriveFile: "S'ha tret la marca de sensible del fitxer"
resolveAbuseReport: "Informe resolt"
forwardAbuseReport: "Informe reenviat"
updateAbuseReportNote: "Nota de moderació d'un informe actualitzat"
createInvitation: "Crear codi d'invitació "
createAd: "Anunci creat"
deleteAd: "Anunci esborrat"

View file

@ -1629,7 +1629,6 @@ _theme:
buttonBg: "Pozadí tlačítka"
buttonHoverBg: "Pozadí tlačítka (Hover)"
inputBorder: "Ohraničení vstupního pole"
listItemHoverBg: "Pozadí položky seznamu (Hover)"
driveFolderBg: "Pozadí složky disku"
wallpaperOverlay: "Překrytí tapety"
badge: "Odznak"

View file

@ -1784,7 +1784,6 @@ _theme:
buttonBg: "Hintergrund von Schaltflächen"
buttonHoverBg: "Hintergrund von Schaltflächen (Mouseover)"
inputBorder: "Rahmen von Eingabefeldern"
listItemHoverBg: "Hintergrund von Listeneinträgen (Mouseover)"
driveFolderBg: "Hintergrund von Drive-Ordnern"
wallpaperOverlay: "Hintergrundbild-Overlay"
badge: "Wappen"

View file

@ -1984,7 +1984,6 @@ _theme:
buttonBg: "Button background"
buttonHoverBg: "Button background (Hover)"
inputBorder: "Input field border"
listItemHoverBg: "List item background (Hover)"
driveFolderBg: "Drive folder background"
wallpaperOverlay: "Wallpaper overlay"
badge: "Badge"

View file

@ -1915,7 +1915,6 @@ _theme:
buttonBg: "Fondo de botón"
buttonHoverBg: "Fondo de botón (hover)"
inputBorder: "Borde de los campos de entrada"
listItemHoverBg: "Fondo de elemento de listas (hover)"
driveFolderBg: "Fondo de capeta del drive"
wallpaperOverlay: "Transparencia del fondo de pantalla"
badge: "Medalla"

View file

@ -1701,7 +1701,6 @@ _theme:
buttonBg: "Arrière-plan du bouton"
buttonHoverBg: "Arrière-plan du bouton (survolé)"
inputBorder: "Cadre de la zone de texte"
listItemHoverBg: "Arrière-plan d'item de liste (survolé)"
driveFolderBg: "Arrière-plan du dossier de disque"
wallpaperOverlay: "Superposition de fond d'écran"
badge: "Badge"

View file

@ -1924,7 +1924,6 @@ _theme:
buttonBg: "Latar belakang tombol"
buttonHoverBg: "Latar belakang tombol (Mengambang)"
inputBorder: "Batas bidang masukan"
listItemHoverBg: "Latar belakang daftar item (Mengambang)"
driveFolderBg: "Latar belakang folder drive"
wallpaperOverlay: "Lapisan wallpaper"
badge: "Lencana"

40
locales/index.d.ts vendored
View file

@ -4366,6 +4366,10 @@ export interface Locale extends ILocale {
*
*/
"enableChartsForFederatedInstances": string;
/**
*
*/
"enableStatsForFederatedInstances": string;
/**
*
*/
@ -5166,6 +5170,26 @@ export interface Locale extends ILocale {
*
*/
"target": string;
/**
* CAPTCHAのテストを目的とした機能です<strong>使</strong>
*/
"testCaptchaWarning": string;
/**
*
*/
"prohibitedWordsForNameOfUser": string;
/**
*
*/
"prohibitedWordsForNameOfUserDescription": string;
/**
*
*/
"yourNameContainsProhibitedWords": string;
/**
* 使
*/
"yourNameContainsProhibitedWordsDescription": string;
"_abuseUserReport": {
/**
*
@ -5696,6 +5720,10 @@ export interface Locale extends ILocale {
* URLやWebページのURLを指定します
*/
"inquiryUrlDescription": string;
/**
*
*/
"thisSettingWillAutomaticallyOffWhenModeratorsInactive": string;
};
"_accountMigration": {
/**
@ -7705,10 +7733,6 @@ export interface Locale extends ILocale {
*
*/
"inputBorder": string;
/**
* ()
*/
"listItemHoverBg": string;
/**
*
*/
@ -9637,6 +9661,14 @@ export interface Locale extends ILocale {
*
*/
"userCreated": string;
/**
*
*/
"inactiveModeratorsWarning": string;
/**
*
*/
"inactiveModeratorsInvitationOnlyChanged": string;
};
/**
* Webhookを削除しますか

View file

@ -454,6 +454,7 @@ totpDescription: "Puoi autenticarti inserendo un codice OTP tramite la tua App d
moderator: "Moderatore"
moderation: "moderazione"
moderationNote: "Promemoria di moderazione"
moderationNoteDescription: "Puoi scrivere promemoria condivisi solo tra moderatori."
addModerationNote: "Aggiungi promemoria di moderazione"
moderationLogs: "Cronologia di moderazione"
nUsersMentioned: "{n} profili ne parlano"
@ -841,7 +842,7 @@ onlineStatus: "Stato di connessione"
hideOnlineStatus: "Modalità invisibile"
hideOnlineStatusDescription: "Attivando questa opzione potresti ridurre l'usabilità di alcune funzioni, come la ricerca."
online: "Online"
active: "Attività"
active: "Attivo"
offline: "Offline"
notRecommended: "Sconsigliato"
botProtection: "Protezione contro i bot"
@ -1086,6 +1087,7 @@ retryAllQueuesConfirmTitle: "Vuoi ritentare adesso?"
retryAllQueuesConfirmText: "Potrebbe sovraccaricare il server temporaneamente."
enableChartsForRemoteUser: "Abilita i grafici per i profili remoti"
enableChartsForFederatedInstances: "Abilita i grafici per le istanze federate"
enableStatsForFederatedInstances: "Informazioni statistiche sui server federati"
showClipButtonInNoteFooter: "Aggiungi il bottone Clip tra le azioni delle Note"
reactionsDisplaySize: "Grandezza delle reazioni"
limitWidthOfReaction: "Limita la larghezza delle reazioni e ridimensionale"
@ -1285,6 +1287,19 @@ unknownWebAuthnKey: "Questa è una passkey sconosciuta."
passkeyVerificationFailed: "La verifica della passkey non è riuscita."
passkeyVerificationSucceededButPasswordlessLoginDisabled: "La verifica della passkey è riuscita, ma l'accesso senza password è disabilitato."
messageToFollower: "Messaggio ai follower"
target: "Riferimento"
testCaptchaWarning: "Questa funzione è destinata al test CAPTCHA. <strong>Da non utilizzare in ambiente di produzione.</strong>"
prohibitedWordsForNameOfUser: "Parole proibite (nome utente)"
prohibitedWordsForNameOfUserDescription: "Il sistema rifiuta di rinominare un utente, se il nome contiene qualsiasi parola nell'elenco. Sono esenti i profili con privilegi di moderazione."
yourNameContainsProhibitedWords: "Il nome che hai scelto contiene una o più parole vietate"
yourNameContainsProhibitedWordsDescription: "Se desideri comunque utilizzare questo nome, contatta l''amministrazione."
_abuseUserReport:
forward: "Inoltra"
forwardDescription: "Inoltra il report al server remoto, per mezzo di account di sistema, anonimo."
resolve: "Risolvi"
accept: "Approva"
reject: "Rifiuta"
resolveTutorial: "Se moderi una segnalazione legittima, scegli \"Approva\" per risolvere positivamente.\nSe la segnalazione non è legittima, seleziona \"Rifiuta\" per risolvere negativamente."
_delivery:
status: "Stato della consegna"
stop: "Sospensione"
@ -1312,16 +1327,16 @@ _bubbleGame:
_announcement:
forExistingUsers: "Solo ai profili attuali"
forExistingUsersDescription: "L'annuncio sarà visibile solo ai profili esistenti in questo momento. Se disabilitato, sarà visibile anche ai profili che verranno creati dopo la pubblicazione di questo annuncio."
needConfirmationToRead: "Richiede la conferma di lettura"
needConfirmationToReadDescription: "Sarà visualizzata una finestra di dialogo che richiede la conferma di lettura. Inoltre, non è soggetto a conferme di lettura massicce."
needConfirmationToRead: "Conferma di lettura obbligatoria"
needConfirmationToReadDescription: "I profili riceveranno una finestra di dialogo che richiede di accettare obbligatoriamente per procedere. Tale richiesta è esente da \"conferma tutte\"."
end: "Archivia l'annuncio"
tooManyActiveAnnouncementDescription: "L'esperienza delle persone può peggiorare se ci sono troppi annunci attivi. Considera anche l'archiviazione degli annunci conclusi."
readConfirmTitle: "Segnare come già letto?"
readConfirmText: "Hai già letto \"{title}˝?"
shouldNotBeUsedToPresentPermanentInfo: "Ti consigliamo di utilizzare gli annunci per pubblicare informazioni tempestive e limitate nel tempo, anziché informazioni importanti a lungo andare nel tempo, poiché potrebbero risultare difficili da ritrovare e peggiorare la fruibilità del servizio, specialmente alle nuove persone iscritte."
dialogAnnouncementUxWarn: "Ti consigliamo di usarli con cautela, poiché è molto probabile che avere più di un annuncio in stile \"finestra di dialogo\" peggiori sensibilmente la fruibilità del servizio, specialmente alle nuove persone iscritte."
silence: "Silenziare gli annunci"
silenceDescription: "Se attivi questa opzione, non riceverai notifiche sugli annunci, evitando di contrassegnarle come già lette."
silence: "Annuncio silenzioso"
silenceDescription: "Attivando questa opzione, non invierai la notifica, evitando che debba essere contrassegnata come già letta."
_initialAccountSetting:
accountCreated: "Il tuo profilo è stato creato!"
letsStartAccountSetup: "Per iniziare, impostiamo il tuo profilo."
@ -1422,6 +1437,7 @@ _serverSettings:
reactionsBufferingDescription: "Attivando questa opzione, puoi migliorare significativamente le prestazioni durante la creazione delle reazioni e ridurre il carico sul database. Tuttavia, aumenterà l'impiego di memoria Redis."
inquiryUrl: "URL di contatto"
inquiryUrlDescription: "Specificare l'URL al modulo di contatto, oppure le informazioni con i dati di contatto dell'amministrazione."
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "Per prevenire SPAM, questa impostazione verrà disattivata automaticamente, se non si rileva alcuna attività di moderazione durante un certo periodo di tempo."
_accountMigration:
moveFrom: "Migra un altro profilo dentro a questo"
moveFromSub: "Crea un alias verso un altro profilo remoto"
@ -1975,7 +1991,6 @@ _theme:
buttonBg: "Sfondo del pulsante"
buttonHoverBg: "Sfondo del pulsante (sorvolato)"
inputBorder: "Inquadra casella di testo"
listItemHoverBg: "Sfondo della voce di elenco (sorvolato)"
driveFolderBg: "Sfondo della cartella di disco"
wallpaperOverlay: "Sovrapposizione dello sfondo"
badge: "Distintivo"
@ -2188,7 +2203,7 @@ _widgets:
_userList:
chooseList: "Seleziona una lista"
clicker: "Cliccaggio"
birthdayFollowings: "Chi nacque oggi"
birthdayFollowings: "Compleanni del giorno"
_cw:
hide: "Nascondere"
show: "Continua la lettura..."
@ -2477,6 +2492,8 @@ _webhookSettings:
abuseReport: "Quando arriva una segnalazione"
abuseReportResolved: "Quando una segnalazione è risolta"
userCreated: "Quando viene creato un profilo"
inactiveModeratorsWarning: "Quando un profilo moderatore rimane inattivo per un determinato periodo"
inactiveModeratorsInvitationOnlyChanged: "Quando la moderazione è rimasta inattiva per un determinato periodo e il sistema è cambiato in modalità \"solo inviti\""
deleteConfirm: "Vuoi davvero eliminare il Webhook?"
testRemarks: "Clicca il bottone a destra dell'interruttore, per provare l'invio di un webhook con dati fittizi."
_abuseReport:
@ -2522,6 +2539,8 @@ _moderationLogTypes:
markSensitiveDriveFile: "File nel Drive segnato come esplicito"
unmarkSensitiveDriveFile: "File nel Drive segnato come non esplicito"
resolveAbuseReport: "Segnalazione risolta"
forwardAbuseReport: "Segnalazione inoltrata"
updateAbuseReportNote: "Ha aggiornato la segnalazione"
createInvitation: "Genera codice di invito"
createAd: "Banner creato"
deleteAd: "Banner eliminato"

View file

@ -1087,6 +1087,7 @@ retryAllQueuesConfirmTitle: "今すぐ再試行しますか?"
retryAllQueuesConfirmText: "一時的にサーバーの負荷が増大することがあります。"
enableChartsForRemoteUser: "リモートユーザーのチャートを生成"
enableChartsForFederatedInstances: "リモートサーバーのチャートを生成"
enableStatsForFederatedInstances: "リモートサーバーの情報を取得"
showClipButtonInNoteFooter: "ノートのアクションにクリップを追加"
reactionsDisplaySize: "リアクションの表示サイズ"
limitWidthOfReaction: "リアクションの最大横幅を制限し、縮小して表示する"
@ -1287,6 +1288,11 @@ passkeyVerificationFailed: "パスキーの検証に失敗しました。"
passkeyVerificationSucceededButPasswordlessLoginDisabled: "パスキーの検証に成功しましたが、パスワードレスログインが無効になっています。"
messageToFollower: "フォロワーへのメッセージ"
target: "対象"
testCaptchaWarning: "CAPTCHAのテストを目的とした機能です。<strong>本番環境で使用しないでください。</strong>"
prohibitedWordsForNameOfUser: "禁止ワード(ユーザーの名前)"
prohibitedWordsForNameOfUserDescription: "このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。"
yourNameContainsProhibitedWords: "変更しようとした名前に禁止された文字列が含まれています"
yourNameContainsProhibitedWordsDescription: "名前に禁止されている文字列が含まれています。この名前を使用したい場合は、サーバー管理者にお問い合わせください。"
_abuseUserReport:
forward: "転送"
@ -1440,6 +1446,7 @@ _serverSettings:
reactionsBufferingDescription: "有効にすると、リアクション作成時のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。"
inquiryUrl: "問い合わせ先URL"
inquiryUrlDescription: "サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定します。"
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "一定期間モデレーターのアクティビティが検出されなかった場合、スパム防止のためこの設定は自動でオフになります。"
_accountMigration:
moveFrom: "別のアカウントからこのアカウントに移行"
@ -2018,7 +2025,6 @@ _theme:
buttonBg: "ボタンの背景"
buttonHoverBg: "ボタンの背景 (ホバー)"
inputBorder: "入力ボックスの縁取り"
listItemHoverBg: "リスト項目の背景 (ホバー)"
driveFolderBg: "ドライブフォルダーの背景"
wallpaperOverlay: "壁紙のオーバーレイ"
badge: "バッジ"
@ -2553,6 +2559,8 @@ _webhookSettings:
abuseReport: "ユーザーから通報があったとき"
abuseReportResolved: "ユーザーからの通報を処理したとき"
userCreated: "ユーザーが作成されたとき"
inactiveModeratorsWarning: "モデレーターが一定期間非アクティブになったとき"
inactiveModeratorsInvitationOnlyChanged: "モデレーターが一定期間非アクティブだったため、システムにより招待制へと変更されたとき"
deleteConfirm: "Webhookを削除しますか"
testRemarks: "スイッチの右にあるボタンをクリックするとダミーのデータを使用したテスト用Webhookを送信できます。"

View file

@ -1943,7 +1943,6 @@ _theme:
buttonBg: "ボタンの背景"
buttonHoverBg: "ボタンの背景 (ホバー)"
inputBorder: "入力ボックスの縁取り"
listItemHoverBg: "リスト項目の背景 (ホバー)"
driveFolderBg: "ドライブフォルダーの背景"
wallpaperOverlay: "壁紙のオーバーレイ"
badge: "バッジ"

View file

@ -1087,6 +1087,7 @@ retryAllQueuesConfirmTitle: "지금 다시 시도하시겠습니까?"
retryAllQueuesConfirmText: "일시적으로 서버의 부하가 증가할 수 있습니다."
enableChartsForRemoteUser: "리모트 유저의 차트를 생성"
enableChartsForFederatedInstances: "리모트 서버의 차트를 생성"
enableStatsForFederatedInstances: "리모트 서버 정보 받아오기"
showClipButtonInNoteFooter: "노트 동작에 클립을 추가"
reactionsDisplaySize: "리액션 표시 크기"
limitWidthOfReaction: "리액션의 최대 폭을 제한하고 작게 표시하기"
@ -1287,6 +1288,11 @@ passkeyVerificationFailed: "패스키 검증을 실패했습니다."
passkeyVerificationSucceededButPasswordlessLoginDisabled: "패스키를 검증했으나, 비밀번호 없이 로그인하기가 꺼져 있습니다."
messageToFollower: "팔로워에 보낼 메시지"
target: "대상"
testCaptchaWarning: "CAPTCHA를 테스트하기 위한 기능입니다. <strong>실제 환경에서는 사용하지 마세요.</strong>"
prohibitedWordsForNameOfUser: "금지 단어 (사용자 이름)"
prohibitedWordsForNameOfUserDescription: "이 목록에 포함되는 키워드가 사용자 이름에 있는 경우, 일반 사용자는 이름을 바꿀 수 없습니다. 모더레이터 권한을 가진 사용자는 제한 대상에서 제외됩니다."
yourNameContainsProhibitedWords: "바꾸려는 이름에 금지된 키워드가 포함되어 있습니다."
yourNameContainsProhibitedWordsDescription: "이름에 금지된 키워드가 있습니다. 이름을 사용해야 하는 경우, 서버 관리자에 문의하세요."
_abuseUserReport:
forward: "전달"
forwardDescription: "익명 시스템 계정을 사용하여 리모트 서버에 신고 내용을 전달할 수 있습니다."
@ -1431,6 +1437,7 @@ _serverSettings:
reactionsBufferingDescription: "활성화 한 경우, 리액션 작성 퍼포먼스가 대폭 향상되어 DB의 부하를 줄일 수 있으나, Redis의 메모리 사용량이 많아집니다."
inquiryUrl: "문의처 URL"
inquiryUrlDescription: "서버 운영자에게 보내는 문의 양식의 URL이나 운영자의 연락처 등이 적힌 웹 페이지의 URL을 설정합니다."
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "일정 기간동안 모더레이터의 활동이 감지되지 않는 경우, 스팸 방지를 위해 이 설정은 자동으로 꺼집니다."
_accountMigration:
moveFrom: "다른 계정에서 이 계정으로 이사"
moveFromSub: "다른 계정에 대한 별칭을 생성"
@ -1984,7 +1991,6 @@ _theme:
buttonBg: "버튼 배경"
buttonHoverBg: "버튼 배경 (호버)"
inputBorder: "입력 필드 테두리"
listItemHoverBg: "리스트 항목 배경 (호버)"
driveFolderBg: "드라이브 폴더 배경"
wallpaperOverlay: "배경화면 오버레이"
badge: "배지"
@ -2486,6 +2492,8 @@ _webhookSettings:
abuseReport: "유저롭"
abuseReportResolved: "받은 신고를 처리했을 때"
userCreated: "유저가 생성되었을 때"
inactiveModeratorsWarning: "모더레이터가 일정 기간동안 활동하지 않은 경우"
inactiveModeratorsInvitationOnlyChanged: "모더레이터가 일정 기간 활동하지 않아 시스템에 의해 초대제로 바뀐 경우"
deleteConfirm: "Webhook을 삭제할까요?"
testRemarks: "스위치 오른쪽에 있는 버튼을 클릭하여 더미 데이터를 사용한 테스트용 웹 훅을 보낼 수 있습니다."
_abuseReport:

View file

@ -1205,7 +1205,6 @@ _theme:
buttonBg: "Tło przycisku"
buttonHoverBg: "Tło przycisku (po najechaniu)"
inputBorder: "Obramowanie pola wejścia"
listItemHoverBg: "Tło elementu listy (po najechaniu)"
driveFolderBg: "Tło folderu na dysku"
wallpaperOverlay: "Nakładka tapety"
badge: "Odznaka"

View file

@ -25,7 +25,7 @@ basicSettings: "Configurações básicas"
otherSettings: "Outras configurações"
openInWindow: "Abrir em um janela"
profile: "Perfil"
timeline: "Cronologia"
timeline: "Linha do tempo"
noAccountDescription: "Este usuário não tem uma descrição."
login: "Iniciar sessão"
loggingIn: "Iniciando sessão…"
@ -1944,7 +1944,6 @@ _theme:
buttonBg: "Plano de fundo de botão"
buttonHoverBg: "Plano de fundo de botão (Selecionado)"
inputBorder: "Borda de campo digitável"
listItemHoverBg: "Plano de fundo do item de uma lista (Selecionado)"
driveFolderBg: "Plano de fundo da pasta no Drive"
wallpaperOverlay: "Sobreposição do papel de parede."
badge: "Emblema"

View file

@ -1694,7 +1694,6 @@ _theme:
buttonBg: "Фон кнопки"
buttonHoverBg: "Текст кнопки"
inputBorder: "Рамка поля ввода"
listItemHoverBg: "Фон пункта списка (под указателем)"
driveFolderBg: "Фон папки «Диска»"
wallpaperOverlay: "Слой обоев"
badge: "Значок"

View file

@ -1108,7 +1108,6 @@ _theme:
buttonBg: "Pozadie tlačidla"
buttonHoverBg: "Pozadie tlačidla (pod kurzorom)"
inputBorder: "Okraj vstupného poľa"
listItemHoverBg: "Pozadie položky zoznamu (pod kurzorom)"
driveFolderBg: "Pozadie priečinu disku"
wallpaperOverlay: "Vrstvenie pozadia"
badge: "Odznak"

View file

@ -1943,7 +1943,6 @@ _theme:
buttonBg: "ปุ่มพื้นหลัง"
buttonHoverBg: "ปุ่มพื้นหลัง (โฮเวอร์)"
inputBorder: "เส้นขอบของช่องป้อนข้อมูล"
listItemHoverBg: "รายการไอเทมพื้นหลัง (โฮเวอร์)"
driveFolderBg: "พื้นหลังโฟลเดอร์ไดรฟ์"
wallpaperOverlay: "วอลล์เปเปอร์ซ้อนทับ"
badge: "ตรา"

View file

@ -1302,7 +1302,6 @@ _theme:
buttonBg: "Фон кнопки"
buttonHoverBg: "Фон кнопки (при наведенні)"
inputBorder: "Край поля вводу"
listItemHoverBg: "Фон елементу в списку (при наведенні)"
driveFolderBg: "Фон папки на диску"
wallpaperOverlay: "Накладання шпалер"
badge: "Значок"

View file

@ -1546,7 +1546,6 @@ _theme:
buttonBg: "Nền nút"
buttonHoverBg: "Nền nút (Chạm)"
inputBorder: "Đường viền khung soạn thảo"
listItemHoverBg: "Nền mục liệt kê (Chạm)"
driveFolderBg: "Nền thư mục Ổ đĩa"
wallpaperOverlay: "Lớp phủ hình nền"
badge: "Huy hiệu"

View file

@ -1087,6 +1087,7 @@ retryAllQueuesConfirmTitle: "要再尝试一次吗?"
retryAllQueuesConfirmText: "可能会使服务器负荷在一定时间内增加"
enableChartsForRemoteUser: "生成远程用户的图表"
enableChartsForFederatedInstances: "生成远程服务器的图表"
enableStatsForFederatedInstances: "获取远程服务器的信息"
showClipButtonInNoteFooter: "在贴文下方显示便签按钮"
reactionsDisplaySize: "回应显示大小"
limitWidthOfReaction: "限制回应的最大宽度,并将其缩小显示"
@ -1287,6 +1288,11 @@ passkeyVerificationFailed: "验证通行密钥失败。"
passkeyVerificationSucceededButPasswordlessLoginDisabled: "通行密钥验证成功,但账户未开启无密码登录。"
messageToFollower: "给关注者的消息"
target: "对象"
testCaptchaWarning: "此功能为测试 CAPTCHA 用。<strong>请勿在正式环境中使用。</strong>"
prohibitedWordsForNameOfUser: "用户名中禁止的词"
prohibitedWordsForNameOfUserDescription: "更改用户名时,如果用户名中包含此列表里的词汇,用户的改名请求将被拒绝。持有管理员权限的用户不受此限制。"
yourNameContainsProhibitedWords: "目标用户名包含违禁词"
yourNameContainsProhibitedWordsDescription: "用户名内含有违禁词。若想使用此用户名,请联系服务器管理员。"
_abuseUserReport:
forward: "转发"
forwardDescription: "目标是匿名系统账户,将把举报转发给远程服务器。"
@ -1431,6 +1437,7 @@ _serverSettings:
reactionsBufferingDescription: "开启时可显著提高发送回应时的性能,及减轻数据库负荷。但 Redis 的内存用量会相应增加。"
inquiryUrl: "联络地址"
inquiryUrlDescription: "用来指定诸如向服务运营商咨询的论坛地址,或记载了运营商联系方式之类的网页地址。"
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "若在一段时间内没有检测到管理活动,为防止垃圾信息,此设定将自动关闭。"
_accountMigration:
moveFrom: "从别的账号迁移到此账户"
moveFromSub: "为另一个账户建立别名"
@ -1984,7 +1991,6 @@ _theme:
buttonBg: "按钮背景"
buttonHoverBg: "按钮背景(悬停)"
inputBorder: "输入框边框"
listItemHoverBg: "下拉列表项目背景(悬停)"
driveFolderBg: "网盘的文件夹背景"
wallpaperOverlay: "壁纸叠加层"
badge: "徽章"
@ -2263,7 +2269,7 @@ _profile:
avatarDecorationMax: "最多可添加 {max} 个挂件"
followedMessage: "被关注时显示的消息"
followedMessageDescription: "可以设置被关注时向对方显示的短消息。"
followedMessageDescriptionForLockedAccount: "需要批准才能关注的情况下,消息是在请求被批准后显示。"
followedMessageDescriptionForLockedAccount: "需要批准才能关注的情况下,消息是在请求被批准后显示。"
_exportOrImport:
allNotes: "所有帖子"
favoritedNotes: "收藏的帖子"
@ -2486,6 +2492,8 @@ _webhookSettings:
abuseReport: "当收到举报时"
abuseReportResolved: "当举报被处理时"
userCreated: "当用户被创建时"
inactiveModeratorsWarning: "当管理员在一段时间内不活跃时"
inactiveModeratorsInvitationOnlyChanged: "当因为管理员在一段时间内不活跃,导致服务器变为邀请制时"
deleteConfirm: "要删除 webhook 吗?"
testRemarks: "点击开关右侧的按钮,可以发送使用假数据的测试 Webhook。"
_abuseReport:

View file

@ -454,6 +454,7 @@ totpDescription: "以驗證應用程式輸入一次性密碼"
moderator: "審查員"
moderation: "審查"
moderationNote: "管理筆記"
moderationNoteDescription: "您可以編寫僅在審查員之間共用的註解。"
addModerationNote: "新增管理筆記"
moderationLogs: "管理日誌"
nUsersMentioned: "被 {n} 個人提及"
@ -519,7 +520,7 @@ menuStyle: "選單風格"
style: "風格"
drawer: "側邊欄"
popup: "彈出式視窗"
showNoteActionsOnlyHover: "僅在游標停留時顯示貼文的操作選項"
showNoteActionsOnlyHover: "僅在游標停留時顯示貼文的"
showReactionsCount: "顯示貼文的反應數目"
noHistory: "沒有歷史紀錄"
signinHistory: "登入歷史"
@ -1018,7 +1019,7 @@ show: "檢視"
neverShow: "不再顯示"
remindMeLater: "以後再說"
didYouLikeMisskey: "您喜歡 Misskey 嗎?"
pleaseDonate: "Misskey 是由 {host} 使用的免費軟體。請贊助我們,讓開發得以持續!"
pleaseDonate: "Misskey是由{host}使用的免費軟體。請贊助我們,讓開發的工作能夠持續!"
correspondingSourceIsAvailable: "對應的原始碼可以在 {anchor} 處找到。"
roles: "角色"
role: "角色"
@ -1086,6 +1087,7 @@ retryAllQueuesConfirmTitle: "要現在重試嗎?"
retryAllQueuesConfirmText: "伺服器的負荷可能會暫時增加。"
enableChartsForRemoteUser: "生成遠端使用者的圖表"
enableChartsForFederatedInstances: "生成遠端伺服器的圖表"
enableStatsForFederatedInstances: "取得遠端伺服器資訊"
showClipButtonInNoteFooter: "新增摘錄按鈕至貼文"
reactionsDisplaySize: "反應的顯示尺寸"
limitWidthOfReaction: "限制反應的最大寬度,並縮小顯示尺寸。"
@ -1194,7 +1196,7 @@ showRenotes: "顯示其他人的轉發貼文"
edited: "已編輯"
notificationRecieveConfig: "接受通知的設定"
mutualFollow: "互相追隨"
followingOrFollower: "追隨中或追隨者"
followingOrFollower: "追隨中或追隨者"
fileAttachedOnly: "只顯示包含附件的貼文"
showRepliesToOthersInTimeline: "顯示給其他人的回覆"
hideRepliesToOthersInTimeline: "在時間軸上隱藏給其他人的回覆"
@ -1265,7 +1267,7 @@ useNativeUIForVideoAudioPlayer: "使用瀏覽器的 UI 播放影片與音訊"
keepOriginalFilename: "保留原始檔名"
keepOriginalFilenameDescription: "如果關閉此設置,上傳時檔案名稱會自動替換為隨機字串。"
noDescription: "沒有說明文字"
alwaysConfirmFollow: "點擊追隨時總是顯示確認訊息"
alwaysConfirmFollow: "跟隨時總是確認"
inquiry: "聯絡我們"
tryAgain: "請再試一次。"
confirmWhenRevealingSensitiveMedia: "要顯示敏感媒體時需確認"
@ -1285,6 +1287,19 @@ unknownWebAuthnKey: "未註冊的金鑰。"
passkeyVerificationFailed: "驗證金鑰失敗。"
passkeyVerificationSucceededButPasswordlessLoginDisabled: "雖然驗證金鑰成功,但是無密碼登入的方式是停用的。"
messageToFollower: "給追隨者的訊息"
target: "目標 "
testCaptchaWarning: "此功能用於 CAPTCHA 的測試。<strong>請勿在正式環境中使用。</strong>"
prohibitedWordsForNameOfUser: "禁止使用的字詞(使用者名稱)"
prohibitedWordsForNameOfUserDescription: "如果使用者名稱包含此清單中的任何字串,則拒絕重新命名使用者。 具有審查員權限的使用者不受此限制的影響。"
yourNameContainsProhibitedWords: "您嘗試更改的名稱包含禁止的字串"
yourNameContainsProhibitedWordsDescription: "名稱中包含禁止使用的字串。 如果您想使用此名稱,請聯絡您的伺服器管理員。"
_abuseUserReport:
forward: "轉發"
forwardDescription: "以匿名系統帳戶將檢舉轉發至遠端伺服器。"
resolve: "解決"
accept: "接受"
reject: "拒絕"
resolveTutorial: "如果您已回覆正當的檢舉,請選擇「接受」以將案件標記為已解決。\n 如果檢舉的內容不正當,請選擇「拒絕」將案件標記為已解決。"
_delivery:
status: "傳送狀態"
stop: "停止發送"
@ -1422,6 +1437,7 @@ _serverSettings:
reactionsBufferingDescription: "啟用時,可以顯著提高建立反應時的效能並減少資料庫的負載。 但是Redis 記憶體使用量會增加。"
inquiryUrl: "聯絡表單網址"
inquiryUrlDescription: "指定伺服器運營者的聯絡表單網址,或包含運營者聯絡資訊網頁的網址。"
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "為了防止 spam如果一段期間內沒有偵測到審查員的活動此設定將自動關閉。"
_accountMigration:
moveFrom: "從其他帳戶遷移到這個帳戶"
moveFromSub: "為另一個帳戶建立別名"
@ -1435,7 +1451,7 @@ _accountMigration:
startMigration: "遷移"
migrationConfirm: "確定要將這個帳戶遷移至 {account} 嗎?一旦遷移就無法撤銷,也就無法以原來的狀態使用這個帳戶。\n另外請確認在要遷移到的帳戶已經建立了一個別名。"
movedAndCannotBeUndone: "帳戶已遷移。\n遷移無法撤消。"
postMigrationNote: "在完成遷移的 24 小時後解除此帳戶的追隨。此帳戶的追隨中、追隨者數量變為 0。由於不會解除追隨者你的追隨者仍然可以繼續檢視這個帳戶發布給追隨者的貼文。"
postMigrationNote: "取消追蹤此帳戶將在遷移操作後 24 小時執行。\n 此帳戶有 0 個關注者/關注者。 您的關注者仍然可以看到此帳戶的關注者帖子,因為您不會被取消關注。"
movedTo: "要遷移到的帳戶:"
_achievements:
earnedAt: "獲得日期"
@ -1555,7 +1571,7 @@ _achievements:
_markedAsCat:
title: "我是貓"
description: "已將帳戶設定為貓"
flavor: "沒有名字。"
flavor: "沒有名字。"
_following1:
title: "首次追隨"
description: "首次追隨了"
@ -1569,7 +1585,7 @@ _achievements:
title: "一百位朋友"
description: "追隨超過100人了"
_following300:
title: "朋友多"
title: "朋友多"
description: "追隨超過300人了"
_followers1:
title: "第一個追隨者"
@ -1895,7 +1911,7 @@ _channel:
following: "追隨中"
usersCount: "有 {n} 人參與"
notesCount: "有 {n} 篇貼文"
nameAndDescription: "名稱與說明"
nameAndDescription: "名稱"
nameOnly: "僅名稱"
allowRenoteToExternal: "允許在頻道外轉發和引用"
_menuDisplay:
@ -1975,7 +1991,6 @@ _theme:
buttonBg: "按鈕背景"
buttonHoverBg: "按鈕背景 (漂浮)"
inputBorder: "輸入框邊框"
listItemHoverBg: "列表物品背景 (漂浮)"
driveFolderBg: "雲端硬碟文件夾背景"
wallpaperOverlay: "壁紙覆蓋層"
badge: "徽章"
@ -2477,6 +2492,8 @@ _webhookSettings:
abuseReport: "當使用者檢舉時"
abuseReportResolved: "當處理了使用者的檢舉時"
userCreated: "使用者被新增時"
inactiveModeratorsWarning: "當審查員在一段時間內沒有活動時"
inactiveModeratorsInvitationOnlyChanged: "當審查員在一段時間內不活動時,系統會將模式變更為邀請制"
deleteConfirm: "請問是否要刪除 Webhook"
testRemarks: "按下切換開關右側的按鈕,就會將假資料發送至 Webhook。"
_abuseReport:
@ -2491,7 +2508,7 @@ _abuseReport:
mail: "寄送到擁有監察員權限的使用者電子郵件地址(僅在收到檢舉時)"
webhook: "向指定的 SystemWebhook 發送通知(在收到檢舉和解決檢舉時發送)"
keywords: "關鍵字"
notifiedUser: "通知的使用者"
notifiedUser: "通知的使用者"
notifiedWebhook: "使用的 Webhook"
deleteConfirm: "確定要刪除通知對象嗎?"
_moderationLogTypes:
@ -2522,6 +2539,8 @@ _moderationLogTypes:
markSensitiveDriveFile: "標記為敏感檔案"
unmarkSensitiveDriveFile: "撤銷標記為敏感檔案"
resolveAbuseReport: "解決檢舉"
forwardAbuseReport: "轉發檢舉"
updateAbuseReportNote: "更新檢舉的審查備註"
createInvitation: "建立邀請碼"
createAd: "建立廣告"
deleteAd: "刪除廣告"

View file

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

View file

@ -11,7 +11,7 @@ export default [
languageOptions: {
parserOptions: {
parser: tsParser,
project: ['./tsconfig.json', './test/tsconfig.json'],
project: ['./tsconfig.json', './test/tsconfig.json', './test-federation/tsconfig.json'],
sourceType: 'module',
tsconfigRootDir: import.meta.dirname,
},

View file

@ -0,0 +1,13 @@
/*
* For a detailed explanation regarding each configuration property and type check, visit:
* https://jestjs.io/docs/en/configuration.html
*/
const base = require('./jest.config.cjs');
module.exports = {
...base,
testMatch: [
'<rootDir>/test-federation/test/**/*.test.ts',
],
};

View file

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class EnableStatsForFederatedInstances1727318020265 {
name = 'EnableStatsForFederatedInstances1727318020265'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "enableStatsForFederatedInstances" boolean NOT NULL DEFAULT true`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableStatsForFederatedInstances"`);
}
}

View file

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class Testcaptcha1728550878802 {
name = 'Testcaptcha1728550878802'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "enableTestcaptcha" boolean NOT NULL DEFAULT false`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableTestcaptcha"`);
}
}

View file

@ -0,0 +1,14 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class ProhibitedWordsForNameOfUser1728634286056 {
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "prohibitedWordsForNameOfUser" character varying(1024) array NOT NULL DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "prohibitedWordsForNameOfUser"`);
}
}

View file

@ -19,16 +19,18 @@
"watch": "node ./scripts/watch.mjs",
"restart": "pnpm build && pnpm start",
"dev": "node ./scripts/dev.mjs",
"typecheck": "tsc --noEmit && tsc -p test --noEmit",
"eslint": "eslint --quiet \"src/**/*.ts\"",
"typecheck": "tsc --noEmit && tsc -p test --noEmit && tsc -p test-federation --noEmit",
"eslint": "eslint --quiet \"{src,test-federation}/**/*.ts\"",
"lint": "pnpm typecheck && pnpm eslint",
"jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.unit.cjs",
"jest:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.e2e.cjs",
"jest:fed": "node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.fed.cjs",
"jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.unit.cjs",
"jest-and-coverage:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.e2e.cjs",
"jest-clear": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --clearCache",
"test": "pnpm jest",
"test:e2e": "pnpm build && pnpm build:test && pnpm jest:e2e",
"test:fed": "pnpm jest:fed",
"test-and-coverage": "pnpm jest-and-coverage",
"test-and-coverage:e2e": "pnpm build && pnpm build:test && pnpm jest-and-coverage:e2e",
"generate-api-json": "node ./scripts/generate_api_json.js"

View file

@ -61,7 +61,10 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
return;
}
const moderatorIds = await this.roleService.getModeratorIds(true, true);
const moderatorIds = await this.roleService.getModeratorIds({
includeAdmins: true,
excludeExpire: true,
});
for (const moderatorId of moderatorIds) {
for (const abuseReport of abuseReports) {
@ -285,8 +288,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
.log(updater, 'createAbuseReportNotificationRecipient', {
recipientId: id,
recipient: created,
})
.then();
});
return created;
}
@ -324,8 +326,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
recipientId: params.id,
before: beforeEntity,
after: afterEntity,
})
.then();
});
return afterEntity;
}
@ -346,8 +347,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
.log(updater, 'deleteAbuseReportNotificationRecipient', {
recipientId: id,
recipient: entity,
})
.then();
});
}
/**
@ -370,7 +370,10 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
}
// モデレータ権限の有無で通知先設定を振り分ける
const authorizedUserIds = await this.roleService.getModeratorIds(true, true);
const authorizedUserIds = await this.roleService.getModeratorIds({
includeAdmins: true,
excludeExpire: true,
});
const authorizedUserRecipients = Array.of<MiAbuseReportNotificationRecipient>();
const unauthorizedUserRecipients = Array.of<MiAbuseReportNotificationRecipient>();
for (const recipient of userRecipients) {

View file

@ -110,8 +110,7 @@ export class AbuseReportService {
reportId: report.id,
report: report,
resolvedAs: ps.resolvedAs,
})
.then();
});
}
return this.abuseUserReportsRepository.findBy({ id: In(reports.map(it => it.id)) })
@ -148,8 +147,7 @@ export class AbuseReportService {
.log(moderator, 'forwardAbuseReport', {
reportId: report.id,
report: report,
})
.then();
});
}
@bindThis

View file

@ -274,13 +274,15 @@ export class AccountMoveService {
}
// Update instance stats by decreasing remote followers count by the number of local followers who were following the old account.
if (this.userEntityService.isRemoteUser(oldAccount)) {
this.federatedInstanceService.fetch(oldAccount.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, false);
}
});
if (this.meta.enableStatsForFederatedInstances) {
if (this.userEntityService.isRemoteUser(oldAccount)) {
this.federatedInstanceService.fetchOrRegister(oldAccount.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, false);
}
});
}
}
// FIXME: expensive?

View file

@ -209,6 +209,13 @@ export class AnnouncementService {
return;
}
const announcement = await this.announcementsRepository.findOneBy({ id: announcementId });
if (announcement != null && announcement.userId === user.id) {
await this.announcementsRepository.update(announcementId, {
isActive: false,
});
}
if ((await this.getUnreadAnnouncements(user)).length === 0) {
this.globalEventService.publishMainStream(user.id, 'readAllAnnouncements');
}

View file

@ -119,5 +119,18 @@ export class CaptchaService {
throw new Error(`turnstile-failed: ${errorCodes}`);
}
}
@bindThis
public async verifyTestcaptcha(response: string | null | undefined): Promise<void> {
if (response == null) {
throw new Error('testcaptcha-failed: no response provided');
}
const success = response === 'testcaptcha-passed';
if (!success) {
throw new Error('testcaptcha-failed');
}
}
}

View file

@ -103,19 +103,33 @@ export class CustomEmojiService implements OnApplicationShutdown {
}
@bindThis
public async update(id: MiEmoji['id'], data: {
public async update(data: (
{ id: MiEmoji['id'], name?: string; } | { name: string; id?: MiEmoji['id'], }
) & {
driveFile?: MiDriveFile;
name?: string;
category?: string | null;
aliases?: string[];
license?: string | null;
isSensitive?: boolean;
localOnly?: boolean;
roleIdsThatCanBeUsedThisEmojiAsReaction?: MiRole['id'][];
}, moderator?: MiUser): Promise<void> {
const emoji = await this.emojisRepository.findOneByOrFail({ id: id });
const sameNameEmoji = await this.emojisRepository.findOneBy({ name: data.name, host: IsNull() });
if (sameNameEmoji != null && sameNameEmoji.id !== id) throw new Error('name already exists');
}, moderator?: MiUser): Promise<
null
| 'NO_SUCH_EMOJI'
| 'SAME_NAME_EMOJI_EXISTS'
> {
const emoji = data.id
? await this.getEmojiById(data.id)
: await this.getEmojiByName(data.name!);
if (emoji === null) return 'NO_SUCH_EMOJI';
const id = emoji.id;
// IDと絵文字名が両方指定されている場合は絵文字名の変更を行うため重複チェックが必要
const doNameUpdate = data.id && data.name && (data.name !== emoji.name);
if (doNameUpdate) {
const isDuplicate = await this.checkDuplicate(data.name!);
if (isDuplicate) return 'SAME_NAME_EMOJI_EXISTS';
}
await this.emojisRepository.update(emoji.id, {
updatedAt: new Date(),
@ -135,7 +149,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
const packed = await this.emojiEntityService.packDetailed(emoji.id);
if (emoji.name === data.name) {
if (!doNameUpdate) {
this.globalEventService.publishBroadcastStream('emojiUpdated', {
emojis: [packed],
});
@ -157,6 +171,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
after: updated,
});
}
return null;
}
@bindThis

View file

@ -47,7 +47,7 @@ export class FederatedInstanceService implements OnApplicationShutdown {
}
@bindThis
public async fetch(host: string): Promise<MiInstance> {
public async fetchOrRegister(host: string): Promise<MiInstance> {
host = this.utilityService.toPuny(host);
const cached = await this.federatedInstanceCache.get(host);
@ -70,6 +70,24 @@ export class FederatedInstanceService implements OnApplicationShutdown {
}
}
@bindThis
public async fetch(host: string): Promise<MiInstance | null> {
host = this.utilityService.toPuny(host);
const cached = await this.federatedInstanceCache.get(host);
if (cached !== undefined) return cached;
const index = await this.instancesRepository.findOneBy({ host });
if (index == null) {
this.federatedInstanceCache.set(host, null);
return null;
} else {
this.federatedInstanceCache.set(host, index);
return index;
}
}
@bindThis
public async update(id: MiInstance['id'], data: Partial<MiInstance>): Promise<void> {
const result = await this.instancesRepository.createQueryBuilder().update()

View file

@ -82,7 +82,7 @@ export class FetchInstanceMetadataService {
try {
if (!force) {
const _instance = await this.federatedInstanceService.fetch(host);
const _instance = await this.federatedInstanceService.fetchOrRegister(host);
const now = Date.now();
if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) {
// unlock at the finally caluse

View file

@ -511,13 +511,15 @@ export class NoteCreateService implements OnApplicationShutdown {
}
// Register host
if (this.userEntityService.isRemoteUser(user)) {
this.federatedInstanceService.fetch(user.host).then(async i => {
this.updateNotesCountQueue.enqueue(i.id, 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateNote(i.host, note, true);
}
});
if (this.meta.enableStatsForFederatedInstances) {
if (this.userEntityService.isRemoteUser(user)) {
this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
this.updateNotesCountQueue.enqueue(i.id, 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateNote(i.host, note, true);
}
});
}
}
// ハッシュタグ更新

View file

@ -106,13 +106,15 @@ export class NoteDeleteService {
this.perUserNotesChart.update(user, note, false);
}
if (this.userEntityService.isRemoteUser(user)) {
this.federatedInstanceService.fetch(user.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateNote(i.host, note, false);
}
});
if (this.meta.enableStatsForFederatedInstances) {
if (this.userEntityService.isRemoteUser(user)) {
this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateNote(i.host, note, false);
}
});
}
}
}

View file

@ -93,6 +93,13 @@ export class QueueService {
repeat: { pattern: '0 0 * * *' },
removeOnComplete: true,
});
this.systemQueue.add('checkModeratorsActivity', {
}, {
// 毎時30分に起動
repeat: { pattern: '30 * * * *' },
removeOnComplete: true,
});
}
@bindThis

View file

@ -101,6 +101,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
@Injectable()
export class RoleService implements OnApplicationShutdown, OnModuleInit {
private rootUserIdCache: MemorySingleCache<MiUser['id']>;
private rolesCache: MemorySingleCache<MiRole[]>;
private roleAssignmentByUserIdCache: MemoryKVCache<MiRoleAssignment[]>;
private notificationService: NotificationService;
@ -136,6 +137,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
private moderationLogService: ModerationLogService,
private fanoutTimelineService: FanoutTimelineService,
) {
this.rootUserIdCache = new MemorySingleCache<MiUser['id']>(1000 * 60 * 60 * 24 * 7); // 1week. rootユーザのIDは不変なので長めに
this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60); // 1h
this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 5); // 5m
@ -416,49 +418,78 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
}
@bindThis
public async isExplorable(role: { id: MiRole['id']} | null): Promise<boolean> {
public async isExplorable(role: { id: MiRole['id'] } | null): Promise<boolean> {
if (role == null) return false;
const check = await this.rolesRepository.findOneBy({ id: role.id });
if (check == null) return false;
return check.isExplorable;
}
/**
* ID一覧を取得する.
*
* @param opts.includeAdmins (デフォルト: true)
* @param opts.includeRoot rootユーザも含めるか(デフォルト: false)
* @param opts.excludeExpire (デフォルト: false)
*/
@bindThis
public async getModeratorIds(includeAdmins = true, excludeExpire = false): Promise<MiUser['id'][]> {
public async getModeratorIds(opts?: {
includeAdmins?: boolean,
includeRoot?: boolean,
excludeExpire?: boolean,
}): Promise<MiUser['id'][]> {
const includeAdmins = opts?.includeAdmins ?? true;
const includeRoot = opts?.includeRoot ?? false;
const excludeExpire = opts?.excludeExpire ?? false;
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
const moderatorRoles = includeAdmins
? roles.filter(r => r.isModerator || r.isAdministrator)
: roles.filter(r => r.isModerator);
// TODO: isRootなアカウントも含める
const assigns = moderatorRoles.length > 0
? await this.roleAssignmentsRepository.findBy({ roleId: In(moderatorRoles.map(r => r.id)) })
: [];
// Setを経由して重複を除去ユーザIDは重複する可能性があるので
const now = Date.now();
const result = [
// Setを経由して重複を除去ユーザIDは重複する可能性があるので
...new Set(
assigns
.filter(it =>
(excludeExpire)
? (it.expiresAt == null || it.expiresAt.getTime() > now)
: true,
)
.map(a => a.userId),
),
];
const resultSet = new Set(
assigns
.filter(it =>
(excludeExpire)
? (it.expiresAt == null || it.expiresAt.getTime() > now)
: true,
)
.map(a => a.userId),
);
return result.sort((x, y) => x.localeCompare(y));
if (includeRoot) {
const rootUserId = await this.rootUserIdCache.fetch(async () => {
const it = await this.usersRepository.createQueryBuilder('users')
.select('id')
.where({ isRoot: true })
.getRawOne<{ id: string }>();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return it!.id;
});
resultSet.add(rootUserId);
}
return [...resultSet].sort((x, y) => x.localeCompare(y));
}
@bindThis
public async getModerators(includeAdmins = true): Promise<MiUser[]> {
const ids = await this.getModeratorIds(includeAdmins);
const users = ids.length > 0 ? await this.usersRepository.findBy({
id: In(ids),
}) : [];
return users;
public async getModerators(opts?: {
includeAdmins?: boolean,
includeRoot?: boolean,
excludeExpire?: boolean,
}): Promise<MiUser[]> {
const ids = await this.getModeratorIds(opts);
return ids.length > 0
? await this.usersRepository.findBy({
id: In(ids),
})
: [];
}
@bindThis

View file

@ -150,8 +150,8 @@ export class SignupService {
}));
});
this.usersChart.update(account, true).then();
this.userService.notifySystemWebhook(account, 'userCreated').then();
this.usersChart.update(account, true);
this.userService.notifySystemWebhook(account, 'userCreated');
return { account, secret };
}

View file

@ -101,8 +101,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
.log(updater, 'createSystemWebhook', {
systemWebhookId: webhook.id,
webhook: webhook,
})
.then();
});
return webhook;
}
@ -139,8 +138,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
systemWebhookId: beforeEntity.id,
before: beforeEntity,
after: afterEntity,
})
.then();
});
return afterEntity;
}
@ -158,8 +156,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
.log(updater, 'deleteSystemWebhook', {
systemWebhookId: webhook.id,
webhook,
})
.then();
});
}
/**

View file

@ -305,20 +305,22 @@ export class UserFollowingService implements OnModuleInit {
//#endregion
//#region Update instance stats
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
this.federatedInstanceService.fetch(follower.host).then(async i => {
this.instancesRepository.increment({ id: i.id }, 'followingCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowing(i.host, true);
}
});
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
this.federatedInstanceService.fetch(followee.host).then(async i => {
this.instancesRepository.increment({ id: i.id }, 'followersCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, true);
}
});
if (this.meta.enableStatsForFederatedInstances) {
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => {
this.instancesRepository.increment({ id: i.id }, 'followingCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowing(i.host, true);
}
});
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => {
this.instancesRepository.increment({ id: i.id }, 'followersCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, true);
}
});
}
}
//#endregion
@ -437,20 +439,22 @@ export class UserFollowingService implements OnModuleInit {
//#endregion
//#region Update instance stats
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
this.federatedInstanceService.fetch(follower.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowing(i.host, false);
}
});
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
this.federatedInstanceService.fetch(followee.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, false);
}
});
if (this.meta.enableStatsForFederatedInstances) {
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowing(i.host, false);
}
});
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, false);
}
});
}
}
//#endregion

View file

@ -12,6 +12,7 @@ import { Packed } from '@/misc/json-schema.js';
import { type WebhookEventTypes } from '@/models/Webhook.js';
import { UserWebhookService } from '@/core/UserWebhookService.js';
import { QueueService } from '@/core/QueueService.js';
import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
const oneDayMillis = 24 * 60 * 60 * 1000;
@ -446,6 +447,22 @@ export class WebhookTestService {
send(toPackedUserLite(dummyUser1));
break;
}
case 'inactiveModeratorsWarning': {
const dummyTime: ModeratorInactivityRemainingTime = {
time: 100000,
asDays: 1,
asHours: 24,
};
send({
remainingTime: dummyTime,
});
break;
}
case 'inactiveModeratorsInvitationOnlyChanged': {
send({});
break;
}
}
}
}

View file

@ -408,13 +408,15 @@ export class ApPersonService implements OnModuleInit {
this.cacheService.uriPersonCache.set(user.uri, user);
// Register host
this.federatedInstanceService.fetch(host).then(i => {
this.instancesRepository.increment({ id: i.id }, 'usersCount', 1);
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.newUser(i.host);
}
});
if (this.meta.enableStatsForFederatedInstances) {
this.federatedInstanceService.fetchOrRegister(host).then(i => {
this.instancesRepository.increment({ id: i.id }, 'usersCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.newUser(i.host);
}
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
});
}
this.usersChart.update(user, true);

View file

@ -96,6 +96,7 @@ export class MetaEntityService {
recaptchaSiteKey: instance.recaptchaSiteKey,
enableTurnstile: instance.enableTurnstile,
turnstileSiteKey: instance.turnstileSiteKey,
enableTestcaptcha: instance.enableTestcaptcha,
swPublickey: instance.swPublicKey,
themeColor: instance.themeColor,
mascotImageUrl: instance.mascotImageUrl ?? '/assets/ai.png',

View file

@ -22,6 +22,30 @@ import type { ReactionService } from '../ReactionService.js';
import type { UserEntityService } from './UserEntityService.js';
import type { DriveFileEntityService } from './DriveFileEntityService.js';
// is-renote.tsとよしなにリンク
function isPureRenote(note: MiNote): note is MiNote & { renoteId: MiNote['id']; renote: MiNote } {
return (
note.renote != null &&
note.reply == null &&
note.text == null &&
note.cw == null &&
(note.fileIds == null || note.fileIds.length === 0) &&
!note.hasPoll
);
}
function getAppearNoteIds(notes: MiNote[]): Set<string> {
const appearNoteIds = new Set<string>();
for (const note of notes) {
if (isPureRenote(note)) {
appearNoteIds.add(note.renoteId);
} else {
appearNoteIds.add(note.id);
}
}
return appearNoteIds;
}
@Injectable()
export class NoteEntityService implements OnModuleInit {
private userEntityService: UserEntityService;
@ -90,7 +114,7 @@ export class NoteEntityService implements OnModuleInit {
hide = false;
} else {
// 指定されているかどうか
const specified = packedNote.visibleUserIds!.some((id: any) => meId === id);
const specified = packedNote.visibleUserIds!.some(id => meId === id);
if (specified) {
hide = false;
@ -227,7 +251,7 @@ export class NoteEntityService implements OnModuleInit {
return true;
} else {
// 指定されているかどうか
return note.visibleUserIds.some((id: any) => meId === id);
return note.visibleUserIds.some(id => meId === id);
}
}
@ -421,7 +445,7 @@ export class NoteEntityService implements OnModuleInit {
) {
if (notes.length === 0) return [];
const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany(notes.map(x => x.id)) : null;
const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany([...getAppearNoteIds(notes)]) : null;
const meId = me ? me.id : null;
const myReactionsMap = new Map<MiNote['id'], string | null>();
@ -432,7 +456,7 @@ export class NoteEntityService implements OnModuleInit {
const oldId = this.idService.gen(Date.now() - 2000);
for (const note of notes) {
if (note.renote && (note.text == null && note.fileIds.length === 0)) { // pure renote
if (isPureRenote(note)) {
const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.renote.reactions, bufferedReactions?.get(note.renote.id)?.deltas ?? {})).reduce((a, b) => a + b, 0);
if (reactionsCount === 0) {
myReactionsMap.set(note.renote.id, null);

View file

@ -6,6 +6,8 @@
import type { MiNote } from '@/models/Note.js';
import type { Packed } from '@/misc/json-schema.js';
// NoteEntityService.isPureRenote とよしなにリンク
type Renote =
MiNote & {
renoteId: NonNullable<MiNote['renoteId']>

View file

@ -81,6 +81,11 @@ export class MiMeta {
})
public prohibitedWords: string[];
@Column('varchar', {
length: 1024, array: true, default: '{}',
})
public prohibitedWordsForNameOfUser: string[];
@Column('varchar', {
length: 1024, array: true, default: '{}',
})
@ -258,6 +263,11 @@ export class MiMeta {
})
public turnstileSecretKey: string | null;
@Column('boolean', {
default: false,
})
public enableTestcaptcha: boolean;
// chaptcha系を追加した際にはnodeinfoのレスポンスに追加するのを忘れないようにすること
@Column('enum', {
@ -519,6 +529,11 @@ export class MiMeta {
})
public enableChartsForFederatedInstances: boolean;
@Column('boolean', {
default: true,
})
public enableStatsForFederatedInstances: boolean;
@Column('boolean', {
default: false,
})

View file

@ -14,6 +14,10 @@ export const systemWebhookEventTypes = [
'abuseReportResolved',
// ユーザが作成された時
'userCreated',
// モデレータが一定期間不在である警告
'inactiveModeratorsWarning',
// モデレータが一定期間不在のためシステムにより招待制へと変更された
'inactiveModeratorsInvitationOnlyChanged',
] as const;
export type SystemWebhookEventType = typeof systemWebhookEventTypes[number];

View file

@ -115,6 +115,10 @@ export const packedMetaLiteSchema = {
type: 'string',
optional: false, nullable: true,
},
enableTestcaptcha: {
type: 'boolean',
optional: false, nullable: false,
},
swPublickey: {
type: 'string',
optional: false, nullable: true,

View file

@ -6,6 +6,7 @@
import { Module } from '@nestjs/common';
import { CoreModule } from '@/core/CoreModule.js';
import { GlobalModule } from '@/GlobalModule.js';
import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
import { QueueLoggerService } from './QueueLoggerService.js';
import { QueueProcessorService } from './QueueProcessorService.js';
import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
@ -80,6 +81,8 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
DeliverProcessorService,
InboxProcessorService,
AggregateRetentionProcessorService,
CheckExpiredMutingsProcessorService,
CheckModeratorsActivityProcessorService,
QueueProcessorService,
],
exports: [

View file

@ -10,6 +10,7 @@ import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
@ -66,7 +67,7 @@ function getJobInfo(job: Bull.Job | undefined, increment = false): string {
// onActiveとかonCompletedのattemptsMadeがなぜか0始まりなのでインクリメントする
const currentAttempts = job.attemptsMade + (increment ? 1 : 0);
const maxAttempts = job.opts ? job.opts.attempts : 0;
const maxAttempts = job.opts.attempts ?? 0;
return `id=${job.id} attempts=${currentAttempts}/${maxAttempts} age=${formated}`;
}
@ -120,24 +121,35 @@ export class QueueProcessorService implements OnApplicationShutdown {
private aggregateRetentionProcessorService: AggregateRetentionProcessorService,
private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService,
private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService,
private checkModeratorsActivityProcessorService: CheckModeratorsActivityProcessorService,
private cleanProcessorService: CleanProcessorService,
) {
this.logger = this.queueLoggerService.logger;
function renderError(e: Error): any {
if (e) { // 何故かeがundefinedで来ることがある
return {
stack: e.stack,
message: e.message,
name: e.name,
};
} else {
return {
stack: '?',
message: '?',
name: '?',
};
function renderError(e?: Error) {
// 何故かeがundefinedで来ることがある
if (!e) return '?';
if (e instanceof Bull.UnrecoverableError || e.name === 'AbortError') {
return `${e.name}: ${e.message}`;
}
return {
stack: e.stack,
message: e.message,
name: e.name,
};
}
function renderJob(job?: Bull.Job) {
if (!job) return '?';
return {
name: job.name || undefined,
info: getJobInfo(job),
failedReason: job.failedReason || undefined,
data: job.data,
};
}
//#region system
@ -150,6 +162,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
case 'aggregateRetention': return this.aggregateRetentionProcessorService.process();
case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process();
case 'bakeBufferedReactions': return this.bakeBufferedReactionsProcessorService.process();
case 'checkModeratorsActivity': return this.checkModeratorsActivityProcessorService.process();
case 'clean': return this.cleanProcessorService.process();
default: throw new Error(`unrecognized job type ${job.name} for system`);
}
@ -172,15 +185,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('active', (job) => logger.debug(`active id=${job.id}`))
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err: Error) => {
logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) });
logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
if (config.sentryForBackend) {
Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.message}`, {
Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
})
.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) }))
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
}
//#endregion
@ -229,15 +242,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('active', (job) => logger.debug(`active id=${job.id}`))
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) });
logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
if (config.sentryForBackend) {
Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.message}`, {
Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
})
.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) }))
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
}
//#endregion
@ -269,15 +282,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`))
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
if (config.sentryForBackend) {
Sentry.captureMessage(`Queue: Deliver: ${err.message}`, {
Sentry.captureMessage(`Queue: Deliver: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
})
.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) }))
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
}
//#endregion
@ -309,15 +322,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('active', (job) => logger.debug(`active ${getJobInfo(job, true)}`))
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.stack}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job, e: renderError(err) });
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job: renderJob(job), e: renderError(err) });
if (config.sentryForBackend) {
Sentry.captureMessage(`Queue: Inbox: ${err.message}`, {
Sentry.captureMessage(`Queue: Inbox: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
})
.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) }))
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
}
//#endregion
@ -349,15 +362,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`))
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
if (config.sentryForBackend) {
Sentry.captureMessage(`Queue: UserWebhookDeliver: ${err.message}`, {
Sentry.captureMessage(`Queue: UserWebhookDeliver: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
})
.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) }))
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
}
//#endregion
@ -389,15 +402,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`))
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
if (config.sentryForBackend) {
Sentry.captureMessage(`Queue: SystemWebhookDeliver: ${err.message}`, {
Sentry.captureMessage(`Queue: SystemWebhookDeliver: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
})
.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) }))
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
}
//#endregion
@ -436,15 +449,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('active', (job) => logger.debug(`active id=${job.id}`))
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) });
logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
if (config.sentryForBackend) {
Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.message}`, {
Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
})
.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) }))
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
}
//#endregion
@ -477,15 +490,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('active', (job) => logger.debug(`active id=${job.id}`))
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) });
logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
if (config.sentryForBackend) {
Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.message}`, {
Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
})
.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) }))
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
}
//#endregion

View file

@ -0,0 +1,292 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { MetaService } from '@/core/MetaService.js';
import { RoleService } from '@/core/RoleService.js';
import { EmailService } from '@/core/EmailService.js';
import { MiUser, type UserProfilesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
import { AnnouncementService } from '@/core/AnnouncementService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
// モデレーターが不在と判断する日付の閾値
const MODERATOR_INACTIVITY_LIMIT_DAYS = 7;
// 警告通知やログ出力を行う残日数の閾値
const MODERATOR_INACTIVITY_WARNING_REMAINING_DAYS = 2;
// 期限から6時間ごとに通知を行う
const MODERATOR_INACTIVITY_WARNING_NOTIFY_INTERVAL_HOURS = 6;
const ONE_HOUR_MILLI_SEC = 1000 * 60 * 60;
const ONE_DAY_MILLI_SEC = ONE_HOUR_MILLI_SEC * 24;
export type ModeratorInactivityEvaluationResult = {
isModeratorsInactive: boolean;
inactiveModerators: MiUser[];
remainingTime: ModeratorInactivityRemainingTime;
}
export type ModeratorInactivityRemainingTime = {
time: number;
asHours: number;
asDays: number;
};
function generateModeratorInactivityMail(remainingTime: ModeratorInactivityRemainingTime) {
const subject = 'Moderator Inactivity Warning / モデレーター不在の通知';
const timeVariant = remainingTime.asDays === 0 ? `${remainingTime.asHours} hours` : `${remainingTime.asDays} days`;
const timeVariantJa = remainingTime.asDays === 0 ? `${remainingTime.asHours} 時間` : `${remainingTime.asDays} 日間`;
const message = [
'To Moderators,',
'',
`A moderator has been inactive for a period of time. If there are ${timeVariant} of inactivity left, it will switch to invitation only.`,
'If you do not wish to move to invitation only, you must log into Misskey and update your last active date and time.',
'',
'---------------',
'',
'To モデレーター各位',
'',
`モデレーターが一定期間活動していないようです。あと${timeVariantJa}活動していない状態が続くと招待制に切り替わります。`,
'招待制に切り替わることを望まない場合は、Misskeyにログインして最終アクティブ日時を更新してください。',
'',
];
const html = message.join('<br>');
const text = message.join('\n');
return {
subject,
html,
text,
};
}
function generateInvitationOnlyChangedMail() {
const subject = 'Change to Invitation-Only / 招待制に変更されました';
const message = [
'To Moderators,',
'',
`Changed to invitation only because no moderator activity was detected for ${MODERATOR_INACTIVITY_LIMIT_DAYS} days.`,
'To cancel the invitation only, you need to access the control panel.',
'',
'---------------',
'',
'To モデレーター各位',
'',
`モデレーターの活動が${MODERATOR_INACTIVITY_LIMIT_DAYS}日間検出されなかったため、招待制に変更されました。`,
'招待制を解除するには、コントロールパネルにアクセスする必要があります。',
'',
];
const html = message.join('<br>');
const text = message.join('\n');
return {
subject,
html,
text,
};
}
@Injectable()
export class CheckModeratorsActivityProcessorService {
private logger: Logger;
constructor(
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private metaService: MetaService,
private roleService: RoleService,
private emailService: EmailService,
private announcementService: AnnouncementService,
private systemWebhookService: SystemWebhookService,
private queueLoggerService: QueueLoggerService,
) {
this.logger = this.queueLoggerService.logger.createSubLogger('check-moderators-activity');
}
@bindThis
public async process(): Promise<void> {
this.logger.info('start.');
const meta = await this.metaService.fetch(false);
if (!meta.disableRegistration) {
await this.processImpl();
} else {
this.logger.info('is already invitation only.');
}
this.logger.succ('finish.');
}
@bindThis
private async processImpl() {
const evaluateResult = await this.evaluateModeratorsInactiveDays();
if (evaluateResult.isModeratorsInactive) {
this.logger.warn(`The moderator has been inactive for ${MODERATOR_INACTIVITY_LIMIT_DAYS} days. We will move to invitation only.`);
await this.changeToInvitationOnly();
await this.notifyChangeToInvitationOnly();
} else {
const remainingTime = evaluateResult.remainingTime;
if (remainingTime.asDays <= MODERATOR_INACTIVITY_WARNING_REMAINING_DAYS) {
const timeVariant = remainingTime.asDays === 0 ? `${remainingTime.asHours} hours` : `${remainingTime.asDays} days`;
this.logger.warn(`A moderator has been inactive for a period of time. If you are inactive for an additional ${timeVariant}, it will switch to invitation only.`);
if (remainingTime.asHours % MODERATOR_INACTIVITY_WARNING_NOTIFY_INTERVAL_HOURS === 0) {
// ジョブの実行頻度と同等だと通知が多すぎるため期限から6時間ごとに通知する
// つまり、のこり2日を切ったら6時間ごとに通知が送られる
await this.notifyInactiveModeratorsWarning(remainingTime);
}
}
}
}
/**
* trueの場合はモデレーターが不在である
* isModerator, isAdministrator, isRootのいずれかがtrueのユーザを対象に
* {@link MiUser.lastActiveDate}{@link MODERATOR_INACTIVITY_LIMIT_DAYS}
* {@link MiUser.lastActiveDate}nullの場合は
*
* -----
*
* ###
* - 実行日時: 2022-01-30 12:00:00
* - 判定基準: 2022-01-23 12:00:00{@link MODERATOR_INACTIVITY_LIMIT_DAYS}
*
* ####
* - モデレータA: lastActiveDate = 2022-01-20 00:00:00
* - モデレータB: lastActiveDate = 2022-01-23 12:00:00 0
* - モデレータC: lastActiveDate = 2022-01-23 11:59:59 -1
* - モデレータD: lastActiveDate = null
*
* Bのアクティビティのみ判定基準日よりも古くないため
*
* ####
* - モデレータA: lastActiveDate = 2022-01-20 00:00:00
* - モデレータB: lastActiveDate = 2022-01-22 12:00:00 -1
* - モデレータC: lastActiveDate = 2022-01-23 11:59:59 -1
* - モデレータD: lastActiveDate = null
*
* A, B, Cのアクティビティは判定基準日よりも古いため
*/
@bindThis
public async evaluateModeratorsInactiveDays(): Promise<ModeratorInactivityEvaluationResult> {
const today = new Date();
const inactivePeriod = new Date(today);
inactivePeriod.setDate(today.getDate() - MODERATOR_INACTIVITY_LIMIT_DAYS);
const moderators = await this.fetchModerators()
.then(it => it.filter(it => it.lastActiveDate != null));
const inactiveModerators = moderators
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
.filter(it => it.lastActiveDate!.getTime() < inactivePeriod.getTime());
// 残りの猶予を示したいので、最終アクティブ日時が一番若いモデレータの日数を基準に猶予を計算する
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const newestLastActiveDate = new Date(Math.max(...moderators.map(it => it.lastActiveDate!.getTime())));
const remainingTime = newestLastActiveDate.getTime() - inactivePeriod.getTime();
const remainingTimeAsDays = Math.floor(remainingTime / ONE_DAY_MILLI_SEC);
const remainingTimeAsHours = Math.floor((remainingTime / ONE_HOUR_MILLI_SEC));
return {
isModeratorsInactive: inactiveModerators.length === moderators.length,
inactiveModerators,
remainingTime: {
time: remainingTime,
asHours: remainingTimeAsHours,
asDays: remainingTimeAsDays,
},
};
}
@bindThis
private async changeToInvitationOnly() {
await this.metaService.update({ disableRegistration: true });
}
@bindThis
public async notifyInactiveModeratorsWarning(remainingTime: ModeratorInactivityRemainingTime) {
// -- モデレータへのメール送信
const moderators = await this.fetchModerators();
const moderatorProfiles = await this.userProfilesRepository
.findBy({ userId: In(moderators.map(it => it.id)) })
.then(it => new Map(it.map(it => [it.userId, it])));
const mail = generateModeratorInactivityMail(remainingTime);
for (const moderator of moderators) {
const profile = moderatorProfiles.get(moderator.id);
if (profile && profile.email && profile.emailVerified) {
this.emailService.sendEmail(profile.email, mail.subject, mail.html, mail.text);
}
}
// -- SystemWebhook
const systemWebhooks = await this.systemWebhookService.fetchActiveSystemWebhooks()
.then(it => it.filter(it => it.on.includes('inactiveModeratorsWarning')));
for (const systemWebhook of systemWebhooks) {
this.systemWebhookService.enqueueSystemWebhook(
systemWebhook,
'inactiveModeratorsWarning',
{ remainingTime: remainingTime },
);
}
}
@bindThis
public async notifyChangeToInvitationOnly() {
// -- モデレータへのメールとお知らせ(個人向け)送信
const moderators = await this.fetchModerators();
const moderatorProfiles = await this.userProfilesRepository
.findBy({ userId: In(moderators.map(it => it.id)) })
.then(it => new Map(it.map(it => [it.userId, it])));
const mail = generateInvitationOnlyChangedMail();
for (const moderator of moderators) {
this.announcementService.create({
title: mail.subject,
text: mail.text,
forExistingUsers: true,
needConfirmationToRead: true,
userId: moderator.id,
});
const profile = moderatorProfiles.get(moderator.id);
if (profile && profile.email && profile.emailVerified) {
this.emailService.sendEmail(profile.email, mail.subject, mail.html, mail.text);
}
}
// -- SystemWebhook
const systemWebhooks = await this.systemWebhookService.fetchActiveSystemWebhooks()
.then(it => it.filter(it => it.on.includes('inactiveModeratorsInvitationOnlyChanged')));
for (const systemWebhook of systemWebhooks) {
this.systemWebhookService.enqueueSystemWebhook(
systemWebhook,
'inactiveModeratorsInvitationOnlyChanged',
{},
);
}
}
@bindThis
private async fetchModerators() {
// TODO: モデレーター以外にも特別な権限を持つユーザーがいる場合は考慮する
return this.roleService.getModerators({
includeAdmins: true,
includeRoot: true,
excludeExpire: true,
});
}
}

View file

@ -74,8 +74,17 @@ export class DeliverProcessorService {
try {
await this.apRequestService.signedPost(job.data.user, job.data.to, job.data.content, job.data.digest);
// Update stats
this.federatedInstanceService.fetch(host).then(i => {
this.apRequestChart.deliverSucc();
this.federationChart.deliverd(host, true);
// Update instance stats
process.nextTick(async () => {
const i = await (this.meta.enableStatsForFederatedInstances
? this.federatedInstanceService.fetchOrRegister(host)
: this.federatedInstanceService.fetch(host));
if (i == null) return;
if (i.isNotResponding) {
this.federatedInstanceService.update(i.id, {
isNotResponding: false,
@ -83,9 +92,9 @@ export class DeliverProcessorService {
});
}
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
this.apRequestChart.deliverSucc();
this.federationChart.deliverd(i.host, true);
if (this.meta.enableStatsForFederatedInstances) {
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
}
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.requestSent(i.host, true);
@ -94,8 +103,11 @@ export class DeliverProcessorService {
return 'Success';
} catch (res) {
// Update stats
this.federatedInstanceService.fetch(host).then(i => {
this.apRequestChart.deliverFail();
this.federationChart.deliverd(host, false);
// Update instance stats
this.federatedInstanceService.fetchOrRegister(host).then(i => {
if (!i.isNotResponding) {
this.federatedInstanceService.update(i.id, {
isNotResponding: true,
@ -116,9 +128,6 @@ export class DeliverProcessorService {
});
}
this.apRequestChart.deliverFail();
this.federationChart.deliverd(i.host, false);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.requestSent(i.host, false);
}
@ -129,7 +138,7 @@ export class DeliverProcessorService {
if (!res.isRetryable) {
// 相手が閉鎖していることを明示しているため、配送停止する
if (job.data.isSharedInbox && res.statusCode === 410) {
this.federatedInstanceService.fetch(host).then(i => {
this.federatedInstanceService.fetchOrRegister(host).then(i => {
this.federatedInstanceService.update(i.id, {
suspensionState: 'goneSuspended',
});

View file

@ -192,21 +192,27 @@ export class InboxProcessorService implements OnApplicationShutdown {
}
}
// Update stats
this.federatedInstanceService.fetch(authUser.user.host).then(i => {
this.apRequestChart.inbox();
this.federationChart.inbox(authUser.user.host);
// Update instance stats
process.nextTick(async () => {
const i = await (this.meta.enableStatsForFederatedInstances
? this.federatedInstanceService.fetchOrRegister(authUser.user.host)
: this.federatedInstanceService.fetch(authUser.user.host));
if (i == null) return;
this.updateInstanceQueue.enqueue(i.id, {
latestRequestReceivedAt: new Date(),
shouldUnsuspend: i.suspensionState === 'autoSuspendedForNotResponding',
});
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
this.apRequestChart.inbox();
this.federationChart.inbox(i.host);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.requestReceived(i.host);
}
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
});
// アクティビティを処理

View file

@ -119,6 +119,7 @@ export class ApiServerService {
'g-recaptcha-response'?: string;
'turnstile-response'?: string;
'm-captcha-response'?: string;
'testcaptcha-response'?: string;
}
}>('/signup', (request, reply) => this.signupApiService.signup(request, reply));
@ -132,6 +133,7 @@ export class ApiServerService {
'g-recaptcha-response'?: string;
'turnstile-response'?: string;
'm-captcha-response'?: string;
'testcaptcha-response'?: string;
};
}>('/signin-flow', (request, reply) => this.signinApiService.signin(request, reply));

View file

@ -71,6 +71,7 @@ export class SigninApiService {
'g-recaptcha-response'?: string;
'turnstile-response'?: string;
'm-captcha-response'?: string;
'testcaptcha-response'?: string;
};
}>,
reply: FastifyReply,
@ -194,6 +195,12 @@ export class SigninApiService {
throw new FastifyReplyError(400, err);
});
}
if (this.meta.enableTestcaptcha) {
await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => {
throw new FastifyReplyError(400, err);
});
}
}
if (same) {

View file

@ -67,6 +67,7 @@ export class SignupApiService {
'g-recaptcha-response'?: string;
'turnstile-response'?: string;
'm-captcha-response'?: string;
'testcaptcha-response'?: string;
}
}>,
reply: FastifyReply,
@ -99,6 +100,12 @@ export class SignupApiService {
throw new FastifyReplyError(400, err);
});
}
if (this.meta.enableTestcaptcha) {
await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => {
throw new FastifyReplyError(400, err);
});
}
}
const username = body['username'];

View file

@ -6,7 +6,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import type { DriveFilesRepository } from '@/models/_.js';
import type { DriveFilesRepository, MiEmoji } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../../error.js';
@ -78,25 +78,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
}
let emojiId;
if (ps.id) {
emojiId = ps.id;
const emoji = await this.customEmojiService.getEmojiById(ps.id);
if (!emoji) throw new ApiError(meta.errors.noSuchEmoji);
if (ps.name && (ps.name !== emoji.name)) {
const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name);
if (isDuplicate) throw new ApiError(meta.errors.sameNameEmojiExists);
}
} else {
if (!ps.name) throw new Error('Invalid Params unexpectedly passed. This is a BUG. Please report it to the development team.');
const emoji = await this.customEmojiService.getEmojiByName(ps.name);
if (!emoji) throw new ApiError(meta.errors.noSuchEmoji);
emojiId = emoji.id;
}
// JSON schemeのanyOfの型変換がうまくいっていないらしい
const required = { id: ps.id, name: ps.name } as
| { id: MiEmoji['id']; name?: string }
| { id?: MiEmoji['id']; name: string };
await this.customEmojiService.update(emojiId, {
const error = await this.customEmojiService.update({
...required,
driveFile,
name: ps.name,
category: ps.category,
aliases: ps.aliases,
license: ps.license,
@ -104,6 +93,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
localOnly: ps.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction,
}, me);
switch (error) {
case null: return;
case 'NO_SUCH_EMOJI': throw new ApiError(meta.errors.noSuchEmoji);
case 'SAME_NAME_EMOJI_EXISTS': throw new ApiError(meta.errors.sameNameEmojiExists);
}
// 網羅性チェック
const mustBeNever: never = error;
});
}
}

View file

@ -69,6 +69,10 @@ export const meta = {
type: 'string',
optional: false, nullable: true,
},
enableTestcaptcha: {
type: 'boolean',
optional: false, nullable: false,
},
swPublickey: {
type: 'string',
optional: false, nullable: true,
@ -173,6 +177,13 @@ export const meta = {
type: 'string',
},
},
prohibitedWordsForNameOfUser: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
},
},
bannedEmailDomains: {
type: 'array',
optional: true, nullable: false,
@ -337,6 +348,10 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
enableStatsForFederatedInstances: {
type: 'boolean',
optional: false, nullable: false,
},
enableServerMachineStats: {
type: 'boolean',
optional: false, nullable: false,
@ -555,6 +570,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
recaptchaSiteKey: instance.recaptchaSiteKey,
enableTurnstile: instance.enableTurnstile,
turnstileSiteKey: instance.turnstileSiteKey,
enableTestcaptcha: instance.enableTestcaptcha,
swPublickey: instance.swPublicKey,
themeColor: instance.themeColor,
mascotImageUrl: instance.mascotImageUrl,
@ -581,6 +597,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
mediaSilencedHosts: instance.mediaSilencedHosts,
sensitiveWords: instance.sensitiveWords,
prohibitedWords: instance.prohibitedWords,
prohibitedWordsForNameOfUser: instance.prohibitedWordsForNameOfUser,
preservedUsernames: instance.preservedUsernames,
hcaptchaSecretKey: instance.hcaptchaSecretKey,
mcaptchaSecretKey: instance.mcaptchaSecretKey,
@ -622,6 +639,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
truemailAuthKey: instance.truemailAuthKey,
enableChartsForRemoteUser: instance.enableChartsForRemoteUser,
enableChartsForFederatedInstances: instance.enableChartsForFederatedInstances,
enableStatsForFederatedInstances: instance.enableStatsForFederatedInstances,
enableServerMachineStats: instance.enableServerMachineStats,
enableIdenticonGeneration: instance.enableIdenticonGeneration,
bannedEmailDomains: instance.bannedEmailDomains,

View file

@ -71,13 +71,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
break;
}
case 'moderator': {
const moderatorIds = await this.roleService.getModeratorIds(false);
const moderatorIds = await this.roleService.getModeratorIds({ includeAdmins: false });
if (moderatorIds.length === 0) return [];
query.where('user.id IN (:...moderatorIds)', { moderatorIds: moderatorIds });
break;
}
case 'adminOrModerator': {
const adminOrModeratorIds = await this.roleService.getModeratorIds();
const adminOrModeratorIds = await this.roleService.getModeratorIds({ includeAdmins: true });
if (adminOrModeratorIds.length === 0) return [];
query.where('user.id IN (:...adminOrModeratorIds)', { adminOrModeratorIds: adminOrModeratorIds });
break;

View file

@ -46,6 +46,11 @@ export const paramDef = {
type: 'string',
},
},
prohibitedWordsForNameOfUser: {
type: 'array', nullable: true, items: {
type: 'string',
},
},
themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' },
mascotImageUrl: { type: 'string', nullable: true },
bannerUrl: { type: 'string', nullable: true },
@ -78,6 +83,7 @@ export const paramDef = {
enableTurnstile: { type: 'boolean' },
turnstileSiteKey: { type: 'string', nullable: true },
turnstileSecretKey: { type: 'string', nullable: true },
enableTestcaptcha: { type: 'boolean' },
sensitiveMediaDetection: { type: 'string', enum: ['none', 'all', 'local', 'remote'] },
sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] },
setSensitiveFlagAutomatically: { type: 'boolean' },
@ -130,6 +136,7 @@ export const paramDef = {
truemailAuthKey: { type: 'string', nullable: true },
enableChartsForRemoteUser: { type: 'boolean' },
enableChartsForFederatedInstances: { type: 'boolean' },
enableStatsForFederatedInstances: { type: 'boolean' },
enableServerMachineStats: { type: 'boolean' },
enableIdenticonGeneration: { type: 'boolean' },
serverRules: { type: 'array', items: { type: 'string' } },
@ -213,6 +220,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (Array.isArray(ps.prohibitedWords)) {
set.prohibitedWords = ps.prohibitedWords.filter(Boolean);
}
if (Array.isArray(ps.prohibitedWordsForNameOfUser)) {
set.prohibitedWordsForNameOfUser = ps.prohibitedWordsForNameOfUser.filter(Boolean);
}
if (Array.isArray(ps.silencedHosts)) {
let lastValue = '';
set.silencedHosts = ps.silencedHosts.sort().filter((h) => {
@ -357,6 +367,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.turnstileSecretKey = ps.turnstileSecretKey;
}
if (ps.enableTestcaptcha !== undefined) {
set.enableTestcaptcha = ps.enableTestcaptcha;
}
if (ps.sensitiveMediaDetection !== undefined) {
set.sensitiveMediaDetection = ps.sensitiveMediaDetection;
}
@ -565,6 +579,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.enableChartsForFederatedInstances = ps.enableChartsForFederatedInstances;
}
if (ps.enableStatsForFederatedInstances !== undefined) {
set.enableStatsForFederatedInstances = ps.enableStatsForFederatedInstances;
}
if (ps.enableServerMachineStats !== undefined) {
set.enableServerMachineStats = ps.enableServerMachineStats;
}

View file

@ -11,7 +11,7 @@ import { JSDOM } from 'jsdom';
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
import { extractHashtags } from '@/misc/extract-hashtags.js';
import * as Acct from '@/misc/acct.js';
import type { UsersRepository, DriveFilesRepository, UserProfilesRepository, PagesRepository } from '@/models/_.js';
import type { UsersRepository, DriveFilesRepository, MiMeta, UserProfilesRepository, PagesRepository } from '@/models/_.js';
import type { MiLocalUser, MiUser } from '@/models/User.js';
import { birthdaySchema, descriptionSchema, followedMessageSchema, locationSchema, nameSchema } from '@/models/User.js';
import type { MiUserProfile } from '@/models/UserProfile.js';
@ -22,6 +22,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
import { AccountUpdateService } from '@/core/AccountUpdateService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { HashtagService } from '@/core/HashtagService.js';
import { DI } from '@/di-symbols.js';
import { RolePolicies, RoleService } from '@/core/RoleService.js';
@ -114,6 +115,13 @@ export const meta = {
code: 'RESTRICTED_BY_ROLE',
id: '8feff0ba-5ab5-585b-31f4-4df816663fad',
},
nameContainsProhibitedWords: {
message: 'Your new name contains prohibited words.',
code: 'YOUR_NAME_CONTAINS_PROHIBITED_WORDS',
id: '0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191',
httpStatusCode: 422,
},
},
res: {
@ -223,6 +231,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.config)
private config: Config,
@Inject(DI.meta)
private instanceMeta: MiMeta,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@ -247,6 +258,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private cacheService: CacheService,
private httpRequestService: HttpRequestService,
private avatarDecorationService: AvatarDecorationService,
private utilityService: UtilityService,
) {
super(meta, paramDef, async (ps, _user, token) => {
const user = await this.usersRepository.findOneByOrFail({ id: _user.id }) as MiLocalUser;
@ -449,6 +461,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const newFields = profileUpdates.fields === undefined ? profile.fields : profileUpdates.fields;
if (newName != null) {
let hasProhibitedWords = false;
if (!await this.roleService.isModerator(user)) {
hasProhibitedWords = this.utilityService.isKeyWordIncluded(newName, this.instanceMeta.prohibitedWordsForNameOfUser);
}
if (hasProhibitedWords) {
throw new ApiError(meta.errors.nameContainsProhibitedWords);
}
const tokens = mfm.parseSimple(newName);
emojis = emojis.concat(extractCustomEmojisFromMfm(tokens));
}

View file

@ -98,7 +98,7 @@
const theme = localStorage.getItem('theme');
if (theme) {
for (const [k, v] of Object.entries(JSON.parse(theme))) {
document.documentElement.style.setProperty(`--${k}`, v.toString());
document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString());
// HTMLの theme-color 適用
if (k === 'htmlThemeColor') {

View file

@ -5,8 +5,8 @@
*/
html {
background-color: var(--bg);
color: var(--fg);
background-color: var(--MI_THEME-bg);
color: var(--MI_THEME-fg);
}
#splash {
@ -17,7 +17,7 @@ html {
width: 100vw;
height: 100vh;
cursor: wait;
background-color: var(--bg);
background-color: var(--MI_THEME-bg);
opacity: 1;
transition: opacity 0.5s ease;
}
@ -45,7 +45,7 @@ html {
width: 28px;
height: 28px;
transform: translateY(70px);
color: var(--accent);
color: var(--MI_THEME-accent);
}
#splashSpinner > .spinner {

View file

@ -5,8 +5,8 @@
*/
html {
background-color: var(--bg);
color: var(--fg);
background-color: var(--MI_THEME-bg);
color: var(--MI_THEME-fg);
}
html.embed {
@ -24,7 +24,7 @@ html.embed {
width: 100vw;
height: 100vh;
cursor: wait;
background-color: var(--bg);
background-color: var(--MI_THEME-bg);
opacity: 1;
transition: opacity 0.5s ease;
}
@ -33,7 +33,7 @@ html.embed #splash {
box-sizing: border-box;
min-height: 300px;
border-radius: var(--radius, 12px);
border: 1px solid var(--divider, #e8e8e8);
border: 1px solid var(--MI_THEME-divider, #e8e8e8);
}
html.embed.norounded #splash {
@ -67,7 +67,7 @@ html.embed.noborder #splash {
width: 28px;
height: 28px;
transform: translateY(70px);
color: var(--accent);
color: var(--MI_THEME-accent);
}
#splashSpinner > .spinner {

View file

@ -0,0 +1,70 @@
# based on https://github.com/misskey-dev/misskey-hub/blob/7071f63a1c80ee35c71f0fd8a6d8722c118c7574/src/docs/admin/nginx.md
# For WebSocket
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
proxy_cache_path /tmp/nginx_cache levels=1:2 keys_zone=cache1:16m max_size=1g inactive=720m use_temp_path=off;
server {
listen 80;
listen [::]:80;
server_name ${HOST};
# For SSL domain validation
root /var/www/html;
location /.well-known/acme-challenge/ { allow all; }
location /.well-known/pki-validation/ { allow all; }
location / { return 301 https://$server_name$request_uri; }
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name ${HOST};
ssl_session_timeout 1d;
ssl_session_cache shared:ssl_session_cache:10m;
ssl_session_tickets off;
ssl_trusted_certificate /etc/nginx/certificates/rootCA.crt;
ssl_certificate /etc/nginx/certificates/$server_name.crt;
ssl_certificate_key /etc/nginx/certificates/$server_name.key;
# SSL protocol settings
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_stapling on;
ssl_stapling_verify on;
# Change to your upload limit
client_max_body_size 80m;
# Proxy to Node
location / {
proxy_pass http://misskey.${HOST}:3000;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_redirect off;
# If it's behind another reverse proxy or CDN, remove the following.
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
# For WebSocket
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Cache settings
proxy_cache cache1;
proxy_cache_lock on;
proxy_cache_use_stale updating;
proxy_force_ranges on;
add_header X-Cache $upstream_cache_status;
}
}

View file

@ -0,0 +1,25 @@
url: https://${HOST}/
port: 3000
db:
host: db.${HOST}
port: 5432
db: misskey
user: postgres
pass: postgres
dbReplications: false
redis:
host: redis.test
port: 6379
id: 'aidx'
proxyBypassHosts:
- api.deepl.com
- api-free.deepl.com
- www.recaptcha.net
- hcaptcha.com
- challenges.cloudflare.com
proxyRemoteFiles: true
signToActivityPubGet: true
allowedPrivateNetworks: [
'127.0.0.1/32',
'172.20.0.0/16'
]

View file

@ -0,0 +1,5 @@
NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/rootCA.crt
POSTGRES_DB=misskey
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
MK_VERBOSE=true

View file

@ -0,0 +1,6 @@
certificates
volumes
.env
docker.env
*.test.conf
*.test.default.yml

View file

@ -0,0 +1,24 @@
## test-federation
Test federation between two Misskey servers: `a.test` and `b.test`.
Before testing, you need to build the entire project, and change working directory to here:
```sh
pnpm build
cd packages/backend/test-federation
```
First, you need to start servers by executing following commands:
```sh
bash ./setup.sh
docker compose up --scale tester=0
```
Then you can run all tests by a following command:
```sh
docker compose run --no-deps --rm tester
```
For testing a specific file, run a following command:
```sh
docker compose run --no-deps --rm tester -- pnpm -F backend test:fed packages/backend/test-federation/test/user.test.ts
```

View file

@ -0,0 +1,64 @@
services:
a.test:
extends:
file: ./compose.tpl.yml
service: nginx
depends_on:
misskey.a.test:
condition: service_healthy
networks:
- internal_network_a
volumes:
- type: bind
source: ./.config/a.test.conf
target: /etc/nginx/conf.d/a.test.conf
read_only: true
- type: bind
source: ./certificates/a.test.crt
target: /etc/nginx/certificates/a.test.crt
read_only: true
- type: bind
source: ./certificates/a.test.key
target: /etc/nginx/certificates/a.test.key
read_only: true
misskey.a.test:
extends:
file: ./compose.tpl.yml
service: misskey
depends_on:
db.a.test:
condition: service_healthy
redis.test:
condition: service_healthy
setup:
condition: service_completed_successfully
networks:
- internal_network_a
volumes:
- type: bind
source: ./.config/a.test.default.yml
target: /misskey/.config/default.yml
read_only: true
db.a.test:
extends:
file: ./compose.tpl.yml
service: db
networks:
- internal_network_a
volumes:
- type: bind
source: ./volumes/db.a
target: /var/lib/postgresql/data
bind:
create_host_path: true
networks:
internal_network_a:
internal: true
driver: bridge
ipam:
config:
- subnet: 172.21.0.0/16
ip_range: 172.21.0.0/24

View file

@ -0,0 +1,64 @@
services:
b.test:
extends:
file: ./compose.tpl.yml
service: nginx
depends_on:
misskey.b.test:
condition: service_healthy
networks:
- internal_network_b
volumes:
- type: bind
source: ./.config/b.test.conf
target: /etc/nginx/conf.d/b.test.conf
read_only: true
- type: bind
source: ./certificates/b.test.crt
target: /etc/nginx/certificates/b.test.crt
read_only: true
- type: bind
source: ./certificates/b.test.key
target: /etc/nginx/certificates/b.test.key
read_only: true
misskey.b.test:
extends:
file: ./compose.tpl.yml
service: misskey
depends_on:
db.b.test:
condition: service_healthy
redis.test:
condition: service_healthy
setup:
condition: service_completed_successfully
networks:
- internal_network_b
volumes:
- type: bind
source: ./.config/b.test.default.yml
target: /misskey/.config/default.yml
read_only: true
db.b.test:
extends:
file: ./compose.tpl.yml
service: db
networks:
- internal_network_b
volumes:
- type: bind
source: ./volumes/db.b
target: /var/lib/postgresql/data
bind:
create_host_path: true
networks:
internal_network_b:
internal: true
driver: bridge
ipam:
config:
- subnet: 172.22.0.0/16
ip_range: 172.22.0.0/24

View file

@ -0,0 +1,117 @@
services:
setup:
volumes:
- type: volume
source: node_modules
target: /misskey/node_modules
- type: volume
source: node_modules_backend
target: /misskey/packages/backend/node_modules
- type: volume
source: node_modules_misskey-js
target: /misskey/packages/misskey-js/node_modules
- type: volume
source: node_modules_misskey-reversi
target: /misskey/packages/misskey-reversi/node_modules
tester:
networks:
external_network:
internal_network:
ipv4_address: 172.20.1.1
volumes:
- type: volume
source: node_modules_dev
target: /misskey/node_modules
- type: volume
source: node_modules_backend_dev
target: /misskey/packages/backend/node_modules
- type: volume
source: node_modules_misskey-js_dev
target: /misskey/packages/misskey-js/node_modules
daemon:
networks:
- external_network
- internal_network_a
- internal_network_b
volumes:
- type: volume
source: node_modules_dev
target: /misskey/node_modules
- type: volume
source: node_modules_backend_dev
target: /misskey/packages/backend/node_modules
redis.test:
networks:
- internal_network_a
- internal_network_b
a.test:
networks:
- internal_network
misskey.a.test:
networks:
- external_network
- internal_network
volumes:
- type: volume
source: node_modules
target: /misskey/node_modules
- type: volume
source: node_modules_backend
target: /misskey/packages/backend/node_modules
- type: volume
source: node_modules_misskey-js
target: /misskey/packages/misskey-js/node_modules
- type: volume
source: node_modules_misskey-reversi
target: /misskey/packages/misskey-reversi/node_modules
b.test:
networks:
- internal_network
misskey.b.test:
networks:
- external_network
- internal_network
volumes:
- type: volume
source: node_modules
target: /misskey/node_modules
- type: volume
source: node_modules_backend
target: /misskey/packages/backend/node_modules
- type: volume
source: node_modules_misskey-js
target: /misskey/packages/misskey-js/node_modules
- type: volume
source: node_modules_misskey-reversi
target: /misskey/packages/misskey-reversi/node_modules
networks:
external_network:
driver: bridge
ipam:
config:
- subnet: 172.23.0.0/16
ip_range: 172.23.0.0/24
internal_network:
internal: true
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
ip_range: 172.20.0.0/24
volumes:
node_modules:
node_modules_dev:
node_modules_backend:
node_modules_backend_dev:
node_modules_misskey-js:
node_modules_misskey-js_dev:
node_modules_misskey-reversi:

View file

@ -0,0 +1,101 @@
services:
nginx:
image: nginx:1.27
volumes:
- type: bind
source: ./certificates/rootCA.crt
target: /etc/nginx/certificates/rootCA.crt
read_only: true
healthcheck:
test: service nginx status
interval: 5s
retries: 20
misskey:
image: node:20
env_file:
- ./.config/docker.env
environment:
- NODE_ENV=production
volumes:
- type: bind
source: ../../../built
target: /misskey/built
read_only: true
- type: bind
source: ../assets
target: /misskey/packages/backend/assets
read_only: true
- type: bind
source: ../built
target: /misskey/packages/backend/built
read_only: true
- type: bind
source: ../migration
target: /misskey/packages/backend/migration
read_only: true
- type: bind
source: ../ormconfig.js
target: /misskey/packages/backend/ormconfig.js
read_only: true
- type: bind
source: ../package.json
target: /misskey/packages/backend/package.json
read_only: true
- type: bind
source: ../../misskey-js/built
target: /misskey/packages/misskey-js/built
read_only: true
- type: bind
source: ../../misskey-js/package.json
target: /misskey/packages/misskey-js/package.json
read_only: true
- type: bind
source: ../../misskey-reversi/built
target: /misskey/packages/misskey-reversi/built
read_only: true
- type: bind
source: ../../misskey-reversi/package.json
target: /misskey/packages/misskey-reversi/package.json
read_only: true
- type: bind
source: ../../../healthcheck.sh
target: /misskey/healthcheck.sh
read_only: true
- type: bind
source: ../../../package.json
target: /misskey/package.json
read_only: true
- type: bind
source: ../../../pnpm-lock.yaml
target: /misskey/pnpm-lock.yaml
read_only: true
- type: bind
source: ../../../pnpm-workspace.yaml
target: /misskey/pnpm-workspace.yaml
read_only: true
- type: bind
source: ./certificates/rootCA.crt
target: /usr/local/share/ca-certificates/rootCA.crt
read_only: true
working_dir: /misskey
command: >
bash -c "
corepack enable && corepack prepare
pnpm -F backend migrate
pnpm -F backend start
"
healthcheck:
test: bash /misskey/healthcheck.sh
interval: 5s
retries: 20
db:
image: postgres:15-alpine
env_file:
- ./.config/docker.env
volumes:
healthcheck:
test: pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB
interval: 5s
retries: 20

View file

@ -0,0 +1,133 @@
include:
- ./compose.a.yml
- ./compose.b.yml
services:
setup:
extends:
file: ./compose.tpl.yml
service: misskey
command: >
bash -c "
corepack enable && corepack prepare
pnpm -F backend i
pnpm -F misskey-js i
pnpm -F misskey-reversi i
"
tester:
image: node:20
depends_on:
a.test:
condition: service_healthy
b.test:
condition: service_healthy
environment:
- NODE_ENV=development
- NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/rootCA.crt
volumes:
- type: bind
source: ../package.json
target: /misskey/packages/backend/package.json
read_only: true
- type: bind
source: ../test/resources
target: /misskey/packages/backend/test/resources
read_only: true
- type: bind
source: ./test
target: /misskey/packages/backend/test-federation/test
read_only: true
- type: bind
source: ../jest.config.cjs
target: /misskey/packages/backend/jest.config.cjs
read_only: true
- type: bind
source: ../jest.config.fed.cjs
target: /misskey/packages/backend/jest.config.fed.cjs
read_only: true
- type: bind
source: ../../misskey-js/built
target: /misskey/packages/misskey-js/built
read_only: true
- type: bind
source: ../../misskey-js/package.json
target: /misskey/packages/misskey-js/package.json
read_only: true
- type: bind
source: ../../../package.json
target: /misskey/package.json
read_only: true
- type: bind
source: ../../../pnpm-lock.yaml
target: /misskey/pnpm-lock.yaml
read_only: true
- type: bind
source: ../../../pnpm-workspace.yaml
target: /misskey/pnpm-workspace.yaml
read_only: true
- type: bind
source: ./certificates/rootCA.crt
target: /usr/local/share/ca-certificates/rootCA.crt
read_only: true
working_dir: /misskey
entrypoint: >
bash -c '
corepack enable && corepack prepare
pnpm -F misskey-js i --frozen-lockfile
pnpm -F backend i --frozen-lockfile
exec "$0" "$@"
'
command: pnpm -F backend test:fed
daemon:
image: node:20
depends_on:
redis.test:
condition: service_healthy
volumes:
- type: bind
source: ../package.json
target: /misskey/packages/backend/package.json
read_only: true
- type: bind
source: ./daemon.ts
target: /misskey/packages/backend/test-federation/daemon.ts
read_only: true
- type: bind
source: ./tsconfig.json
target: /misskey/packages/backend/test-federation/tsconfig.json
read_only: true
- type: bind
source: ../../../package.json
target: /misskey/package.json
read_only: true
- type: bind
source: ../../../pnpm-lock.yaml
target: /misskey/pnpm-lock.yaml
read_only: true
- type: bind
source: ../../../pnpm-workspace.yaml
target: /misskey/pnpm-workspace.yaml
read_only: true
working_dir: /misskey
command: >
bash -c "
corepack enable && corepack prepare
pnpm -F backend i --frozen-lockfile
pnpm exec tsc -p ./packages/backend/test-federation
node ./packages/backend/test-federation/built/daemon.js
"
redis.test:
image: redis:7-alpine
volumes:
- type: bind
source: ./volumes/redis
target: /data
bind:
create_host_path: true
healthcheck:
test: redis-cli ping
interval: 5s
retries: 20

View file

@ -0,0 +1,38 @@
import IPCIDR from 'ip-cidr';
import { Redis } from 'ioredis';
const TESTER_IP_ADDRESS = '172.20.1.1';
/**
* This should be same as {@link file://./../src/misc/get-ip-hash.ts}.
*/
function getIpHash(ip: string) {
const prefix = IPCIDR.createAddress(ip).mask(64);
return `ip-${BigInt('0b' + prefix).toString(36)}`;
}
/**
* This prevents hitting rate limit when login.
*/
export async function purgeLimit(host: string, client: Redis) {
const ipHash = getIpHash(TESTER_IP_ADDRESS);
const key = `${host}:limit:${ipHash}:signin`;
const res = await client.zrange(key, 0, -1);
if (res.length !== 0) {
console.log(`${key} - ${JSON.stringify(res)}`);
await client.del(key);
}
}
console.log('Daemon started running');
{
const redisClient = new Redis({
host: 'redis.test',
});
setInterval(() => {
purgeLimit('a.test', redisClient);
purgeLimit('b.test', redisClient);
}, 200);
}

View file

@ -0,0 +1,21 @@
import globals from 'globals';
import tsParser from '@typescript-eslint/parser';
import sharedConfig from '../../shared/eslint.config.js';
export default [
...sharedConfig,
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
globals: {
...globals.node,
},
parserOptions: {
parser: tsParser,
project: ['./tsconfig.json'],
sourceType: 'module',
tsconfigRootDir: import.meta.dirname,
},
},
},
];

View file

@ -0,0 +1,35 @@
#!/bin/bash
mkdir certificates
# rootCA
openssl genrsa -des3 \
-passout pass:rootCA \
-out certificates/rootCA.key 4096
openssl req -x509 -new -nodes -batch \
-key certificates/rootCA.key \
-sha256 \
-days 1024 \
-passin pass:rootCA \
-out certificates/rootCA.crt
# domain
function generate {
openssl req -new -newkey rsa:2048 -sha256 -nodes \
-keyout certificates/$1.key \
-subj "/CN=$1/emailAddress=admin@$1/C=JP/ST=/L=/O=Misskey Tester/OU=Some Unit" \
-out certificates/$1.csr
openssl x509 -req -sha256 \
-in certificates/$1.csr \
-CA certificates/rootCA.crt \
-CAkey certificates/rootCA.key \
-CAcreateserial \
-passin pass:rootCA \
-out certificates/$1.crt \
-days 500
if [ ! -f .config/docker.env ]; then cp .config/example.docker.env .config/docker.env; fi
if [ ! -f .config/$1.conf ]; then sed "s/\${HOST}/$1/g" .config/example.conf > .config/$1.conf; fi
if [ ! -f .config/$1.default.yml ]; then sed "s/\${HOST}/$1/g" .config/example.default.yml > .config/$1.default.yml; fi
}
generate a.test
generate b.test

View file

@ -0,0 +1,52 @@
import { rejects, strictEqual } from 'node:assert';
import * as Misskey from 'misskey-js';
import { createAccount, createModerator, resolveRemoteUser, sleep, type LoginUser } from './utils.js';
describe('Abuse report', () => {
describe('Forwarding report', () => {
let alice: LoginUser, bob: LoginUser, aModerator: LoginUser, bModerator: LoginUser;
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
beforeAll(async () => {
[alice, bob] = await Promise.all([
createAccount('a.test'),
createAccount('b.test'),
]);
[aModerator, bModerator] = await Promise.all([
createModerator('a.test'),
createModerator('b.test'),
]);
[bobInA, aliceInB] = await Promise.all([
resolveRemoteUser('b.test', bob.id, alice),
resolveRemoteUser('a.test', alice.id, bob),
]);
});
test('Alice reports Bob, moderator in A forwards it, and B moderator receives it', async () => {
const comment = crypto.randomUUID();
await alice.client.request('users/report-abuse', { userId: bobInA.id, comment });
const reports = await aModerator.client.request('admin/abuse-user-reports', {});
const report = reports.filter(report => report.comment === comment)[0];
await aModerator.client.request('admin/forward-abuse-user-report', { reportId: report.id });
await sleep();
const reportsInB = await bModerator.client.request('admin/abuse-user-reports', {});
const reportInB = reportsInB.filter(report => report.comment.includes(comment))[0];
// NOTE: reporter is not Alice, and is not moderator in A
strictEqual(reportInB.reporter.url, 'https://a.test/@instance.actor');
strictEqual(reportInB.targetUserId, bob.id);
// NOTE: cannot forward multiple times
await rejects(
async () => await aModerator.client.request('admin/forward-abuse-user-report', { reportId: report.id }),
(err: any) => {
strictEqual(err.code, 'INTERNAL_ERROR');
strictEqual(err.info.e.message, 'The report has already been forwarded.');
return true;
},
);
});
});
});

View file

@ -0,0 +1,224 @@
import { deepStrictEqual, rejects, strictEqual } from 'node:assert';
import * as Misskey from 'misskey-js';
import { assertNotificationReceived, createAccount, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js';
describe('Block', () => {
describe('Check follow', () => {
let alice: LoginUser, bob: LoginUser;
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
beforeAll(async () => {
[alice, bob] = await Promise.all([
createAccount('a.test'),
createAccount('b.test'),
]);
[bobInA, aliceInB] = await Promise.all([
resolveRemoteUser('b.test', bob.id, alice),
resolveRemoteUser('a.test', alice.id, bob),
]);
});
test('Cannot follow if blocked', async () => {
await alice.client.request('blocking/create', { userId: bobInA.id });
await sleep();
await rejects(
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
(err: any) => {
strictEqual(err.code, 'BLOCKED');
return true;
},
);
const following = await bob.client.request('users/following', { userId: bob.id });
strictEqual(following.length, 0);
const followers = await alice.client.request('users/followers', { userId: alice.id });
strictEqual(followers.length, 0);
});
// FIXME: this is invalid case
test('Cannot follow even if unblocked', async () => {
// unblock here
await alice.client.request('blocking/delete', { userId: bobInA.id });
await sleep();
// TODO: why still being blocked?
await rejects(
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
(err: any) => {
strictEqual(err.code, 'BLOCKED');
return true;
},
);
});
test.skip('Can follow if unblocked', async () => {
await alice.client.request('blocking/delete', { userId: bobInA.id });
await sleep();
await bob.client.request('following/create', { userId: aliceInB.id });
await sleep();
const following = await bob.client.request('users/following', { userId: bob.id });
strictEqual(following.length, 1);
const followers = await alice.client.request('users/followers', { userId: alice.id });
strictEqual(followers.length, 1);
});
test.skip('Remove follower when block them', async () => {
test('before block', async () => {
const following = await bob.client.request('users/following', { userId: bob.id });
strictEqual(following.length, 1);
const followers = await alice.client.request('users/followers', { userId: alice.id });
strictEqual(followers.length, 1);
});
await alice.client.request('blocking/create', { userId: bobInA.id });
await sleep();
test('after block', async () => {
const following = await bob.client.request('users/following', { userId: bob.id });
strictEqual(following.length, 0);
const followers = await alice.client.request('users/followers', { userId: alice.id });
strictEqual(followers.length, 0);
});
});
});
describe('Check reply', () => {
let alice: LoginUser, bob: LoginUser;
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
beforeAll(async () => {
[alice, bob] = await Promise.all([
createAccount('a.test'),
createAccount('b.test'),
]);
[bobInA, aliceInB] = await Promise.all([
resolveRemoteUser('b.test', bob.id, alice),
resolveRemoteUser('a.test', alice.id, bob),
]);
});
test('Cannot reply if blocked', async () => {
await alice.client.request('blocking/create', { userId: bobInA.id });
await sleep();
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
await rejects(
async () => await bob.client.request('notes/create', { text: 'b', replyId: resolvedNote.id }),
(err: any) => {
strictEqual(err.code, 'YOU_HAVE_BEEN_BLOCKED');
return true;
},
);
});
test('Can reply if unblocked', async () => {
await alice.client.request('blocking/delete', { userId: bobInA.id });
await sleep();
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
const reply = (await bob.client.request('notes/create', { text: 'b', replyId: resolvedNote.id })).createdNote;
await resolveRemoteNote('b.test', reply.id, alice);
});
});
describe('Check reaction', () => {
let alice: LoginUser, bob: LoginUser;
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
beforeAll(async () => {
[alice, bob] = await Promise.all([
createAccount('a.test'),
createAccount('b.test'),
]);
[bobInA, aliceInB] = await Promise.all([
resolveRemoteUser('b.test', bob.id, alice),
resolveRemoteUser('a.test', alice.id, bob),
]);
});
test('Cannot reaction if blocked', async () => {
await alice.client.request('blocking/create', { userId: bobInA.id });
await sleep();
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
await rejects(
async () => await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: '😅' }),
(err: any) => {
strictEqual(err.code, 'YOU_HAVE_BEEN_BLOCKED');
return true;
},
);
});
// FIXME: this is invalid case
test('Cannot reaction even if unblocked', async () => {
// unblock here
await alice.client.request('blocking/delete', { userId: bobInA.id });
await sleep();
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
// TODO: why still being blocked?
await rejects(
async () => await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: '😅' }),
(err: any) => {
strictEqual(err.code, 'YOU_HAVE_BEEN_BLOCKED');
return true;
},
);
});
test.skip('Can reaction if unblocked', async () => {
await alice.client.request('blocking/delete', { userId: bobInA.id });
await sleep();
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: '😅' });
const _note = await alice.client.request('notes/show', { noteId: note.id });
deepStrictEqual(_note.reactions, { '😅': 1 });
});
});
describe('Check mention', () => {
let alice: LoginUser, bob: LoginUser;
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
beforeAll(async () => {
[alice, bob] = await Promise.all([
createAccount('a.test'),
createAccount('b.test'),
]);
[bobInA, aliceInB] = await Promise.all([
resolveRemoteUser('b.test', bob.id, alice),
resolveRemoteUser('a.test', alice.id, bob),
]);
});
/** NOTE: You should mute the target to stop receiving notifications */
test('Can mention and notified even if blocked', async () => {
await alice.client.request('blocking/create', { userId: bobInA.id });
await sleep();
const text = `@${alice.username}@a.test plz unblock me!`;
await assertNotificationReceived(
'a.test', alice,
async () => await bob.client.request('notes/create', { text }),
notification => notification.type === 'mention' && notification.userId === bobInA.id && notification.note.text === text,
true,
);
});
});
});

View file

@ -0,0 +1,175 @@
import assert, { strictEqual } from 'node:assert';
import * as Misskey from 'misskey-js';
import { createAccount, deepStrictEqualWithExcludedFields, fetchAdmin, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, uploadFile } from './utils.js';
const bAdmin = await fetchAdmin('b.test');
describe('Drive', () => {
describe('Upload image in a.test and resolve from b.test', () => {
let uploader: LoginUser;
beforeAll(async () => {
uploader = await createAccount('a.test');
});
let image: Misskey.entities.DriveFile, imageInB: Misskey.entities.DriveFile;
describe('Upload', () => {
beforeAll(async () => {
image = await uploadFile('a.test', uploader);
const noteWithImage = (await uploader.client.request('notes/create', { fileIds: [image.id] })).createdNote;
const noteInB = await resolveRemoteNote('a.test', noteWithImage.id, bAdmin);
assert(noteInB.files != null);
strictEqual(noteInB.files.length, 1);
imageInB = noteInB.files[0];
});
test('Check consistency of DriveFile', () => {
// console.log(`a.test: ${JSON.stringify(image, null, '\t')}`);
// console.log(`b.test: ${JSON.stringify(imageInB, null, '\t')}`);
deepStrictEqualWithExcludedFields(image, imageInB, [
'id',
'createdAt',
'size',
'url',
'thumbnailUrl',
'userId',
]);
});
});
let updatedImage: Misskey.entities.DriveFile, updatedImageInB: Misskey.entities.DriveFile;
describe('Update', () => {
beforeAll(async () => {
updatedImage = await uploader.client.request('drive/files/update', {
fileId: image.id,
name: 'updated_192.jpg',
isSensitive: true,
});
updatedImageInB = await bAdmin.client.request('drive/files/show', {
fileId: imageInB.id,
});
});
test('Check consistency', () => {
// console.log(`a.test: ${JSON.stringify(updatedImage, null, '\t')}`);
// console.log(`b.test: ${JSON.stringify(updatedImageInB, null, '\t')}`);
// FIXME: not updated with `drive/files/update`
strictEqual(updatedImage.isSensitive, true);
strictEqual(updatedImage.name, 'updated_192.jpg');
strictEqual(updatedImageInB.isSensitive, false);
strictEqual(updatedImageInB.name, '192.jpg');
});
});
let reupdatedImageInB: Misskey.entities.DriveFile;
describe('Re-update with attaching to Note', () => {
beforeAll(async () => {
const noteWithUpdatedImage = (await uploader.client.request('notes/create', { fileIds: [updatedImage.id] })).createdNote;
const noteWithUpdatedImageInB = await resolveRemoteNote('a.test', noteWithUpdatedImage.id, bAdmin);
assert(noteWithUpdatedImageInB.files != null);
strictEqual(noteWithUpdatedImageInB.files.length, 1);
reupdatedImageInB = noteWithUpdatedImageInB.files[0];
});
test('Check consistency', () => {
// console.log(`b.test: ${JSON.stringify(reupdatedImageInB, null, '\t')}`);
// `isSensitive` is updated
strictEqual(reupdatedImageInB.isSensitive, true);
// FIXME: but `name` is not updated
strictEqual(reupdatedImageInB.name, '192.jpg');
});
});
});
describe('Sensitive flag', () => {
describe('isSensitive is federated in delivering to followers', () => {
let alice: LoginUser, bob: LoginUser;
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
beforeAll(async () => {
[alice, bob] = await Promise.all([
createAccount('a.test'),
createAccount('b.test'),
]);
[bobInA, aliceInB] = await Promise.all([
resolveRemoteUser('b.test', bob.id, alice),
resolveRemoteUser('a.test', alice.id, bob),
]);
await bob.client.request('following/create', { userId: aliceInB.id });
await sleep();
});
test('Alice uploads sensitive image and it is shown as sensitive from Bob', async () => {
const file = await uploadFile('a.test', alice);
await alice.client.request('drive/files/update', { fileId: file.id, isSensitive: true });
await alice.client.request('notes/create', { text: 'sensitive', fileIds: [file.id] });
await sleep();
const notes = await bob.client.request('notes/timeline', {});
strictEqual(notes.length, 1);
const noteInB = notes[0];
assert(noteInB.files != null);
strictEqual(noteInB.files.length, 1);
strictEqual(noteInB.files[0].isSensitive, true);
});
});
describe('isSensitive is federated in resolving', () => {
let alice: LoginUser, bob: LoginUser;
beforeAll(async () => {
[alice, bob] = await Promise.all([
createAccount('a.test'),
createAccount('b.test'),
]);
});
test('Alice uploads sensitive image and it is shown as sensitive from Bob', async () => {
const file = await uploadFile('a.test', alice);
await alice.client.request('drive/files/update', { fileId: file.id, isSensitive: true });
const note = (await alice.client.request('notes/create', { text: 'sensitive', fileIds: [file.id] })).createdNote;
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
assert(noteInB.files != null);
strictEqual(noteInB.files.length, 1);
strictEqual(noteInB.files[0].isSensitive, true);
});
});
/** @see https://github.com/misskey-dev/misskey/issues/12208 */
describe('isSensitive is federated in replying', () => {
let alice: LoginUser, bob: LoginUser;
beforeAll(async () => {
[alice, bob] = await Promise.all([
createAccount('a.test'),
createAccount('b.test'),
]);
});
test('Alice uploads sensitive image and it is shown as sensitive from Bob', async () => {
const bobNote = (await bob.client.request('notes/create', { text: 'I\'m Bob' })).createdNote;
const file = await uploadFile('a.test', alice);
await alice.client.request('drive/files/update', { fileId: file.id, isSensitive: true });
const bobNoteInA = await resolveRemoteNote('b.test', bobNote.id, alice);
const note = (await alice.client.request('notes/create', { text: 'sensitive', fileIds: [file.id], replyId: bobNoteInA.id })).createdNote;
await sleep();
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
assert(noteInB.files != null);
strictEqual(noteInB.files.length, 1);
strictEqual(noteInB.files[0].isSensitive, true);
});
});
});
});

View file

@ -0,0 +1,97 @@
import assert, { deepStrictEqual, strictEqual } from 'assert';
import * as Misskey from 'misskey-js';
import { addCustomEmoji, createAccount, type LoginUser, resolveRemoteUser, sleep } from './utils.js';
describe('Emoji', () => {
let alice: LoginUser, bob: LoginUser;
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
beforeAll(async () => {
[alice, bob] = await Promise.all([
createAccount('a.test'),
createAccount('b.test'),
]);
[bobInA, aliceInB] = await Promise.all([
resolveRemoteUser('b.test', bob.id, alice),
resolveRemoteUser('a.test', alice.id, bob),
]);
await bob.client.request('following/create', { userId: aliceInB.id });
await sleep();
});
test('Custom emoji are delivered with Note delivery', async () => {
const emoji = await addCustomEmoji('a.test');
await alice.client.request('notes/create', { text: `I love :${emoji.name}:` });
await sleep();
const notes = await bob.client.request('notes/timeline', {});
const noteInB = notes[0];
strictEqual(noteInB.text, `I love \u200b:${emoji.name}:\u200b`);
assert(noteInB.emojis != null);
assert(emoji.name in noteInB.emojis);
strictEqual(noteInB.emojis[emoji.name], emoji.url);
});
test('Custom emoji are delivered with Reaction delivery', async () => {
const emoji = await addCustomEmoji('a.test');
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
await sleep();
await alice.client.request('notes/reactions/create', { noteId: note.id, reaction: `:${emoji.name}:` });
await sleep();
const noteInB = (await bob.client.request('notes/timeline', {}))[0];
deepStrictEqual(noteInB.reactions[`:${emoji.name}@a.test:`], 1);
deepStrictEqual(noteInB.reactionEmojis[`${emoji.name}@a.test`], emoji.url);
});
test('Custom emoji are delivered with Profile delivery', async () => {
const emoji = await addCustomEmoji('a.test');
const renewedAlice = await alice.client.request('i/update', { name: `:${emoji.name}:` });
await sleep();
const renewedaliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
strictEqual(renewedaliceInB.name, renewedAlice.name);
assert(emoji.name in renewedaliceInB.emojis);
strictEqual(renewedaliceInB.emojis[emoji.name], emoji.url);
});
test('Local-only custom emoji aren\'t delivered with Note delivery', async () => {
const emoji = await addCustomEmoji('a.test', { localOnly: true });
await alice.client.request('notes/create', { text: `I love :${emoji.name}:` });
await sleep();
const notes = await bob.client.request('notes/timeline', {});
const noteInB = notes[0];
strictEqual(noteInB.text, `I love \u200b:${emoji.name}:\u200b`);
// deepStrictEqual(noteInB.emojis, {}); // TODO: this fails (why?)
deepStrictEqual({ ...noteInB.emojis }, {});
});
test('Local-only custom emoji aren\'t delivered with Reaction delivery', async () => {
const emoji = await addCustomEmoji('a.test', { localOnly: true });
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
await sleep();
await alice.client.request('notes/reactions/create', { noteId: note.id, reaction: `:${emoji.name}:` });
await sleep();
const noteInB = (await bob.client.request('notes/timeline', {}))[0];
deepStrictEqual({ ...noteInB.reactions }, { '❤': 1 });
deepStrictEqual({ ...noteInB.reactionEmojis }, {});
});
test('Local-only custom emoji aren\'t delivered with Profile delivery', async () => {
const emoji = await addCustomEmoji('a.test', { localOnly: true });
const renewedAlice = await alice.client.request('i/update', { name: `:${emoji.name}:` });
await sleep();
const renewedaliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
strictEqual(renewedaliceInB.name, renewedAlice.name);
deepStrictEqual({ ...renewedaliceInB.emojis }, {});
});
});

View file

@ -0,0 +1,52 @@
import assert, { strictEqual } from 'node:assert';
import { createAccount, type LoginUser, sleep } from './utils.js';
describe('Move', () => {
test('Minimum move', async () => {
const [alice, bob] = await Promise.all([
createAccount('a.test'),
createAccount('b.test'),
]);
await bob.client.request('i/update', { alsoKnownAs: [`@${alice.username}@a.test`] });
await alice.client.request('i/move', { moveToAccount: `@${bob.username}@b.test` });
});
/** @see https://github.com/misskey-dev/misskey/issues/11320 */
describe('Following relation is transferred after move', () => {
let alice: LoginUser, bob: LoginUser, carol: LoginUser;
beforeAll(async () => {
[alice, bob] = await Promise.all([
createAccount('a.test'),
createAccount('b.test'),
]);
carol = await createAccount('a.test');
// Follow @carol@a.test ==> @alice@a.test
await carol.client.request('following/create', { userId: alice.id });
// Move @alice@a.test ==> @bob@b.test
await bob.client.request('i/update', { alsoKnownAs: [`@${alice.username}@a.test`] });
await alice.client.request('i/move', { moveToAccount: `@${bob.username}@b.test` });
await sleep();
});
test('Check from follower', async () => {
const following = await carol.client.request('users/following', { userId: carol.id });
strictEqual(following.length, 2);
const followees = following.map(({ followee }) => followee);
assert(followees.every(followee => followee != null));
assert(followees.some(({ id, url }) => id === alice.id && url === null));
assert(followees.some(({ url }) => url === `https://b.test/@${bob.username}`));
});
test('Check from followee', async () => {
const followers = await bob.client.request('users/followers', { userId: bob.id });
strictEqual(followers.length, 1);
const follower = followers[0].follower;
assert(follower != null);
strictEqual(follower.url, `https://a.test/@${carol.username}`);
});
});
});

View file

@ -0,0 +1,317 @@
import assert, { rejects, strictEqual } from 'node:assert';
import * as Misskey from 'misskey-js';
import { addCustomEmoji, createAccount, createModerator, deepStrictEqualWithExcludedFields, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, uploadFile } from './utils.js';
describe('Note', () => {
let alice: LoginUser, bob: LoginUser;
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
beforeAll(async () => {
[alice, bob] = await Promise.all([
createAccount('a.test'),
createAccount('b.test'),
]);
[bobInA, aliceInB] = await Promise.all([
resolveRemoteUser('b.test', bob.id, alice),
resolveRemoteUser('a.test', alice.id, bob),
]);
});
describe('Note content', () => {
test('Consistency of Public Note', async () => {
const image = await uploadFile('a.test', alice);
const note = (await alice.client.request('notes/create', {
text: 'I am Alice!',
fileIds: [image.id],
poll: {
choices: ['neko', 'inu'],
multiple: false,
expiredAfter: 60 * 60 * 1000,
},
})).createdNote;
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
deepStrictEqualWithExcludedFields(note, resolvedNote, [
'id',
'emojis',
/** Consistency of files is checked at {@link file://./drive.test.ts}, so let's skip. */
'fileIds',
'files',
/** @see https://github.com/misskey-dev/misskey/issues/12409 */
'reactionAcceptance',
'userId',
'user',
'uri',
]);
strictEqual(aliceInB.id, resolvedNote.userId);
});
test('Consistency of reply', async () => {
const _replyedNote = (await alice.client.request('notes/create', {
text: 'a',
})).createdNote;
const note = (await alice.client.request('notes/create', {
text: 'b',
replyId: _replyedNote.id,
})).createdNote;
// NOTE: the repliedCount is incremented, so fetch again
const replyedNote = await alice.client.request('notes/show', { noteId: _replyedNote.id });
strictEqual(replyedNote.repliesCount, 1);
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
deepStrictEqualWithExcludedFields(note, resolvedNote, [
'id',
'emojis',
'reactionAcceptance',
'replyId',
'reply',
'userId',
'user',
'uri',
]);
assert(resolvedNote.replyId != null);
assert(resolvedNote.reply != null);
deepStrictEqualWithExcludedFields(replyedNote, resolvedNote.reply, [
'id',
// TODO: why clippedCount loses consistency?
'clippedCount',
'emojis',
'userId',
'user',
'uri',
// flaky because this is parallelly incremented, so let's check it below
'repliesCount',
]);
strictEqual(aliceInB.id, resolvedNote.userId);
await sleep();
const resolvedReplyedNote = await bob.client.request('notes/show', { noteId: resolvedNote.replyId });
strictEqual(resolvedReplyedNote.repliesCount, 1);
});
test('Consistency of Renote', async () => {
// NOTE: the renoteCount is not incremented, so no need to fetch again
const renotedNote = (await alice.client.request('notes/create', {
text: 'a',
})).createdNote;
const note = (await alice.client.request('notes/create', {
text: 'b',
renoteId: renotedNote.id,
})).createdNote;
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
deepStrictEqualWithExcludedFields(note, resolvedNote, [
'id',
'emojis',
'reactionAcceptance',
'renoteId',
'renote',
'userId',
'user',
'uri',
]);
assert(resolvedNote.renoteId != null);
assert(resolvedNote.renote != null);
deepStrictEqualWithExcludedFields(renotedNote, resolvedNote.renote, [
'id',
'emojis',
'userId',
'user',
'uri',
]);
strictEqual(aliceInB.id, resolvedNote.userId);
});
});
describe('Other props', () => {
test('localOnly', async () => {
const note = (await alice.client.request('notes/create', { text: 'a', localOnly: true })).createdNote;
rejects(
async () => await bob.client.request('ap/show', { uri: `https://a.test/notes/${note.id}` }),
(err: any) => {
/**
* FIXME: this error is not handled
* @see https://github.com/misskey-dev/misskey/issues/12736
*/
strictEqual(err.code, 'INTERNAL_ERROR');
return true;
},
);
});
});
describe('Deletion', () => {
describe('Check Delete consistency', () => {
let carol: LoginUser;
beforeAll(async () => {
carol = await createAccount('a.test');
await carol.client.request('following/create', { userId: bobInA.id });
await sleep();
});
test('Delete is derivered to followers', async () => {
const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote;
const noteInA = await resolveRemoteNote('b.test', note.id, carol);
await bob.client.request('notes/delete', { noteId: note.id });
await sleep();
await rejects(
async () => await carol.client.request('notes/show', { noteId: noteInA.id }),
(err: any) => {
strictEqual(err.code, 'NO_SUCH_NOTE');
return true;
},
);
});
});
describe('Deletion of remote user\'s note for moderation', () => {
let note: Misskey.entities.Note;
test('Alice post is deleted in B', async () => {
note = (await alice.client.request('notes/create', { text: 'Hello' })).createdNote;
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
const bMod = await createModerator('b.test');
await bMod.client.request('notes/delete', { noteId: noteInB.id });
await rejects(
async () => await bob.client.request('notes/show', { noteId: noteInB.id }),
(err: any) => {
strictEqual(err.code, 'NO_SUCH_NOTE');
return true;
},
);
});
/**
* FIXME: implement soft deletion as well as user?
* @see https://github.com/misskey-dev/misskey/issues/11437
*/
test.failing('Not found even if resolve again', async () => {
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
await rejects(
async () => await bob.client.request('notes/show', { noteId: noteInB.id }),
(err: any) => {
strictEqual(err.code, 'NO_SUCH_NOTE');
return true;
},
);
});
});
});
describe('Reaction', () => {
describe('Consistency', () => {
test('Unicode reaction', async () => {
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
const reaction = '😅';
await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction });
await sleep();
const reactions = await alice.client.request('notes/reactions', { noteId: note.id });
strictEqual(reactions.length, 1);
strictEqual(reactions[0].type, reaction);
strictEqual(reactions[0].user.id, bobInA.id);
});
test('Custom emoji reaction', async () => {
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
const emoji = await addCustomEmoji('b.test');
await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: `:${emoji.name}:` });
await sleep();
const reactions = await alice.client.request('notes/reactions', { noteId: note.id });
strictEqual(reactions.length, 1);
strictEqual(reactions[0].type, `:${emoji.name}@b.test:`);
strictEqual(reactions[0].user.id, bobInA.id);
});
});
describe('Acceptance', () => {
test('Even if likeOnly, remote users can react with custom emoji, but it is converted to like', async () => {
const note = (await alice.client.request('notes/create', { text: 'a', reactionAcceptance: 'likeOnly' })).createdNote;
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
const emoji = await addCustomEmoji('b.test');
await bob.client.request('notes/reactions/create', { noteId: noteInB.id, reaction: `:${emoji.name}:` });
await sleep();
const reactions = await alice.client.request('notes/reactions', { noteId: note.id });
strictEqual(reactions.length, 1);
strictEqual(reactions[0].type, '❤');
});
/**
* TODO: this may be unexpected behavior?
* @see https://github.com/misskey-dev/misskey/issues/12409
*/
test('Even if nonSensitiveOnly, remote users can react with sensitive emoji, and it is not converted', async () => {
const note = (await alice.client.request('notes/create', { text: 'a', reactionAcceptance: 'nonSensitiveOnly' })).createdNote;
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
const emoji = await addCustomEmoji('b.test', { isSensitive: true });
await bob.client.request('notes/reactions/create', { noteId: noteInB.id, reaction: `:${emoji.name}:` });
await sleep();
const reactions = await alice.client.request('notes/reactions', { noteId: note.id });
strictEqual(reactions.length, 1);
strictEqual(reactions[0].type, `:${emoji.name}@b.test:`);
});
});
});
describe('Poll', () => {
describe('Any remote user\'s vote is delivered to the author', () => {
let carol: LoginUser;
beforeAll(async () => {
carol = await createAccount('a.test');
});
test('Bob creates poll and receives a vote from Carol', async () => {
const note = (await bob.client.request('notes/create', { poll: { choices: ['inu', 'neko'] } })).createdNote;
const noteInA = await resolveRemoteNote('b.test', note.id, carol);
await carol.client.request('notes/polls/vote', { noteId: noteInA.id, choice: 0 });
await sleep();
const noteAfterVote = await bob.client.request('notes/show', { noteId: note.id });
assert(noteAfterVote.poll != null);
strictEqual(noteAfterVote.poll.choices[0].votes, 1);
strictEqual(noteAfterVote.poll.choices[1].votes, 0);
});
});
describe('Local user\'s vote is delivered to the author\'s remote followers', () => {
let bobRemoteFollower: LoginUser, localVoter: LoginUser;
beforeAll(async () => {
[
bobRemoteFollower,
localVoter,
] = await Promise.all([
createAccount('a.test'),
createAccount('b.test'),
]);
await bobRemoteFollower.client.request('following/create', { userId: bobInA.id });
await sleep();
});
test('A vote in Bob\'s server is delivered to Bob\'s remote followers', async () => {
const note = (await bob.client.request('notes/create', { poll: { choices: ['inu', 'neko'] } })).createdNote;
// NOTE: resolve before voting
const noteInA = await resolveRemoteNote('b.test', note.id, bobRemoteFollower);
await localVoter.client.request('notes/polls/vote', { noteId: note.id, choice: 0 });
await sleep();
const noteAfterVote = await bobRemoteFollower.client.request('notes/show', { noteId: noteInA.id });
assert(noteAfterVote.poll != null);
strictEqual(noteAfterVote.poll.choices[0].votes, 1);
strictEqual(noteAfterVote.poll.choices[1].votes, 0);
});
});
});
});

View file

@ -0,0 +1,107 @@
import * as Misskey from 'misskey-js';
import { assertNotificationReceived, createAccount, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js';
describe('Notification', () => {
let alice: LoginUser, bob: LoginUser;
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
beforeAll(async () => {
[alice, bob] = await Promise.all([
createAccount('a.test'),
createAccount('b.test'),
]);
[bobInA, aliceInB] = await Promise.all([
resolveRemoteUser('b.test', bob.id, alice),
resolveRemoteUser('a.test', alice.id, bob),
]);
});
describe('Follow', () => {
test('Get notification when follow', async () => {
await assertNotificationReceived(
'b.test', bob,
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
notification => notification.type === 'followRequestAccepted' && notification.userId === aliceInB.id,
true,
);
await bob.client.request('following/delete', { userId: aliceInB.id });
await sleep();
});
test('Get notification when get followed', async () => {
await assertNotificationReceived(
'a.test', alice,
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
notification => notification.type === 'follow' && notification.userId === bobInA.id,
true,
);
});
afterAll(async () => await bob.client.request('following/delete', { userId: aliceInB.id }));
});
describe('Note', () => {
test('Get notification when get a reaction', async () => {
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
const reaction = '😅';
await assertNotificationReceived(
'a.test', alice,
async () => await bob.client.request('notes/reactions/create', { noteId: noteInB.id, reaction }),
notification =>
notification.type === 'reaction' && notification.note.id === note.id && notification.userId === bobInA.id && notification.reaction === reaction,
true,
);
});
test('Get notification when replied', async () => {
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
const text = crypto.randomUUID();
await assertNotificationReceived(
'a.test', alice,
async () => await bob.client.request('notes/create', { text, replyId: noteInB.id }),
notification =>
notification.type === 'reply' && notification.note.reply!.id === note.id && notification.userId === bobInA.id && notification.note.text === text,
true,
);
});
test('Get notification when renoted', async () => {
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
await assertNotificationReceived(
'a.test', alice,
async () => await bob.client.request('notes/create', { renoteId: noteInB.id }),
notification =>
notification.type === 'renote' && notification.note.renote!.id === note.id && notification.userId === bobInA.id,
true,
);
});
test('Get notification when quoted', async () => {
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
const text = crypto.randomUUID();
await assertNotificationReceived(
'a.test', alice,
async () => await bob.client.request('notes/create', { text, renoteId: noteInB.id }),
notification =>
notification.type === 'quote' && notification.note.renote!.id === note.id && notification.userId === bobInA.id && notification.note.text === text,
true,
);
});
test('Get notification when mentioned', async () => {
const text = `@${alice.username}@a.test`;
await assertNotificationReceived(
'a.test', alice,
async () => await bob.client.request('notes/create', { text }),
notification => notification.type === 'mention' && notification.userId === bobInA.id && notification.note.text === text,
true,
);
});
});
});

View file

@ -0,0 +1,328 @@
import { strictEqual } from 'assert';
import * as Misskey from 'misskey-js';
import { createAccount, fetchAdmin, isNoteUpdatedEventFired, isFired, type LoginUser, type Request, resolveRemoteUser, sleep, createRole } from './utils.js';
const bAdmin = await fetchAdmin('b.test');
describe('Timeline', () => {
let alice: LoginUser, bob: LoginUser;
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
beforeAll(async () => {
[alice, bob] = await Promise.all([
createAccount('a.test'),
createAccount('b.test'),
]);
[bobInA, aliceInB] = await Promise.all([
resolveRemoteUser('b.test', bob.id, alice),
resolveRemoteUser('a.test', alice.id, bob),
]);
await bob.client.request('following/create', { userId: aliceInB.id });
await sleep();
});
type TimelineChannel = keyof Misskey.Channels & (`${string}Timeline` | 'antenna' | 'userList' | 'hashtag');
type TimelineEndpoint = keyof Misskey.Endpoints & (`${string}timeline` | 'antennas/notes' | 'roles/notes' | 'notes/search-by-tag');
const timelineMap = new Map<TimelineChannel, TimelineEndpoint>([
['antenna', 'antennas/notes'],
['globalTimeline', 'notes/global-timeline'],
['homeTimeline', 'notes/timeline'],
['hybridTimeline', 'notes/hybrid-timeline'],
['localTimeline', 'notes/local-timeline'],
['roleTimeline', 'roles/notes'],
['hashtag', 'notes/search-by-tag'],
['userList', 'notes/user-list-timeline'],
]);
async function postAndCheckReception<C extends TimelineChannel>(
timelineChannel: C,
expect: boolean,
noteParams: Misskey.entities.NotesCreateRequest = {},
channelParams: Misskey.Channels[C]['params'] = {},
) {
let note: Misskey.entities.Note | undefined;
const text = noteParams.text ?? crypto.randomUUID();
const streamingFired = await isFired(
'b.test', bob, timelineChannel,
async () => {
note = (await alice.client.request('notes/create', { text, ...noteParams })).createdNote;
},
'note', msg => msg.text === text,
channelParams,
);
strictEqual(streamingFired, expect);
const endpoint = timelineMap.get(timelineChannel)!;
const params: Misskey.Endpoints[typeof endpoint]['req'] =
endpoint === 'antennas/notes' ? { antennaId: (channelParams as Misskey.Channels['antenna']['params']).antennaId } :
endpoint === 'notes/user-list-timeline' ? { listId: (channelParams as Misskey.Channels['userList']['params']).listId } :
endpoint === 'notes/search-by-tag' ? { query: (channelParams as Misskey.Channels['hashtag']['params']).q } :
endpoint === 'roles/notes' ? { roleId: (channelParams as Misskey.Channels['roleTimeline']['params']).roleId } :
{};
await sleep();
const notes = await (bob.client.request as Request)(endpoint, params);
const noteInB = notes.filter(({ uri }) => uri === `https://a.test/notes/${note!.id}`).pop();
const endpointFired = noteInB != null;
strictEqual(endpointFired, expect);
// Let's check Delete reception
if (expect) {
const streamingFired = await isNoteUpdatedEventFired(
'b.test', bob, noteInB!.id,
async () => await alice.client.request('notes/delete', { noteId: note!.id }),
msg => msg.type === 'deleted' && msg.id === noteInB!.id,
);
strictEqual(streamingFired, true);
await sleep();
const notes = await (bob.client.request as Request)(endpoint, params);
const endpointFired = notes.every(({ uri }) => uri !== `https://a.test/notes/${note!.id}`);
strictEqual(endpointFired, true);
}
}
describe('homeTimeline', () => {
// NOTE: narrowing scope intentionally to prevent mistakes by copy-and-paste
const homeTimeline = 'homeTimeline';
describe('Check reception of remote followee\'s Note', () => {
test('Receive remote followee\'s Note', async () => {
await postAndCheckReception(homeTimeline, true);
});
test('Receive remote followee\'s home-only Note', async () => {
await postAndCheckReception(homeTimeline, true, { visibility: 'home' });
});
test('Receive remote followee\'s followers-only Note', async () => {
await postAndCheckReception(homeTimeline, true, { visibility: 'followers' });
});
test('Receive remote followee\'s visible specified-only Note', async () => {
await postAndCheckReception(homeTimeline, true, { visibility: 'specified', visibleUserIds: [bobInA.id] });
});
test('Don\'t receive remote followee\'s localOnly Note', async () => {
await postAndCheckReception(homeTimeline, false, { localOnly: true });
});
test('Don\'t receive remote followee\'s invisible specified-only Note', async () => {
await postAndCheckReception(homeTimeline, false, { visibility: 'specified' });
});
/**
* FIXME: can receive this
* @see https://github.com/misskey-dev/misskey/issues/14083
*/
test.failing('Don\'t receive remote followee\'s invisible and mentioned specified-only Note', async () => {
await postAndCheckReception(homeTimeline, false, { text: `@${bob.username}@b.test Hello`, visibility: 'specified' });
});
/**
* FIXME: cannot receive this
* @see https://github.com/misskey-dev/misskey/issues/14084
*/
test.failing('Receive remote followee\'s visible specified-only reply to invisible specified-only Note', async () => {
const note = (await alice.client.request('notes/create', { text: 'a', visibility: 'specified' })).createdNote;
await postAndCheckReception(homeTimeline, true, { replyId: note.id, visibility: 'specified', visibleUserIds: [bobInA.id] });
});
});
});
describe('localTimeline', () => {
const localTimeline = 'localTimeline';
describe('Check reception of remote followee\'s Note', () => {
test('Don\'t receive remote followee\'s Note', async () => {
await postAndCheckReception(localTimeline, false);
});
});
});
describe('hybridTimeline', () => {
const hybridTimeline = 'hybridTimeline';
describe('Check reception of remote followee\'s Note', () => {
test('Receive remote followee\'s Note', async () => {
await postAndCheckReception(hybridTimeline, true);
});
test('Receive remote followee\'s home-only Note', async () => {
await postAndCheckReception(hybridTimeline, true, { visibility: 'home' });
});
test('Receive remote followee\'s followers-only Note', async () => {
await postAndCheckReception(hybridTimeline, true, { visibility: 'followers' });
});
test('Receive remote followee\'s visible specified-only Note', async () => {
await postAndCheckReception(hybridTimeline, true, { visibility: 'specified', visibleUserIds: [bobInA.id] });
});
});
});
describe('globalTimeline', () => {
const globalTimeline = 'globalTimeline';
describe('Check reception of remote followee\'s Note', () => {
test('Receive remote followee\'s Note', async () => {
await postAndCheckReception(globalTimeline, true);
});
test('Don\'t receive remote followee\'s home-only Note', async () => {
await postAndCheckReception(globalTimeline, false, { visibility: 'home' });
});
test('Don\'t receive remote followee\'s followers-only Note', async () => {
await postAndCheckReception(globalTimeline, false, { visibility: 'followers' });
});
test('Don\'t receive remote followee\'s visible specified-only Note', async () => {
await postAndCheckReception(globalTimeline, false, { visibility: 'specified', visibleUserIds: [bobInA.id] });
});
});
});
describe('userList', () => {
const userList = 'userList';
let list: Misskey.entities.UserList;
beforeAll(async () => {
list = await bob.client.request('users/lists/create', { name: 'Bob\'s List' });
await bob.client.request('users/lists/push', { listId: list.id, userId: aliceInB.id });
await sleep();
});
describe('Check reception of remote followee\'s Note', () => {
test('Receive remote followee\'s Note', async () => {
await postAndCheckReception(userList, true, {}, { listId: list.id });
});
test('Receive remote followee\'s home-only Note', async () => {
await postAndCheckReception(userList, true, { visibility: 'home' }, { listId: list.id });
});
test('Receive remote followee\'s followers-only Note', async () => {
await postAndCheckReception(userList, true, { visibility: 'followers' }, { listId: list.id });
});
test('Receive remote followee\'s visible specified-only Note', async () => {
await postAndCheckReception(userList, true, { visibility: 'specified', visibleUserIds: [bobInA.id] }, { listId: list.id });
});
});
});
describe('hashtag', () => {
const hashtag = 'hashtag';
describe('Check reception of remote followee\'s Note', () => {
test('Receive remote followee\'s Note', async () => {
const tag = crypto.randomUUID();
await postAndCheckReception(hashtag, true, { text: `#${tag}` }, { q: [[tag]] });
});
test('Receive remote followee\'s home-only Note', async () => {
const tag = crypto.randomUUID();
await postAndCheckReception(hashtag, true, { text: `#${tag}`, visibility: 'home' }, { q: [[tag]] });
});
test('Receive remote followee\'s followers-only Note', async () => {
const tag = crypto.randomUUID();
await postAndCheckReception(hashtag, true, { text: `#${tag}`, visibility: 'followers' }, { q: [[tag]] });
});
test('Receive remote followee\'s visible specified-only Note', async () => {
const tag = crypto.randomUUID();
await postAndCheckReception(hashtag, true, { text: `#${tag}`, visibility: 'specified', visibleUserIds: [bobInA.id] }, { q: [[tag]] });
});
});
});
describe('roleTimeline', () => {
const roleTimeline = 'roleTimeline';
let role: Misskey.entities.Role;
beforeAll(async () => {
role = await createRole('b.test', {
name: 'Remote Users',
description: 'Remote users are assigned to this role.',
condFormula: {
/** TODO: @see https://github.com/misskey-dev/misskey/issues/14169 */
type: 'isRemote' as never,
},
});
await sleep();
});
describe('Check reception of remote followee\'s Note', () => {
test('Receive remote followee\'s Note', async () => {
await postAndCheckReception(roleTimeline, true, {}, { roleId: role.id });
});
test('Don\'t receive remote followee\'s home-only Note', async () => {
await postAndCheckReception(roleTimeline, false, { visibility: 'home' }, { roleId: role.id });
});
test('Don\'t receive remote followee\'s followers-only Note', async () => {
await postAndCheckReception(roleTimeline, false, { visibility: 'followers' }, { roleId: role.id });
});
test('Don\'t receive remote followee\'s visible specified-only Note', async () => {
await postAndCheckReception(roleTimeline, false, { visibility: 'specified', visibleUserIds: [bobInA.id] }, { roleId: role.id });
});
});
afterAll(async () => {
await bAdmin.client.request('admin/roles/delete', { roleId: role.id });
});
});
// TODO: Cannot test
describe.skip('antenna', () => {
const antenna = 'antenna';
let bobAntenna: Misskey.entities.Antenna;
beforeAll(async () => {
bobAntenna = await bob.client.request('antennas/create', {
name: 'Bob\'s Egosurfing Antenna',
src: 'all',
keywords: [['Bob']],
excludeKeywords: [],
users: [],
caseSensitive: false,
localOnly: false,
withReplies: true,
withFile: true,
});
await sleep();
});
describe('Check reception of remote followee\'s Note', () => {
test('Receive remote followee\'s Note', async () => {
await postAndCheckReception(antenna, true, { text: 'I love Bob (1)' }, { antennaId: bobAntenna.id });
});
test('Don\'t receive remote followee\'s home-only Note', async () => {
await postAndCheckReception(antenna, false, { text: 'I love Bob (2)', visibility: 'home' }, { antennaId: bobAntenna.id });
});
test('Don\'t receive remote followee\'s followers-only Note', async () => {
await postAndCheckReception(antenna, false, { text: 'I love Bob (3)', visibility: 'followers' }, { antennaId: bobAntenna.id });
});
test('Don\'t receive remote followee\'s visible specified-only Note', async () => {
await postAndCheckReception(antenna, false, { text: 'I love Bob (4)', visibility: 'specified', visibleUserIds: [bobInA.id] }, { antennaId: bobAntenna.id });
});
});
afterAll(async () => {
await bob.client.request('antennas/delete', { antennaId: bobAntenna.id });
});
});
});

View file

@ -0,0 +1,560 @@
import assert, { rejects, strictEqual } from 'node:assert';
import * as Misskey from 'misskey-js';
import { createAccount, deepStrictEqualWithExcludedFields, fetchAdmin, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js';
const [aAdmin, bAdmin] = await Promise.all([
fetchAdmin('a.test'),
fetchAdmin('b.test'),
]);
describe('User', () => {
describe('Profile', () => {
describe('Consistency of profile', () => {
let alice: LoginUser;
let aliceWatcher: LoginUser;
let aliceWatcherInB: LoginUser;
beforeAll(async () => {
alice = await createAccount('a.test');
[
aliceWatcher,
aliceWatcherInB,
] = await Promise.all([
createAccount('a.test'),
createAccount('b.test'),
]);
});
test('Check consistency', async () => {
const aliceInA = await aliceWatcher.client.request('users/show', { userId: alice.id });
const resolved = await resolveRemoteUser('a.test', aliceInA.id, aliceWatcherInB);
const aliceInB = await aliceWatcherInB.client.request('users/show', { userId: resolved.id });
// console.log(`a.test: ${JSON.stringify(aliceInA, null, '\t')}`);
// console.log(`b.test: ${JSON.stringify(aliceInB, null, '\t')}`);
deepStrictEqualWithExcludedFields(aliceInA, aliceInB, [
'id',
'host',
'avatarUrl',
'instance',
'badgeRoles',
'url',
'uri',
'createdAt',
'lastFetchedAt',
'publicReactions',
]);
});
});
describe('ffVisibility is federated', () => {
let alice: LoginUser, bob: LoginUser;
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
beforeAll(async () => {
[alice, bob] = await Promise.all([
createAccount('a.test'),
createAccount('b.test'),
]);
[bobInA, aliceInB] = await Promise.all([
resolveRemoteUser('b.test', bob.id, alice),
resolveRemoteUser('a.test', alice.id, bob),
]);
// NOTE: follow each other
await Promise.all([
alice.client.request('following/create', { userId: bobInA.id }),
bob.client.request('following/create', { userId: aliceInB.id }),
]);
await sleep();
});
test('Visibility set public by default', async () => {
for (const user of await Promise.all([
alice.client.request('users/show', { userId: bobInA.id }),
bob.client.request('users/show', { userId: aliceInB.id }),
])) {
strictEqual(user.followersVisibility, 'public');
strictEqual(user.followingVisibility, 'public');
}
});
/** FIXME: not working */
test.skip('Setting private for followersVisibility is federated', async () => {
await Promise.all([
alice.client.request('i/update', { followersVisibility: 'private' }),
bob.client.request('i/update', { followersVisibility: 'private' }),
]);
await sleep();
for (const user of await Promise.all([
alice.client.request('users/show', { userId: bobInA.id }),
bob.client.request('users/show', { userId: aliceInB.id }),
])) {
strictEqual(user.followersVisibility, 'private');
strictEqual(user.followingVisibility, 'public');
}
});
test.skip('Setting private for followingVisibility is federated', async () => {
await Promise.all([
alice.client.request('i/update', { followingVisibility: 'private' }),
bob.client.request('i/update', { followingVisibility: 'private' }),
]);
await sleep();
for (const user of await Promise.all([
alice.client.request('users/show', { userId: bobInA.id }),
bob.client.request('users/show', { userId: aliceInB.id }),
])) {
strictEqual(user.followersVisibility, 'private');
strictEqual(user.followingVisibility, 'private');
}
});
});
describe('isCat is federated', () => {
let alice: LoginUser, bob: LoginUser;
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
beforeAll(async () => {
[alice, bob] = await Promise.all([
createAccount('a.test'),
createAccount('b.test'),
]);
[bobInA, aliceInB] = await Promise.all([
resolveRemoteUser('b.test', bob.id, alice),
resolveRemoteUser('a.test', alice.id, bob),
]);
});
test('Not isCat for default', () => {
strictEqual(aliceInB.isCat, false);
});
test('Becoming a cat is sent to their followers', async () => {
await bob.client.request('following/create', { userId: aliceInB.id });
await sleep();
await alice.client.request('i/update', { isCat: true });
await sleep();
const res = await bob.client.request('users/show', { userId: aliceInB.id });
strictEqual(res.isCat, true);
});
});
describe('Pinning Notes', () => {
let alice: LoginUser, bob: LoginUser;
let aliceInB: Misskey.entities.UserDetailedNotMe;
beforeAll(async () => {
[alice, bob] = await Promise.all([
createAccount('a.test'),
createAccount('b.test'),
]);
aliceInB = await resolveRemoteUser('a.test', alice.id, bob);
await bob.client.request('following/create', { userId: aliceInB.id });
});
test('Pinning localOnly Note is not delivered', async () => {
const note = (await alice.client.request('notes/create', { text: 'a', localOnly: true })).createdNote;
await alice.client.request('i/pin', { noteId: note.id });
await sleep();
const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
strictEqual(_aliceInB.pinnedNoteIds.length, 0);
});
test('Pinning followers-only Note is not delivered', async () => {
const note = (await alice.client.request('notes/create', { text: 'a', visibility: 'followers' })).createdNote;
await alice.client.request('i/pin', { noteId: note.id });
await sleep();
const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
strictEqual(_aliceInB.pinnedNoteIds.length, 0);
});
let pinnedNote: Misskey.entities.Note;
test('Pinning normal Note is delivered', async () => {
pinnedNote = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
await alice.client.request('i/pin', { noteId: pinnedNote.id });
await sleep();
const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
strictEqual(_aliceInB.pinnedNoteIds.length, 1);
const pinnedNoteInB = await resolveRemoteNote('a.test', pinnedNote.id, bob);
strictEqual(_aliceInB.pinnedNotes[0].id, pinnedNoteInB.id);
});
test('Unpinning normal Note is delivered', async () => {
await alice.client.request('i/unpin', { noteId: pinnedNote.id });
await sleep();
const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
strictEqual(_aliceInB.pinnedNoteIds.length, 0);
});
});
});
describe('Follow / Unfollow', () => {
let alice: LoginUser, bob: LoginUser;
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
beforeAll(async () => {
[alice, bob] = await Promise.all([
createAccount('a.test'),
createAccount('b.test'),
]);
[bobInA, aliceInB] = await Promise.all([
resolveRemoteUser('b.test', bob.id, alice),
resolveRemoteUser('a.test', alice.id, bob),
]);
});
describe('Follow a.test ==> b.test', () => {
beforeAll(async () => {
await alice.client.request('following/create', { userId: bobInA.id });
await sleep();
});
test('Check consistency with `users/following` and `users/followers` endpoints', async () => {
await Promise.all([
strictEqual(
(await alice.client.request('users/following', { userId: alice.id }))
.some(v => v.followeeId === bobInA.id),
true,
),
strictEqual(
(await bob.client.request('users/followers', { userId: bob.id }))
.some(v => v.followerId === aliceInB.id),
true,
),
]);
});
});
describe('Unfollow a.test ==> b.test', () => {
beforeAll(async () => {
await alice.client.request('following/delete', { userId: bobInA.id });
await sleep();
});
test('Check consistency with `users/following` and `users/followers` endpoints', async () => {
await Promise.all([
strictEqual(
(await alice.client.request('users/following', { userId: alice.id }))
.some(v => v.followeeId === bobInA.id),
false,
),
strictEqual(
(await bob.client.request('users/followers', { userId: bob.id }))
.some(v => v.followerId === aliceInB.id),
false,
),
]);
});
});
});
describe('Follow requests', () => {
let alice: LoginUser, bob: LoginUser;
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
beforeAll(async () => {
[alice, bob] = await Promise.all([
createAccount('a.test'),
createAccount('b.test'),
]);
[bobInA, aliceInB] = await Promise.all([
resolveRemoteUser('b.test', bob.id, alice),
resolveRemoteUser('a.test', alice.id, bob),
]);
await alice.client.request('i/update', { isLocked: true });
});
describe('Send follow request from Bob to Alice and cancel', () => {
describe('Bob sends follow request to Alice', () => {
beforeAll(async () => {
await bob.client.request('following/create', { userId: aliceInB.id });
await sleep();
});
test('Alice should have a request', async () => {
const requests = await alice.client.request('following/requests/list', {});
strictEqual(requests.length, 1);
strictEqual(requests[0].followee.id, alice.id);
strictEqual(requests[0].follower.id, bobInA.id);
});
});
describe('Alice cancels it', () => {
beforeAll(async () => {
await bob.client.request('following/requests/cancel', { userId: aliceInB.id });
await sleep();
});
test('Alice should have no requests', async () => {
const requests = await alice.client.request('following/requests/list', {});
strictEqual(requests.length, 0);
});
});
});
describe('Send follow request from Bob to Alice and reject', () => {
beforeAll(async () => {
await bob.client.request('following/create', { userId: aliceInB.id });
await sleep();
await alice.client.request('following/requests/reject', { userId: bobInA.id });
await sleep();
});
test('Bob should have no requests', async () => {
await rejects(
async () => await bob.client.request('following/requests/cancel', { userId: aliceInB.id }),
(err: any) => {
strictEqual(err.code, 'FOLLOW_REQUEST_NOT_FOUND');
return true;
},
);
});
test('Bob doesn\'t follow Alice', async () => {
const following = await bob.client.request('users/following', { userId: bob.id });
strictEqual(following.length, 0);
});
});
describe('Send follow request from Bob to Alice and accept', () => {
beforeAll(async () => {
await bob.client.request('following/create', { userId: aliceInB.id });
await sleep();
await alice.client.request('following/requests/accept', { userId: bobInA.id });
await sleep();
});
test('Bob follows Alice', async () => {
const following = await bob.client.request('users/following', { userId: bob.id });
strictEqual(following.length, 1);
strictEqual(following[0].followeeId, aliceInB.id);
strictEqual(following[0].followerId, bob.id);
});
});
});
describe('Deletion', () => {
describe('Check Delete consistency', () => {
let alice: LoginUser, bob: LoginUser;
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
beforeAll(async () => {
[alice, bob] = await Promise.all([
createAccount('a.test'),
createAccount('b.test'),
]);
[bobInA, aliceInB] = await Promise.all([
resolveRemoteUser('b.test', bob.id, alice),
resolveRemoteUser('a.test', alice.id, bob),
]);
});
test('Bob follows Alice, and Alice deleted themself', async () => {
await bob.client.request('following/create', { userId: aliceInB.id });
await sleep();
const followers = await alice.client.request('users/followers', { userId: alice.id });
strictEqual(followers.length, 1); // followed by Bob
await alice.client.request('i/delete-account', { password: alice.password });
await sleep();
const following = await bob.client.request('users/following', { userId: bob.id });
strictEqual(following.length, 0); // no following relation
await rejects(
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
(err: any) => {
strictEqual(err.code, 'NO_SUCH_USER');
return true;
},
);
});
});
describe('Deletion of remote user for moderation', () => {
let alice: LoginUser, bob: LoginUser;
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
beforeAll(async () => {
[alice, bob] = await Promise.all([
createAccount('a.test'),
createAccount('b.test'),
]);
[bobInA, aliceInB] = await Promise.all([
resolveRemoteUser('b.test', bob.id, alice),
resolveRemoteUser('a.test', alice.id, bob),
]);
});
test('Bob follows Alice, then Alice gets deleted in B server', async () => {
await bob.client.request('following/create', { userId: aliceInB.id });
await sleep();
const followers = await alice.client.request('users/followers', { userId: alice.id });
strictEqual(followers.length, 1); // followed by Bob
await bAdmin.client.request('admin/delete-account', { userId: aliceInB.id });
await sleep();
/**
* FIXME: remote account is not deleted!
* @see https://github.com/misskey-dev/misskey/issues/14728
*/
const deletedAlice = await bob.client.request('users/show', { userId: aliceInB.id });
assert(deletedAlice.id, aliceInB.id);
// TODO: why still following relation?
const following = await bob.client.request('users/following', { userId: bob.id });
strictEqual(following.length, 1);
await rejects(
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
(err: any) => {
strictEqual(err.code, 'ALREADY_FOLLOWING');
return true;
},
);
});
test('Alice tries to follow Bob, but it is not processed', async () => {
await alice.client.request('following/create', { userId: bobInA.id });
await sleep();
const following = await alice.client.request('users/following', { userId: alice.id });
strictEqual(following.length, 0); // Not following Bob because B server doesn't return Accept
const followers = await bob.client.request('users/followers', { userId: bob.id });
strictEqual(followers.length, 0); // Alice's Follow is not processed
});
});
});
describe('Suspension', () => {
describe('Check suspend/unsuspend consistency', () => {
let alice: LoginUser, bob: LoginUser;
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
beforeAll(async () => {
[alice, bob] = await Promise.all([
createAccount('a.test'),
createAccount('b.test'),
]);
[bobInA, aliceInB] = await Promise.all([
resolveRemoteUser('b.test', bob.id, alice),
resolveRemoteUser('a.test', alice.id, bob),
]);
});
test('Bob follows Alice, and Alice gets suspended, there is no following relation, and Bob fails to follow again', async () => {
await bob.client.request('following/create', { userId: aliceInB.id });
await sleep();
const followers = await alice.client.request('users/followers', { userId: alice.id });
strictEqual(followers.length, 1); // followed by Bob
await aAdmin.client.request('admin/suspend-user', { userId: alice.id });
await sleep();
const following = await bob.client.request('users/following', { userId: bob.id });
strictEqual(following.length, 0); // no following relation
await rejects(
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
(err: any) => {
strictEqual(err.code, 'NO_SUCH_USER');
return true;
},
);
});
test('Alice gets unsuspended, Bob succeeds in following Alice', async () => {
await aAdmin.client.request('admin/unsuspend-user', { userId: alice.id });
await sleep();
const followers = await alice.client.request('users/followers', { userId: alice.id });
strictEqual(followers.length, 1); // FIXME: followers are not deleted??
/**
* FIXME: still rejected!
* seems to can't process Undo Delete activity because it is not implemented
* related @see https://github.com/misskey-dev/misskey/issues/13273
*/
await rejects(
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
(err: any) => {
strictEqual(err.code, 'NO_SUCH_USER');
return true;
},
);
// FIXME: resolving also fails
await rejects(
async () => await resolveRemoteUser('a.test', alice.id, bob),
(err: any) => {
strictEqual(err.code, 'INTERNAL_ERROR');
return true;
},
);
});
/**
* instead of simple unsuspension, let's tell existence by following from Alice
*/
test('Alice can follow Bob', async () => {
await alice.client.request('following/create', { userId: bobInA.id });
await sleep();
const bobFollowers = await bob.client.request('users/followers', { userId: bob.id });
strictEqual(bobFollowers.length, 1); // followed by Alice
assert(bobFollowers[0].follower != null);
const renewedaliceInB = bobFollowers[0].follower;
assert(aliceInB.username === renewedaliceInB.username);
assert(aliceInB.host === renewedaliceInB.host);
assert(aliceInB.id !== renewedaliceInB.id); // TODO: Same username and host, but their ids are different! Is it OK?
const following = await bob.client.request('users/following', { userId: bob.id });
strictEqual(following.length, 0); // following are deleted
// Bob tries to follow Alice
await bob.client.request('following/create', { userId: renewedaliceInB.id });
await sleep();
const aliceFollowers = await alice.client.request('users/followers', { userId: alice.id });
strictEqual(aliceFollowers.length, 1);
// FIXME: but resolving still fails ...
await rejects(
async () => await resolveRemoteUser('a.test', alice.id, bob),
(err: any) => {
strictEqual(err.code, 'INTERNAL_ERROR');
return true;
},
);
});
});
});
});

View file

@ -0,0 +1,309 @@
import { deepStrictEqual, strictEqual } from 'assert';
import { readFile } from 'fs/promises';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import * as Misskey from 'misskey-js';
import { WebSocket } from 'ws';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export const ADMIN_PARAMS = { username: 'admin', password: 'admin' };
const ADMIN_CACHE = new Map<Host, SigninResponse>();
await Promise.all([
fetchAdmin('a.test'),
fetchAdmin('b.test'),
]);
type SigninResponse = Omit<Misskey.entities.SigninFlowResponse & { finished: true }, 'finished'>;
export type LoginUser = SigninResponse & {
client: Misskey.api.APIClient;
username: string;
password: string;
}
/** used for avoiding overload and some endpoints */
export type Request = <
E extends keyof Misskey.Endpoints,
P extends Misskey.Endpoints[E]['req'],
>(
endpoint: E,
params: P,
credential?: string | null,
) => Promise<Misskey.api.SwitchCaseResponseType<E, P>>;
type Host = 'a.test' | 'b.test';
export async function sleep(ms = 200): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function signin(
host: Host,
params: Misskey.entities.SigninFlowRequest,
): Promise<SigninResponse> {
// wait for a second to prevent hit rate limit
await sleep(1000);
return await (new Misskey.api.APIClient({ origin: `https://${host}` }).request as Request)('signin-flow', params)
.then(res => {
strictEqual(res.finished, true);
if (params.username === ADMIN_PARAMS.username) ADMIN_CACHE.set(host, res);
return res;
})
.then(({ id, i }) => ({ id, i }))
.catch(async err => {
if (err.code === 'TOO_MANY_AUTHENTICATION_FAILURES') {
await sleep(Math.random() * 2000);
return await signin(host, params);
}
throw err;
});
}
async function createAdmin(host: Host): Promise<Misskey.entities.SignupResponse | undefined> {
const client = new Misskey.api.APIClient({ origin: `https://${host}` });
return await client.request('admin/accounts/create', ADMIN_PARAMS).then(res => {
ADMIN_CACHE.set(host, {
id: res.id,
// @ts-expect-error FIXME: openapi-typescript generates incorrect response type for this endpoint, so ignore this
i: res.token,
});
return res as Misskey.entities.SignupResponse;
}).then(async res => {
await client.request('admin/roles/update-default-policies', {
policies: {
/** TODO: @see https://github.com/misskey-dev/misskey/issues/14169 */
rateLimitFactor: 0 as never,
},
}, res.token);
return res;
}).catch(err => {
if (err.info.e.message === 'access denied') return undefined;
throw err;
});
}
export async function fetchAdmin(host: Host): Promise<LoginUser> {
const admin = ADMIN_CACHE.get(host) ?? await signin(host, ADMIN_PARAMS)
.catch(async err => {
if (err.id === '6cc579cc-885d-43d8-95c2-b8c7fc963280') {
await createAdmin(host);
return await signin(host, ADMIN_PARAMS);
}
throw err;
});
return {
...admin,
client: new Misskey.api.APIClient({ origin: `https://${host}`, credential: admin.i }),
...ADMIN_PARAMS,
};
}
export async function createAccount(host: Host): Promise<LoginUser> {
const username = crypto.randomUUID().replaceAll('-', '').substring(0, 20);
const password = crypto.randomUUID().replaceAll('-', '');
const admin = await fetchAdmin(host);
await admin.client.request('admin/accounts/create', { username, password });
const signinRes = await signin(host, { username, password });
return {
...signinRes,
client: new Misskey.api.APIClient({ origin: `https://${host}`, credential: signinRes.i }),
username,
password,
};
}
export async function createModerator(host: Host): Promise<LoginUser> {
const user = await createAccount(host);
const role = await createRole(host, {
name: 'Moderator',
isModerator: true,
});
const admin = await fetchAdmin(host);
await admin.client.request('admin/roles/assign', { roleId: role.id, userId: user.id });
return user;
}
export async function createRole(
host: Host,
params: Partial<Misskey.entities.AdminRolesCreateRequest> = {},
): Promise<Misskey.entities.Role> {
const admin = await fetchAdmin(host);
return await admin.client.request('admin/roles/create', {
name: 'Some role',
description: 'Role for testing',
color: null,
iconUrl: null,
target: 'conditional',
condFormula: {},
isPublic: true,
isModerator: false,
isAdministrator: false,
isExplorable: true,
asBadge: false,
canEditMembersByModerator: false,
displayOrder: 0,
policies: {},
...params,
});
}
export async function resolveRemoteUser(
host: Host,
id: string,
from: LoginUser,
): Promise<Misskey.entities.UserDetailedNotMe> {
const uri = `https://${host}/users/${id}`;
return await from.client.request('ap/show', { uri })
.then(res => {
strictEqual(res.type, 'User');
strictEqual(res.object.uri, uri);
return res.object;
});
}
export async function resolveRemoteNote(
host: Host,
id: string,
from: LoginUser,
): Promise<Misskey.entities.Note> {
const uri = `https://${host}/notes/${id}`;
return await from.client.request('ap/show', { uri })
.then(res => {
strictEqual(res.type, 'Note');
strictEqual(res.object.uri, uri);
return res.object;
});
}
export async function uploadFile(
host: Host,
user: { i: string },
path = '../../test/resources/192.jpg',
): Promise<Misskey.entities.DriveFile> {
const filename = path.split('/').pop() ?? 'untitled';
const blob = new Blob([await readFile(join(__dirname, path))]);
const body = new FormData();
body.append('i', user.i);
body.append('force', 'true');
body.append('file', blob);
body.append('name', filename);
return await fetch(`https://${host}/api/drive/files/create`, { method: 'POST', body })
.then(async res => await res.json());
}
export async function addCustomEmoji(
host: Host,
param?: Partial<Misskey.entities.AdminEmojiAddRequest>,
path?: string,
): Promise<Misskey.entities.EmojiDetailed> {
const admin = await fetchAdmin(host);
const name = crypto.randomUUID().replaceAll('-', '');
const file = await uploadFile(host, admin, path);
return await admin.client.request('admin/emoji/add', { name, fileId: file.id, ...param });
}
export function deepStrictEqualWithExcludedFields<T>(actual: T, expected: T, excludedFields: (keyof T)[]) {
const _actual = structuredClone(actual);
const _expected = structuredClone(expected);
for (const obj of [_actual, _expected]) {
for (const field of excludedFields) {
delete obj[field];
}
}
deepStrictEqual(_actual, _expected);
}
export async function isFired<C extends keyof Misskey.Channels, T extends keyof Misskey.Channels[C]['events']>(
host: Host,
user: { i: string },
channel: C,
trigger: () => Promise<unknown>,
type: T,
// @ts-expect-error TODO: why getting error here?
cond: (msg: Parameters<Misskey.Channels[C]['events'][T]>[0]) => boolean,
params?: Misskey.Channels[C]['params'],
): Promise<boolean> {
return new Promise<boolean>(async (resolve, reject) => {
// @ts-expect-error TODO: why?
const stream = new Misskey.Stream(`wss://${host}`, { token: user.i }, { WebSocket });
const connection = stream.useChannel(channel, params);
connection.on(type as any, ((msg: any) => {
if (cond(msg)) {
stream.close();
clearTimeout(timer);
resolve(true);
}
}) as any);
let timer: NodeJS.Timeout | undefined;
await trigger().then(() => {
timer = setTimeout(() => {
stream.close();
resolve(false);
}, 500);
}).catch(err => {
stream.close();
clearTimeout(timer);
reject(err);
});
});
};
export async function isNoteUpdatedEventFired(
host: Host,
user: { i: string },
noteId: string,
trigger: () => Promise<unknown>,
cond: (msg: Parameters<Misskey.StreamEvents['noteUpdated']>[0]) => boolean,
): Promise<boolean> {
return new Promise<boolean>(async (resolve, reject) => {
// @ts-expect-error TODO: why?
const stream = new Misskey.Stream(`wss://${host}`, { token: user.i }, { WebSocket });
stream.send('s', { id: noteId });
stream.on('noteUpdated', msg => {
if (cond(msg)) {
stream.close();
clearTimeout(timer);
resolve(true);
}
});
let timer: NodeJS.Timeout | undefined;
await trigger().then(() => {
timer = setTimeout(() => {
stream.close();
resolve(false);
}, 500);
}).catch(err => {
stream.close();
clearTimeout(timer);
reject(err);
});
});
};
export async function assertNotificationReceived(
receiverHost: Host,
receiver: LoginUser,
trigger: () => Promise<unknown>,
cond: (notification: Misskey.entities.Notification) => boolean,
expect: boolean,
) {
const streamingFired = await isFired(receiverHost, receiver, 'main', trigger, 'notification', cond);
strictEqual(streamingFired, expect);
const endpointFired = await receiver.client.request('i/notifications', {})
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
.then(([notification]) => notification != null ? cond(notification) : false);
strictEqual(endpointFired, expect);
}

View file

@ -0,0 +1,114 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "NodeNext", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./built", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
},
"include": [
"daemon.ts",
"./test/**/*.ts"
]
}

View file

@ -8,6 +8,7 @@ process.env.NODE_ENV = 'test';
import { jest } from '@jest/globals';
import { Test } from '@nestjs/testing';
import { Redis } from 'ioredis';
import type { TestingModule } from '@nestjs/testing';
import { GlobalModule } from '@/GlobalModule.js';
import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
@ -16,7 +17,6 @@ import { LoggerService } from '@/core/LoggerService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { IdService } from '@/core/IdService.js';
import { DI } from '@/di-symbols.js';
import type { TestingModule } from '@nestjs/testing';
function mockRedis() {
const hash = {} as any;
@ -52,7 +52,7 @@ describe('FetchInstanceMetadataService', () => {
if (token === HttpRequestService) {
return { getJson: jest.fn(), getHtml: jest.fn(), send: jest.fn() };
} else if (token === FederatedInstanceService) {
return { fetch: jest.fn() };
return { fetchOrRegister: jest.fn() };
} else if (token === DI.redis) {
return mockRedis;
}
@ -75,7 +75,7 @@ describe('FetchInstanceMetadataService', () => {
test('Lock and update', async () => {
redisClient.set = mockRedis();
const now = Date.now();
federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => { return now - 10 * 1000 * 60 * 60 * 24; } } } as any);
federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => { return now - 10 * 1000 * 60 * 60 * 24; } } } as any);
httpRequestService.getJson.mockImplementation(() => { throw Error(); });
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock');
@ -83,14 +83,14 @@ describe('FetchInstanceMetadataService', () => {
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any);
expect(tryLockSpy).toHaveBeenCalledTimes(1);
expect(unlockSpy).toHaveBeenCalledTimes(1);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1);
expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(1);
expect(httpRequestService.getJson).toHaveBeenCalled();
});
test('Lock and don\'t update', async () => {
redisClient.set = mockRedis();
const now = Date.now();
federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now } } as any);
federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => now } } as any);
httpRequestService.getJson.mockImplementation(() => { throw Error(); });
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock');
@ -98,14 +98,14 @@ describe('FetchInstanceMetadataService', () => {
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any);
expect(tryLockSpy).toHaveBeenCalledTimes(1);
expect(unlockSpy).toHaveBeenCalledTimes(1);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1);
expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(1);
expect(httpRequestService.getJson).toHaveBeenCalledTimes(0);
});
test('Do nothing when lock not acquired', async () => {
redisClient.set = mockRedis();
const now = Date.now();
federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any);
federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any);
httpRequestService.getJson.mockImplementation(() => { throw Error(); });
await fetchInstanceMetadataService.tryLock('example.com');
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
@ -114,14 +114,14 @@ describe('FetchInstanceMetadataService', () => {
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any);
expect(tryLockSpy).toHaveBeenCalledTimes(1);
expect(unlockSpy).toHaveBeenCalledTimes(0);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0);
expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(0);
expect(httpRequestService.getJson).toHaveBeenCalledTimes(0);
});
test('Do when lock not acquired but forced', async () => {
redisClient.set = mockRedis();
const now = Date.now();
federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any);
federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any);
httpRequestService.getJson.mockImplementation(() => { throw Error(); });
await fetchInstanceMetadataService.tryLock('example.com');
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
@ -130,7 +130,7 @@ describe('FetchInstanceMetadataService', () => {
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any, true);
expect(tryLockSpy).toHaveBeenCalledTimes(0);
expect(unlockSpy).toHaveBeenCalledTimes(1);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0);
expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(0);
expect(httpRequestService.getJson).toHaveBeenCalled();
});
});

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