Compare commits
64 Commits
c01b8f6303
...
442ca61d2d
Author | SHA1 | Date |
---|---|---|
おさむのひと | 442ca61d2d | |
おさむのひと | 5437289c4e | |
かっこかり | 0e90589290 | |
github-actions[bot] | 872cefcfb8 | |
github-actions[bot] | 551040ed0f | |
syuilo | 71bfa85986 | |
かっこかり | f25fc5215b | |
anatawa12 | 1911972ae2 | |
github-actions[bot] | 752606fe88 | |
かっこかり | 7f0ae038d4 | |
syuilo | 9871035597 | |
github-actions[bot] | a21a2c52d7 | |
かっこかり | c1f19fad1e | |
かっこかり | 3a6c2aa835 | |
かっこかり | 53e827b18c | |
syuilo | 0f59adc436 | |
syuilo | 9fdabe3666 | |
rectcoordsystem | 090e9392cd | |
Julia | b9cb949eb1 | |
Julia | 5f675201f2 | |
syuilo | 1c284c8154 | |
Sayamame-beans | aa48a0e207 | |
syuilo | f0c3a4cc0b | |
鴇峰 朔華 | 4603ab67bb | |
zawa-ch. | 763c708253 | |
github-actions[bot] | 6c5d3113c6 | |
syuilo | 968f595606 | |
おさむのひと | 7b9c884a5d | |
FineArchs | c271534aba | |
饺子w (Yumechi) | e800c0f85a | |
かっこかり | 81348f1277 | |
おさむのひと | acba767fe0 | |
おさむのひと | d95543d05b | |
おさむのひと | 4827fbdd10 | |
おさむのひと | 2a2a379335 | |
おさむのひと | 54db85afff | |
おさむのひと | 986e7edc1d | |
おさむのひと | ebba83f0e6 | |
おさむのひと | b6e22f3ad8 | |
おさむのひと | 5595e8fbd1 | |
samunohito | 0f574b7fd6 | |
おさむのひと | 012e3fec51 | |
samunohito | 915225538b | |
samunohito | b78aa56dcd | |
samunohito | 50e1ee18fa | |
samunohito | 81d883c45f | |
samunohito | 3514c9f68c | |
samunohito | ac728dd3fb | |
samunohito | 5554761354 | |
samunohito | a685336a8b | |
samunohito | 28fdf1b9a6 | |
samunohito | f7f9df878b | |
samunohito | 491541fed1 | |
samunohito | efee424096 | |
samunohito | b5ccf1b484 | |
samunohito | a56c680136 | |
samunohito | ae485ed568 | |
samunohito | fa8d905484 | |
samunohito | de238b70d7 | |
samunohito | fdf2b8cd0b | |
samunohito | a46fefd43c | |
samunohito | 7d7c2d4daf | |
samunohito | 94ededa68d | |
samunohito | cbc256b7ce |
|
@ -86,6 +86,7 @@ jobs:
|
||||||
draft_prerelease_channel: alpha
|
draft_prerelease_channel: alpha
|
||||||
ready_start_prerelease_channel: beta
|
ready_start_prerelease_channel: beta
|
||||||
prerelease_channel: ${{ inputs.start-rc && 'rc' || '' }}
|
prerelease_channel: ${{ inputs.start-rc && 'rc' || '' }}
|
||||||
|
reset_number_on_channel_change: true
|
||||||
secrets:
|
secrets:
|
||||||
RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }}
|
RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }}
|
||||||
RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||||
|
|
|
@ -41,6 +41,7 @@ jobs:
|
||||||
indent: ${{ vars.INDENT }}
|
indent: ${{ vars.INDENT }}
|
||||||
draft_prerelease_channel: alpha
|
draft_prerelease_channel: alpha
|
||||||
ready_start_prerelease_channel: beta
|
ready_start_prerelease_channel: beta
|
||||||
|
reset_number_on_channel_change: true
|
||||||
secrets:
|
secrets:
|
||||||
RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }}
|
RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }}
|
||||||
RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||||
|
|
26
CHANGELOG.md
26
CHANGELOG.md
|
@ -1,3 +1,15 @@
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### General
|
||||||
|
-
|
||||||
|
|
||||||
|
### Client
|
||||||
|
-
|
||||||
|
|
||||||
|
### Server
|
||||||
|
-
|
||||||
|
|
||||||
|
|
||||||
## 2024.11.0
|
## 2024.11.0
|
||||||
|
|
||||||
### Note
|
### Note
|
||||||
|
@ -8,8 +20,11 @@
|
||||||
### General
|
### General
|
||||||
- Feat: コンテンツの表示にログインを必須にできるように
|
- Feat: コンテンツの表示にログインを必須にできるように
|
||||||
- Feat: 過去のノートを非公開化/フォロワーのみ表示可能にできるように
|
- Feat: 過去のノートを非公開化/フォロワーのみ表示可能にできるように
|
||||||
|
- Feat: チャンネルミュート機能の実装 #10649
|
||||||
|
- チャンネルの概要画面の右上からミュートできます(リンクコピー、共有、設定と同列)
|
||||||
- Enhance: 依存関係の更新
|
- Enhance: 依存関係の更新
|
||||||
- Enhance: l10nの更新
|
- Enhance: l10nの更新
|
||||||
|
- Fix: お知らせ作成時に画像URL入力欄を空欄に変更できないのを修正 ( #14976 )
|
||||||
|
|
||||||
### Client
|
### Client
|
||||||
- Enhance: Bull DashboardでRelationship Queueの状態も確認できるように
|
- Enhance: Bull DashboardでRelationship Queueの状態も確認できるように
|
||||||
|
@ -28,17 +43,21 @@
|
||||||
- Enhance: 過去に送信したフォローリクエストを確認できるように
|
- Enhance: 過去に送信したフォローリクエストを確認できるように
|
||||||
(Based on https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/663)
|
(Based on https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/663)
|
||||||
- Enhance: サイドバーを簡単に展開・折りたたみできるように ( #14981 )
|
- Enhance: サイドバーを簡単に展開・折りたたみできるように ( #14981 )
|
||||||
|
- Enhance: リノートメニューに「リノートの詳細」を追加
|
||||||
|
- Enhance: 非ログイン状態でMisskeyを開いた際のパフォーマンスを向上
|
||||||
- Fix: 通知の範囲指定の設定項目が必要ない通知設定でも範囲指定の設定がでている問題を修正
|
- Fix: 通知の範囲指定の設定項目が必要ない通知設定でも範囲指定の設定がでている問題を修正
|
||||||
- Fix: Turnstileが失敗・期限切れした際にも成功扱いとなってしまう問題を修正
|
- Fix: Turnstileが失敗・期限切れした際にも成功扱いとなってしまう問題を修正
|
||||||
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/768)
|
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/768)
|
||||||
- Fix: デッキのタイムラインカラムで「センシティブなファイルを含むノートを表示」設定が使用できなかった問題を修正
|
- Fix: デッキのタイムラインカラムで「センシティブなファイルを含むノートを表示」設定が使用できなかった問題を修正
|
||||||
- Fix: Encode RSS urls with escape sequences before fetching allowing query parameters to be used
|
- Fix: Encode RSS urls with escape sequences before fetching allowing query parameters to be used
|
||||||
- Fix: リンク切れを修正
|
- Fix: リンク切れを修正
|
||||||
= Fix: ノート投稿ボタンにホバー時のスタイルが適用されていないのを修正
|
- Fix: ノート投稿ボタンにホバー時のスタイルが適用されていないのを修正
|
||||||
(Cherry-picked from https://github.com/taiyme/misskey/pull/305)
|
(Cherry-picked from https://github.com/taiyme/misskey/pull/305)
|
||||||
- Fix: メールアドレス登録有効化時の「完了」ダイアログボックスの表示条件を修正
|
- Fix: メールアドレス登録有効化時の「完了」ダイアログボックスの表示条件を修正
|
||||||
- Fix: 画面幅が狭い環境でデザインが崩れる問題を修正
|
- Fix: 画面幅が狭い環境でデザインが崩れる問題を修正
|
||||||
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/815)
|
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/815)
|
||||||
|
- Fix: TypeScriptの型チェック対象ファイルを限定してビルドを高速化するように
|
||||||
|
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/725)
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
- Enhance: DockerのNode.jsを22.11.0に更新
|
- Enhance: DockerのNode.jsを22.11.0に更新
|
||||||
|
@ -60,6 +79,11 @@
|
||||||
- Fix: FTT無効時にユーザーリストタイムラインが使用できない問題を修正
|
- Fix: FTT無効時にユーザーリストタイムラインが使用できない問題を修正
|
||||||
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/709)
|
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/709)
|
||||||
- Fix: User Webhookテスト機能のMock Payloadを修正
|
- Fix: User Webhookテスト機能のMock Payloadを修正
|
||||||
|
- Fix: アカウント削除のモデレーションログが動作していないのを修正 (#14996)
|
||||||
|
- Fix: リノートミュートが新規投稿通知に対して作用していなかった問題を修正
|
||||||
|
- Fix: Inboxの処理で生じるエラーを誤ってActivityとして処理することがある問題を修正
|
||||||
|
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/730)
|
||||||
|
- Fix: セキュリティに関する修正
|
||||||
|
|
||||||
### Misskey.js
|
### Misskey.js
|
||||||
- Fix: Stream初期化時、別途WebSocketを指定する場合の型定義を修正
|
- Fix: Stream初期化時、別途WebSocketを指定する場合の型定義を修正
|
||||||
|
|
|
@ -343,7 +343,6 @@ enableLocalTimeline: "تفعيل الخيط المحلي"
|
||||||
enableGlobalTimeline: "تفعيل الخيط الزمني الشامل"
|
enableGlobalTimeline: "تفعيل الخيط الزمني الشامل"
|
||||||
disablingTimelinesInfo: "سيتمكن المديرون والمشرفون من الوصول إلى كل الخيوط الزمنية حتى وإن لم تفعّل."
|
disablingTimelinesInfo: "سيتمكن المديرون والمشرفون من الوصول إلى كل الخيوط الزمنية حتى وإن لم تفعّل."
|
||||||
registration: "إنشاء حساب"
|
registration: "إنشاء حساب"
|
||||||
enableRegistration: "تفعيل إنشاء الحسابات الجديدة"
|
|
||||||
invite: "دعوة"
|
invite: "دعوة"
|
||||||
driveCapacityPerLocalAccount: "حصة التخزين لكل مستخدم محلي"
|
driveCapacityPerLocalAccount: "حصة التخزين لكل مستخدم محلي"
|
||||||
driveCapacityPerRemoteAccount: "حصة التخزين لكل مستخدم بعيد"
|
driveCapacityPerRemoteAccount: "حصة التخزين لكل مستخدم بعيد"
|
||||||
|
|
|
@ -339,7 +339,6 @@ enableLocalTimeline: "স্থানীয় টাইমলাইন চাল
|
||||||
enableGlobalTimeline: "গ্লোবাল টাইমলাইন চালু করুন"
|
enableGlobalTimeline: "গ্লোবাল টাইমলাইন চালু করুন"
|
||||||
disablingTimelinesInfo: "আপনি এই টাইমলাইনগুলি বন্ধ করলেও প্রশাসক এবং মডারেটররা এই টাইমলাইনগুলি ব্যাবহার করতে পারবে"
|
disablingTimelinesInfo: "আপনি এই টাইমলাইনগুলি বন্ধ করলেও প্রশাসক এবং মডারেটররা এই টাইমলাইনগুলি ব্যাবহার করতে পারবে"
|
||||||
registration: "নিবন্ধন"
|
registration: "নিবন্ধন"
|
||||||
enableRegistration: "নতুন ব্যাবহারকারী নিবন্ধন চালু করুন"
|
|
||||||
invite: "আমন্ত্রণ"
|
invite: "আমন্ত্রণ"
|
||||||
driveCapacityPerLocalAccount: "প্রত্যেক স্থানীয় ব্যাবহারকারীর জন্য ড্রাইভের জায়গা"
|
driveCapacityPerLocalAccount: "প্রত্যেক স্থানীয় ব্যাবহারকারীর জন্য ড্রাইভের জায়গা"
|
||||||
driveCapacityPerRemoteAccount: "প্রত্যেক রিমোট ব্যাবহারকারীর জন্য ড্রাইভের জায়গা"
|
driveCapacityPerRemoteAccount: "প্রত্যেক রিমোট ব্যাবহারকারীর জন্য ড্রাইভের জায়গা"
|
||||||
|
|
|
@ -382,7 +382,6 @@ enableLocalTimeline: "Activa la línia de temps local"
|
||||||
enableGlobalTimeline: "Activa la línia de temps global"
|
enableGlobalTimeline: "Activa la línia de temps global"
|
||||||
disablingTimelinesInfo: "Fins i tot si aquestes línies de temps són desactivades, els administradors i els moderadors poden continuar visualitzant per conveniència."
|
disablingTimelinesInfo: "Fins i tot si aquestes línies de temps són desactivades, els administradors i els moderadors poden continuar visualitzant per conveniència."
|
||||||
registration: "Registre"
|
registration: "Registre"
|
||||||
enableRegistration: "Permet el registre de nous usuaris"
|
|
||||||
invite: "Convida"
|
invite: "Convida"
|
||||||
driveCapacityPerLocalAccount: "Capacitat del disc per usuaris locals"
|
driveCapacityPerLocalAccount: "Capacitat del disc per usuaris locals"
|
||||||
driveCapacityPerRemoteAccount: "Capacitat del disc per usuaris remots"
|
driveCapacityPerRemoteAccount: "Capacitat del disc per usuaris remots"
|
||||||
|
@ -587,6 +586,7 @@ masterVolume: "Volum principal"
|
||||||
notUseSound: "Sense so"
|
notUseSound: "Sense so"
|
||||||
useSoundOnlyWhenActive: "Reproduir sons només quan Misskey estigui actiu"
|
useSoundOnlyWhenActive: "Reproduir sons només quan Misskey estigui actiu"
|
||||||
details: "Detalls"
|
details: "Detalls"
|
||||||
|
renoteDetails: "Més informació sobre l'impuls "
|
||||||
chooseEmoji: "Tria un emoji"
|
chooseEmoji: "Tria un emoji"
|
||||||
unableToProcess: "L'operació no pot ser completada "
|
unableToProcess: "L'operació no pot ser completada "
|
||||||
recentUsed: "Utilitzat recentment"
|
recentUsed: "Utilitzat recentment"
|
||||||
|
@ -1300,6 +1300,7 @@ thisContentsAreMarkedAsSigninRequiredByAuthor: "L'autor requereix l'inici de ses
|
||||||
lockdown: "Bloquejat"
|
lockdown: "Bloquejat"
|
||||||
pleaseSelectAccount: "Seleccionar un compte"
|
pleaseSelectAccount: "Seleccionar un compte"
|
||||||
availableRoles: "Roles disponibles "
|
availableRoles: "Roles disponibles "
|
||||||
|
acknowledgeNotesAndEnable: "Activa'l després de comprendre els possibles perills."
|
||||||
_accountSettings:
|
_accountSettings:
|
||||||
requireSigninToViewContents: "És obligatori l'inici de sessió per poder veure el contingut"
|
requireSigninToViewContents: "És obligatori l'inici de sessió per poder veure el contingut"
|
||||||
requireSigninToViewContentsDescription1: "Es requereix l'inici de sessió per poder veure totes les notes i el contingut que has creat. Amb això esperem evitar que els rastrejadors recopilin informació."
|
requireSigninToViewContentsDescription1: "Es requereix l'inici de sessió per poder veure totes les notes i el contingut que has creat. Amb això esperem evitar que els rastrejadors recopilin informació."
|
||||||
|
@ -1456,6 +1457,8 @@ _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."
|
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 "
|
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ó."
|
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ó."
|
||||||
|
openRegistration: "Registres oberts"
|
||||||
|
openRegistrationWarning: "Obrir els registres és arriscat. Es recomana obrir-los només si el servidor és monitorat constantment i per respondre immediatament davant qualsevol problema."
|
||||||
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."
|
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:
|
_accountMigration:
|
||||||
moveFrom: "Migrar un altre compte a aquest"
|
moveFrom: "Migrar un altre compte a aquest"
|
||||||
|
@ -2738,3 +2741,6 @@ _selfXssPrevention:
|
||||||
description1: "Si posa alguna cosa al seu compte, un usuari malintencionat podria segrestar-la o robar-li les dades."
|
description1: "Si posa alguna cosa al seu compte, un usuari malintencionat podria segrestar-la o robar-li les dades."
|
||||||
description2: "Si no entens que estàs fent %cpara ara mateix i tanca la finestra."
|
description2: "Si no entens que estàs fent %cpara ara mateix i tanca la finestra."
|
||||||
description3: "Per obtenir més informació. {link}"
|
description3: "Per obtenir més informació. {link}"
|
||||||
|
_followRequest:
|
||||||
|
recieved: "Sol·licituds rebudes"
|
||||||
|
sent: "Sol·licituds enviades"
|
||||||
|
|
|
@ -348,7 +348,6 @@ enableLocalTimeline: "Povolit lokální čas"
|
||||||
enableGlobalTimeline: "Povolit globální čas"
|
enableGlobalTimeline: "Povolit globální čas"
|
||||||
disablingTimelinesInfo: "Administrátoři a Moderátoři budou mít stálý přístup ke všem časovým osám i přes to že nejsou zapnuté."
|
disablingTimelinesInfo: "Administrátoři a Moderátoři budou mít stálý přístup ke všem časovým osám i přes to že nejsou zapnuté."
|
||||||
registration: "Registrace"
|
registration: "Registrace"
|
||||||
enableRegistration: "Povolit registraci novým uživatelům"
|
|
||||||
invite: "Pozvat"
|
invite: "Pozvat"
|
||||||
driveCapacityPerLocalAccount: "Kapacita disku na lokálního uživatele"
|
driveCapacityPerLocalAccount: "Kapacita disku na lokálního uživatele"
|
||||||
driveCapacityPerRemoteAccount: "Kapacita disku na vzdáleného uživatele"
|
driveCapacityPerRemoteAccount: "Kapacita disku na vzdáleného uživatele"
|
||||||
|
|
|
@ -10,6 +10,7 @@ username: "Benutzername"
|
||||||
password: "Passwort"
|
password: "Passwort"
|
||||||
initialPasswordForSetup: "Initiales Passwort für die Einrichtung"
|
initialPasswordForSetup: "Initiales Passwort für die Einrichtung"
|
||||||
initialPasswordIsIncorrect: "Das initiale Passwort für die Einrichtung ist falsch"
|
initialPasswordIsIncorrect: "Das initiale Passwort für die Einrichtung ist falsch"
|
||||||
|
initialPasswordForSetupDescription: "Verwende das in der Konfigurationsdatei angegebene Passwort, wenn du Misskey selbst installiert hast.\nWenn du einen Misskey-Hostingdienst o.ä. nutzt, verwende das dort angegebene Kennwort.\nWenn du kein Passwort festgelegt hast, lasse es leer, um fortzufahren."
|
||||||
forgotPassword: "Passwort vergessen"
|
forgotPassword: "Passwort vergessen"
|
||||||
fetchingAsApObject: "Wird aus dem Fediverse angefragt …"
|
fetchingAsApObject: "Wird aus dem Fediverse angefragt …"
|
||||||
ok: "OK"
|
ok: "OK"
|
||||||
|
@ -111,11 +112,14 @@ enterEmoji: "Gib ein Emoji ein"
|
||||||
renote: "Renote"
|
renote: "Renote"
|
||||||
unrenote: "Renote zurücknehmen"
|
unrenote: "Renote zurücknehmen"
|
||||||
renoted: "Renote getätigt."
|
renoted: "Renote getätigt."
|
||||||
|
renotedToX: "Renoted zu {name}."
|
||||||
cantRenote: "Renote dieses Beitrags nicht möglich."
|
cantRenote: "Renote dieses Beitrags nicht möglich."
|
||||||
cantReRenote: "Renote einer Renote nicht möglich."
|
cantReRenote: "Renote einer Renote nicht möglich."
|
||||||
quote: "Zitieren"
|
quote: "Zitieren"
|
||||||
inChannelRenote: "Kanal-interner Renote"
|
inChannelRenote: "Kanal-interner Renote"
|
||||||
inChannelQuote: "Kanal-internes Zitat"
|
inChannelQuote: "Kanal-internes Zitat"
|
||||||
|
renoteToChannel: "Renote zu Kanal"
|
||||||
|
renoteToOtherChannel: "Renote zu anderem Kanal"
|
||||||
pinnedNote: "Angeheftete Notiz"
|
pinnedNote: "Angeheftete Notiz"
|
||||||
pinned: "Angeheftet"
|
pinned: "Angeheftet"
|
||||||
you: "Du"
|
you: "Du"
|
||||||
|
@ -127,12 +131,13 @@ reactions: "Reaktionen"
|
||||||
emojiPicker: "Emoji auswählen"
|
emojiPicker: "Emoji auswählen"
|
||||||
pinnedEmojisForReactionSettingDescription: "Lege Emojis fest, die angepinnt werden sollen, um sie beim Reagieren als Erstes anzuzeigen."
|
pinnedEmojisForReactionSettingDescription: "Lege Emojis fest, die angepinnt werden sollen, um sie beim Reagieren als Erstes anzuzeigen."
|
||||||
pinnedEmojisSettingDescription: "Lege Emojis fest, die angepinnt werden sollen, um sie in der Emoji-Auswahl als Erstes anzuzeigen"
|
pinnedEmojisSettingDescription: "Lege Emojis fest, die angepinnt werden sollen, um sie in der Emoji-Auswahl als Erstes anzuzeigen"
|
||||||
|
emojiPickerDisplay: "Anzeige der Emoji-Auswahl"
|
||||||
overwriteFromPinnedEmojisForReaction: "Überschreiben mit den Reaktions-Einstellungen"
|
overwriteFromPinnedEmojisForReaction: "Überschreiben mit den Reaktions-Einstellungen"
|
||||||
overwriteFromPinnedEmojis: "Überschreiben mit den allgemeinen Einstellungen"
|
overwriteFromPinnedEmojis: "Überschreiben mit den allgemeinen Einstellungen"
|
||||||
reactionSettingDescription2: "Ziehe um Anzuordnen, klicke um zu löschen, drücke „+“ um hinzuzufügen"
|
reactionSettingDescription2: "Ziehe um Anzuordnen, klicke um zu löschen, drücke „+“ um hinzuzufügen"
|
||||||
rememberNoteVisibility: "Notizsichtbarkeit merken"
|
rememberNoteVisibility: "Notizsichtbarkeit merken"
|
||||||
attachCancel: "Anhang entfernen"
|
attachCancel: "Anhang entfernen"
|
||||||
deleteFile: "Datei gelöscht"
|
deleteFile: "Datei löschen"
|
||||||
markAsSensitive: "Als sensibel markieren"
|
markAsSensitive: "Als sensibel markieren"
|
||||||
unmarkAsSensitive: "Als nicht sensibel markieren"
|
unmarkAsSensitive: "Als nicht sensibel markieren"
|
||||||
enterFileName: "Dateinamen eingeben"
|
enterFileName: "Dateinamen eingeben"
|
||||||
|
@ -180,6 +185,8 @@ addAccount: "Benutzerkonto hinzufügen"
|
||||||
reloadAccountsList: "Benutzerkontoliste aktualisieren"
|
reloadAccountsList: "Benutzerkontoliste aktualisieren"
|
||||||
loginFailed: "Anmeldung fehlgeschlagen"
|
loginFailed: "Anmeldung fehlgeschlagen"
|
||||||
showOnRemote: "Auf Ursprungsinstanz ansehen"
|
showOnRemote: "Auf Ursprungsinstanz ansehen"
|
||||||
|
chooseServerOnMisskeyHub: "Wähle einen Server aus dem Misskey Hub"
|
||||||
|
inputHostName: "Gib die Domain an"
|
||||||
general: "Allgemein"
|
general: "Allgemein"
|
||||||
wallpaper: "Hintergrund"
|
wallpaper: "Hintergrund"
|
||||||
setWallpaper: "Hintergrund festlegen"
|
setWallpaper: "Hintergrund festlegen"
|
||||||
|
@ -206,6 +213,7 @@ perDay: "Pro Tag"
|
||||||
stopActivityDelivery: "Senden von Aktivitäten einstellen"
|
stopActivityDelivery: "Senden von Aktivitäten einstellen"
|
||||||
blockThisInstance: "Diese Instanz blockieren"
|
blockThisInstance: "Diese Instanz blockieren"
|
||||||
silenceThisInstance: "Instanz stummschalten"
|
silenceThisInstance: "Instanz stummschalten"
|
||||||
|
mediaSilenceThisInstance: "Medien dieses Servers stummschalten"
|
||||||
operations: "Aktionen"
|
operations: "Aktionen"
|
||||||
software: "Software"
|
software: "Software"
|
||||||
version: "Version"
|
version: "Version"
|
||||||
|
@ -227,6 +235,8 @@ blockedInstances: "Blockierte Instanzen"
|
||||||
blockedInstancesDescription: "Gib die Hostnamen der Instanzen, welche blockiert werden sollen, durch Zeilenumbrüche getrennt an. Blockierte Instanzen können mit dieser instanz nicht mehr kommunizieren."
|
blockedInstancesDescription: "Gib die Hostnamen der Instanzen, welche blockiert werden sollen, durch Zeilenumbrüche getrennt an. Blockierte Instanzen können mit dieser instanz nicht mehr kommunizieren."
|
||||||
silencedInstances: "Stummgeschaltete Instanzen"
|
silencedInstances: "Stummgeschaltete Instanzen"
|
||||||
silencedInstancesDescription: "Gib die Hostnamen der Instanzen, welche stummgeschaltet werden sollen, durch Zeilenumbrüche getrennt an. Alle Konten dieser Instanzen werden als stummgeschaltet behandelt, können nur noch Follow-Anfragen stellen und wenn nicht gefolgt keine lokalen Konten erwähnen. Blockierte Instanzen sind davon nicht betroffen."
|
silencedInstancesDescription: "Gib die Hostnamen der Instanzen, welche stummgeschaltet werden sollen, durch Zeilenumbrüche getrennt an. Alle Konten dieser Instanzen werden als stummgeschaltet behandelt, können nur noch Follow-Anfragen stellen und wenn nicht gefolgt keine lokalen Konten erwähnen. Blockierte Instanzen sind davon nicht betroffen."
|
||||||
|
mediaSilencedInstances: "Medien-stummgeschaltete Server"
|
||||||
|
mediaSilencedInstancesDescription: "Gib pro Zeile die Hostnamen der Server ein, dessen Medien du stummschalten möchtest. Alle Benutzerkonten der aufgeführten Server werden als sensibel behandelt und können keine benutzerdefinierten Emojis verwenden. Gesperrte Server sind davon nicht betroffen."
|
||||||
muteAndBlock: "Stummschaltungen und Blockierungen"
|
muteAndBlock: "Stummschaltungen und Blockierungen"
|
||||||
mutedUsers: "Stummgeschaltete Benutzer"
|
mutedUsers: "Stummgeschaltete Benutzer"
|
||||||
blockedUsers: "Blockierte Benutzer"
|
blockedUsers: "Blockierte Benutzer"
|
||||||
|
@ -325,6 +335,7 @@ renameFolder: "Ordner umbenennen"
|
||||||
deleteFolder: "Ordner löschen"
|
deleteFolder: "Ordner löschen"
|
||||||
folder: "Ordner"
|
folder: "Ordner"
|
||||||
addFile: "Datei hinzufügen"
|
addFile: "Datei hinzufügen"
|
||||||
|
showFile: "Datei anzeigen"
|
||||||
emptyDrive: "Deine Drive ist leer"
|
emptyDrive: "Deine Drive ist leer"
|
||||||
emptyFolder: "Dieser Ordner ist leer"
|
emptyFolder: "Dieser Ordner ist leer"
|
||||||
unableToDelete: "Nicht löschbar"
|
unableToDelete: "Nicht löschbar"
|
||||||
|
@ -367,7 +378,6 @@ enableLocalTimeline: "Lokale Chronik aktivieren"
|
||||||
enableGlobalTimeline: "Globale Chronik aktivieren"
|
enableGlobalTimeline: "Globale Chronik aktivieren"
|
||||||
disablingTimelinesInfo: "Administratoren und Moderatoren haben immer Zugriff auf alle Chroniken, auch wenn diese deaktiviert sind."
|
disablingTimelinesInfo: "Administratoren und Moderatoren haben immer Zugriff auf alle Chroniken, auch wenn diese deaktiviert sind."
|
||||||
registration: "Registrieren"
|
registration: "Registrieren"
|
||||||
enableRegistration: "Registrierung neuer Benutzer erlauben"
|
|
||||||
invite: "Einladen"
|
invite: "Einladen"
|
||||||
driveCapacityPerLocalAccount: "Drive-Kapazität pro lokalem Benutzerkonto"
|
driveCapacityPerLocalAccount: "Drive-Kapazität pro lokalem Benutzerkonto"
|
||||||
driveCapacityPerRemoteAccount: "Drive-Kapazität pro Benutzer fremder Instanzen"
|
driveCapacityPerRemoteAccount: "Drive-Kapazität pro Benutzer fremder Instanzen"
|
||||||
|
@ -473,6 +483,7 @@ retype: "Erneut eingeben"
|
||||||
noteOf: "Notiz von {user}"
|
noteOf: "Notiz von {user}"
|
||||||
quoteAttached: "Zitat"
|
quoteAttached: "Zitat"
|
||||||
quoteQuestion: "Als Zitat anhängen?"
|
quoteQuestion: "Als Zitat anhängen?"
|
||||||
|
attachAsFileQuestion: "Der Text in der Zwischenablage ist lang. Möchtest du ihn als Textdatei anhängen?"
|
||||||
noMessagesYet: "Noch keine Nachrichten vorhanden"
|
noMessagesYet: "Noch keine Nachrichten vorhanden"
|
||||||
newMessageExists: "Du hast eine neue Nachricht"
|
newMessageExists: "Du hast eine neue Nachricht"
|
||||||
onlyOneFileCanBeAttached: "Es kann pro Nachricht nur eine Datei angehängt werden"
|
onlyOneFileCanBeAttached: "Es kann pro Nachricht nur eine Datei angehängt werden"
|
||||||
|
@ -498,7 +509,11 @@ uiLanguage: "Sprache der Benutzeroberfläche"
|
||||||
aboutX: "Über {x}"
|
aboutX: "Über {x}"
|
||||||
emojiStyle: "Emoji-Stil"
|
emojiStyle: "Emoji-Stil"
|
||||||
native: "Nativ"
|
native: "Nativ"
|
||||||
|
menuStyle: "Menü Stil"
|
||||||
|
style: "Stil"
|
||||||
|
popup: "Pop-up"
|
||||||
showNoteActionsOnlyHover: "Notizmenü nur bei Mouseover anzeigen"
|
showNoteActionsOnlyHover: "Notizmenü nur bei Mouseover anzeigen"
|
||||||
|
showReactionsCount: "Zeige die Anzahl der Reaktionen auf Notizen an"
|
||||||
noHistory: "Kein Verlauf gefunden"
|
noHistory: "Kein Verlauf gefunden"
|
||||||
signinHistory: "Anmeldungsverlauf"
|
signinHistory: "Anmeldungsverlauf"
|
||||||
enableAdvancedMfm: "Erweitertes MFM aktivieren"
|
enableAdvancedMfm: "Erweitertes MFM aktivieren"
|
||||||
|
@ -579,6 +594,7 @@ ascendingOrder: "Aufsteigende Reihenfolge"
|
||||||
descendingOrder: "Absteigende Reihenfolge"
|
descendingOrder: "Absteigende Reihenfolge"
|
||||||
scratchpad: "Testumgebung"
|
scratchpad: "Testumgebung"
|
||||||
scratchpadDescription: "Die Testumgebung bietet einen Bereich für AiScript-Experimente. Dort kannst du AiScript schreiben, ausführen sowie dessen Auswirkungen auf Misskey überprüfen."
|
scratchpadDescription: "Die Testumgebung bietet einen Bereich für AiScript-Experimente. Dort kannst du AiScript schreiben, ausführen sowie dessen Auswirkungen auf Misskey überprüfen."
|
||||||
|
uiInspector: "UI-Inspektor"
|
||||||
output: "Ausgabe"
|
output: "Ausgabe"
|
||||||
script: "Skript"
|
script: "Skript"
|
||||||
disablePagesScript: "AiScript auf Seiten deaktivieren"
|
disablePagesScript: "AiScript auf Seiten deaktivieren"
|
||||||
|
@ -659,6 +675,7 @@ smtpSecure: "Für SMTP-Verbindungen implizit SSL/TLS verwenden"
|
||||||
smtpSecureInfo: "Schalte dies aus, falls du STARTTLS verwendest."
|
smtpSecureInfo: "Schalte dies aus, falls du STARTTLS verwendest."
|
||||||
testEmail: "Emailversand testen"
|
testEmail: "Emailversand testen"
|
||||||
wordMute: "Wortstummschaltung"
|
wordMute: "Wortstummschaltung"
|
||||||
|
hardWordMute: "Harte Wort-Stummschaltung"
|
||||||
regexpError: "Fehler in einem regulären Ausdruck"
|
regexpError: "Fehler in einem regulären Ausdruck"
|
||||||
regexpErrorDescription: "Im regulären Ausdruck deiner in Zeile {line} von {tab}en Wortstummschaltungen ist ein Fehler aufgetreten:"
|
regexpErrorDescription: "Im regulären Ausdruck deiner in Zeile {line} von {tab}en Wortstummschaltungen ist ein Fehler aufgetreten:"
|
||||||
instanceMute: "Instanzstummschaltungen"
|
instanceMute: "Instanzstummschaltungen"
|
||||||
|
@ -680,6 +697,7 @@ useGlobalSettingDesc: "Ist diese Option aktiviert, werden die Benachrichtigungse
|
||||||
other: "Anderes"
|
other: "Anderes"
|
||||||
regenerateLoginToken: "Anmeldetoken regenerieren"
|
regenerateLoginToken: "Anmeldetoken regenerieren"
|
||||||
regenerateLoginTokenDescription: "Den zur Anmeldung intern verwendeten Token regenerieren. Normalerweise wird dies nicht benötigt. Bei Regeneration werden alle Geräte ausgeloggt."
|
regenerateLoginTokenDescription: "Den zur Anmeldung intern verwendeten Token regenerieren. Normalerweise wird dies nicht benötigt. Bei Regeneration werden alle Geräte ausgeloggt."
|
||||||
|
theKeywordWhenSearchingForCustomEmoji: "Das ist das Schlagwort beim Suchen von benutzerdefinierten Emojis."
|
||||||
setMultipleBySeparatingWithSpace: "Trenne Elemente durch ein Leerzeichen um mehrere Einstellungen zu kofigurieren."
|
setMultipleBySeparatingWithSpace: "Trenne Elemente durch ein Leerzeichen um mehrere Einstellungen zu kofigurieren."
|
||||||
fileIdOrUrl: "Datei-ID oder URL"
|
fileIdOrUrl: "Datei-ID oder URL"
|
||||||
behavior: "Verhalten"
|
behavior: "Verhalten"
|
||||||
|
@ -889,6 +907,8 @@ makeReactionsPublicDescription: "Jeder wird die Liste deiner gesendeten Reaktion
|
||||||
classic: "Classic"
|
classic: "Classic"
|
||||||
muteThread: "Thread stummschalten"
|
muteThread: "Thread stummschalten"
|
||||||
unmuteThread: "Threadstummschaltung aufheben"
|
unmuteThread: "Threadstummschaltung aufheben"
|
||||||
|
followingVisibility: "Sichtbarkeit der Gefolgten"
|
||||||
|
followersVisibility: "Sichtbarkeit der Folgenden"
|
||||||
continueThread: "Weiteren Threadverlauf anzeigen"
|
continueThread: "Weiteren Threadverlauf anzeigen"
|
||||||
deleteAccountConfirm: "Dein Benutzerkonto wird unwiderruflich gelöscht. Trotzdem fortfahren?"
|
deleteAccountConfirm: "Dein Benutzerkonto wird unwiderruflich gelöscht. Trotzdem fortfahren?"
|
||||||
incorrectPassword: "Falsches Passwort."
|
incorrectPassword: "Falsches Passwort."
|
||||||
|
@ -1021,6 +1041,7 @@ thisPostMayBeAnnoyingHome: "Zur Startseite schicken"
|
||||||
thisPostMayBeAnnoyingCancel: "Abbrechen"
|
thisPostMayBeAnnoyingCancel: "Abbrechen"
|
||||||
thisPostMayBeAnnoyingIgnore: "Trotzdem schicken"
|
thisPostMayBeAnnoyingIgnore: "Trotzdem schicken"
|
||||||
collapseRenotes: "Bereits gesehene Renotes verkürzt anzeigen"
|
collapseRenotes: "Bereits gesehene Renotes verkürzt anzeigen"
|
||||||
|
collapseRenotesDescription: "Klappe Notizen ein, auf die du bereits reagiert oder die du renoted hast."
|
||||||
internalServerError: "Serverinterner Fehler"
|
internalServerError: "Serverinterner Fehler"
|
||||||
internalServerErrorDescription: "Im Server ist ein unerwarteter Fehler aufgetreten."
|
internalServerErrorDescription: "Im Server ist ein unerwarteter Fehler aufgetreten."
|
||||||
copyErrorInfo: "Fehlerdetails kopieren"
|
copyErrorInfo: "Fehlerdetails kopieren"
|
||||||
|
@ -1045,6 +1066,7 @@ sensitiveWords: "Sensible Wörter"
|
||||||
sensitiveWordsDescription: "Die Notizsichtbarkeit aller Notizen, die diese Wörter enthalten, wird automatisch auf \"Startseite\" gesetzt. Durch Zeilenumbrüche können mehrere konfiguriert werden."
|
sensitiveWordsDescription: "Die Notizsichtbarkeit aller Notizen, die diese Wörter enthalten, wird automatisch auf \"Startseite\" gesetzt. Durch Zeilenumbrüche können mehrere konfiguriert werden."
|
||||||
sensitiveWordsDescription2: "Durch die Verwendung von Leerzeichen können AND-Verknüpfungen angegeben werden und durch das Umgeben von Schrägstrichen können reguläre Ausdrücke verwendet werden."
|
sensitiveWordsDescription2: "Durch die Verwendung von Leerzeichen können AND-Verknüpfungen angegeben werden und durch das Umgeben von Schrägstrichen können reguläre Ausdrücke verwendet werden."
|
||||||
prohibitedWords: "Verbotene Wörter"
|
prohibitedWords: "Verbotene Wörter"
|
||||||
|
prohibitedWordsDescription: "Aktiviert eine Fehlermeldung, wenn versucht wird, eine Notiz zu veröffentlichen, die das/die eingestellte(n) Wort(e) enthält. Mehrere Begriffe können durch Zeilenumbrüche getrennt festgelegt werden."
|
||||||
prohibitedWordsDescription2: "Durch die Verwendung von Leerzeichen können AND-Verknüpfungen angegeben werden und durch das Umgeben von Schrägstrichen können reguläre Ausdrücke verwendet werden."
|
prohibitedWordsDescription2: "Durch die Verwendung von Leerzeichen können AND-Verknüpfungen angegeben werden und durch das Umgeben von Schrägstrichen können reguläre Ausdrücke verwendet werden."
|
||||||
hiddenTags: "Ausgeblendete Hashtags"
|
hiddenTags: "Ausgeblendete Hashtags"
|
||||||
hiddenTagsDescription: "Die hier eingestellten Tags werden nicht mehr in den Trends angezeigt. Mit der Umschalttaste können mehrere ausgewählt werden."
|
hiddenTagsDescription: "Die hier eingestellten Tags werden nicht mehr in den Trends angezeigt. Mit der Umschalttaste können mehrere ausgewählt werden."
|
||||||
|
@ -1170,6 +1192,9 @@ confirmShowRepliesAll: "Dies ist eine unwiderrufliche Aktion. Wirklich Antworten
|
||||||
confirmHideRepliesAll: "Dies ist eine unwiderrufliche Aktion. Wirklich Antworten von allen momentan gefolgten Benutzern nicht in der Chronik anzeigen?"
|
confirmHideRepliesAll: "Dies ist eine unwiderrufliche Aktion. Wirklich Antworten von allen momentan gefolgten Benutzern nicht in der Chronik anzeigen?"
|
||||||
externalServices: "Externe Dienste"
|
externalServices: "Externe Dienste"
|
||||||
sourceCode: "Quellcode"
|
sourceCode: "Quellcode"
|
||||||
|
sourceCodeIsNotYetProvided: "Der Quellcode ist noch nicht verfügbar. Kontaktiere den Administrator, um das Problem zu lösen."
|
||||||
|
repositoryUrl: "Repository URL"
|
||||||
|
repositoryUrlOrTarballRequired: "Wenn du kein Repository veröffentlicht hast, musst du stattdessen einen Tarball bereitstellen. Siehe .config/example.yml für weitere Informationen."
|
||||||
impressum: "Impressum"
|
impressum: "Impressum"
|
||||||
impressumUrl: "Impressums-URL"
|
impressumUrl: "Impressums-URL"
|
||||||
impressumDescription: "In manchen Ländern, wie Deutschland und dessen Umgebung, ist die Angabe von Betreiberinformationen (ein Impressum) bei kommerziellem Betrieb zwingend."
|
impressumDescription: "In manchen Ländern, wie Deutschland und dessen Umgebung, ist die Angabe von Betreiberinformationen (ein Impressum) bei kommerziellem Betrieb zwingend."
|
||||||
|
@ -1192,34 +1217,81 @@ cwNotationRequired: "Ist \"Inhaltswarnung verwenden\" aktiviert, muss eine Besch
|
||||||
doReaction: "Reagieren"
|
doReaction: "Reagieren"
|
||||||
code: "Code"
|
code: "Code"
|
||||||
remainingN: "Verbleibend: {n}"
|
remainingN: "Verbleibend: {n}"
|
||||||
|
overwriteContentConfirm: "Bist du sicher, dass du den aktuellen Inhalt überschreiben willst?"
|
||||||
|
seasonalScreenEffect: "Saisonaler Bildschirmeffekt"
|
||||||
decorate: "Dekorieren"
|
decorate: "Dekorieren"
|
||||||
addMfmFunction: "MFM hinzufügen"
|
addMfmFunction: "MFM hinzufügen"
|
||||||
|
enableQuickAddMfmFunction: "Erweiterte MFM-Auswahl anzeigen"
|
||||||
sfx: "Soundeffekte"
|
sfx: "Soundeffekte"
|
||||||
|
soundWillBePlayed: "Es wird Ton wiedergegeben"
|
||||||
showReplay: "Wiederholung anzeigen"
|
showReplay: "Wiederholung anzeigen"
|
||||||
|
ranking: "Rangliste"
|
||||||
lastNDays: "Letzten {n} Tage"
|
lastNDays: "Letzten {n} Tage"
|
||||||
|
backToTitle: "Zurück zum Startbildschirm"
|
||||||
|
enableHorizontalSwipe: "Wischen, um zwischen Tabs zu wechseln"
|
||||||
|
loading: "Laden"
|
||||||
surrender: "Abbrechen"
|
surrender: "Abbrechen"
|
||||||
|
gameRetry: "Erneut versuchen"
|
||||||
|
notUsePleaseLeaveBlank: "Leer lassen, wenn nicht verwendet"
|
||||||
|
useTotp: "Gib das Einmalpasswort ein"
|
||||||
|
useBackupCode: "Verwende die Backup-Codes"
|
||||||
|
launchApp: "Starte die App"
|
||||||
|
useNativeUIForVideoAudioPlayer: "Browser-Benutzeroberfläche für die Video- und Audiowiedergabe verwenden"
|
||||||
keepOriginalFilename: "Ursprünglichen Dateinamen beibehalten"
|
keepOriginalFilename: "Ursprünglichen Dateinamen beibehalten"
|
||||||
|
keepOriginalFilenameDescription: "Wenn diese Einstellung deaktiviert ist, wird der Dateiname beim Hochladen automatisch durch eine zufällige Zeichenfolge ersetzt."
|
||||||
|
noDescription: "Keine Beschreibung vorhanden"
|
||||||
tryAgain: "Bitte später erneut versuchen"
|
tryAgain: "Bitte später erneut versuchen"
|
||||||
confirmWhenRevealingSensitiveMedia: "Das Anzeigen von sensiblen Medien bestätigen"
|
confirmWhenRevealingSensitiveMedia: "Das Anzeigen von sensiblen Medien bestätigen"
|
||||||
|
sensitiveMediaRevealConfirm: "Es könnte sich um sensible Medien handeln. Möchtest du sie anzeigen?"
|
||||||
createdLists: "Erstellte Listen"
|
createdLists: "Erstellte Listen"
|
||||||
createdAntennas: "Erstellte Antennen"
|
createdAntennas: "Erstellte Antennen"
|
||||||
|
fromX: "Von {x}"
|
||||||
genEmbedCode: "Einbettungscode generieren"
|
genEmbedCode: "Einbettungscode generieren"
|
||||||
noteOfThisUser: "Notizen dieses Benutzers"
|
noteOfThisUser: "Notizen dieses Benutzers"
|
||||||
clipNoteLimitExceeded: "Zu diesem Clip können keine weiteren Notizen hinzugefügt werden."
|
clipNoteLimitExceeded: "Zu diesem Clip können keine weiteren Notizen hinzugefügt werden."
|
||||||
discard: "Verwerfen"
|
discard: "Verwerfen"
|
||||||
|
thereAreNChanges: "Es gibt {n} Änderung(en)"
|
||||||
signinWithPasskey: "Mit Passkey anmelden"
|
signinWithPasskey: "Mit Passkey anmelden"
|
||||||
passkeyVerificationFailed: "Die Passkey-Verifizierung ist fehlgeschlagen."
|
passkeyVerificationFailed: "Die Passkey-Verifizierung ist fehlgeschlagen."
|
||||||
passkeyVerificationSucceededButPasswordlessLoginDisabled: "Die Verifizierung des Passkeys war erfolgreich, aber die passwortlose Anmeldung ist deaktiviert."
|
passkeyVerificationSucceededButPasswordlessLoginDisabled: "Die Verifizierung des Passkeys war erfolgreich, aber die passwortlose Anmeldung ist deaktiviert."
|
||||||
|
messageToFollower: "Nachricht an die Follower"
|
||||||
|
testCaptchaWarning: "Diese Funktion ist für CAPTCHA-Testzwecke gedacht.\n<strong>Nicht in einer Produktivumgebung verwenden.</strong>"
|
||||||
|
prohibitedWordsForNameOfUser: "Verbotene Begriffe für Benutzernamen"
|
||||||
|
prohibitedWordsForNameOfUserDescription: "Wenn eine Zeichenfolge aus dieser Liste im Namen eines Benutzers enthalten ist, wird der Benutzername abgelehnt. Benutzer mit Moderatorenrechten sind von dieser Einschränkung nicht betroffen."
|
||||||
|
yourNameContainsProhibitedWords: "Dein Name enthält einen verbotenen Begriff"
|
||||||
|
yourNameContainsProhibitedWordsDescription: "Der Name enthält eine verbotene Zeichenfolge. Wende dich an deinen Serveradministrator, wenn du diesen Namen verwenden möchtest."
|
||||||
pleaseSelectAccount: "Bitte Konto auswählen"
|
pleaseSelectAccount: "Bitte Konto auswählen"
|
||||||
availableRoles: "Verfügbare Rollen"
|
availableRoles: "Verfügbare Rollen"
|
||||||
|
_accountSettings:
|
||||||
|
requireSigninToViewContents: "Anmeldung erfordern, um Inhalte anzuzeigen"
|
||||||
|
requireSigninToViewContentsDescription1: "Erfordere eine Anmeldung, um alle Notizen und andere Inhalte anzuzeigen, die du erstellt hast. Dadurch wird verhindert, dass Crawler deine Informationen sammeln."
|
||||||
|
requireSigninToViewContentsDescription3: "Diese Einschränkungen gelten möglicherweise nicht für föderierte Inhalte von anderen Servern."
|
||||||
|
makeNotesFollowersOnlyBefore: "Macht frühere Notizen nur für Follower sichtbar"
|
||||||
|
makeNotesHiddenBefore: "Frühere Notizen privat machen"
|
||||||
|
mayNotEffectForFederatedNotes: "Dies hat möglicherweise keine Auswirkungen auf Notizen, die an andere Server föderiert werden."
|
||||||
_abuseUserReport:
|
_abuseUserReport:
|
||||||
forward: "Weiterleiten"
|
forward: "Weiterleiten"
|
||||||
|
forwardDescription: "Leite die Meldung an einen entfernten Server als anonymes Systemkonto weiter."
|
||||||
|
accept: "Akzeptieren"
|
||||||
|
reject: "Ablehnen"
|
||||||
_delivery:
|
_delivery:
|
||||||
stop: "Gesperrt"
|
stop: "Gesperrt"
|
||||||
_type:
|
_type:
|
||||||
none: "Wird veröffentlicht"
|
none: "Wird veröffentlicht"
|
||||||
|
manuallySuspended: "Manuell gesperrt"
|
||||||
_bubbleGame:
|
_bubbleGame:
|
||||||
howToPlay: "Wie man spielt"
|
howToPlay: "Wie man spielt"
|
||||||
|
hold: "Halten"
|
||||||
|
_score:
|
||||||
|
score: "Spielstand"
|
||||||
|
scoreYen: "Verdienter Geldbetrag"
|
||||||
|
highScore: "Höchstpunktzahl"
|
||||||
|
maxChain: "Maximale Anzahl an Verkettungen"
|
||||||
|
yen: "{yen} Yen"
|
||||||
|
_howToPlay:
|
||||||
|
section1: "Passe die Position an und lasse das Objekt in das Spielfeld fallen."
|
||||||
|
section2: "Wenn sich zwei Objekte der gleichen Art berühren, verwandeln sie sich in ein anderes Objekt und du bekommst Punkte."
|
||||||
|
section3: "Das Spiel ist vorbei, wenn die Objekte aus dem Spielfeld herausragen. Versuche eine hohe Punktzahl zu erreichen, indem du die Objekte miteinander verschmelzt, ohne dass das Spielfeld überläuft!"
|
||||||
_announcement:
|
_announcement:
|
||||||
forExistingUsers: "Nur für existierende Nutzer"
|
forExistingUsers: "Nur für existierende Nutzer"
|
||||||
forExistingUsersDescription: "Ist diese Option aktiviert, wird diese Ankündigung nur Nutzern angezeigt, die zum Zeitpunkt der Ankündigung bereits registriert sind. Ist sie deaktiviert, wird sie auch Nutzern, die sich nach dessen Veröffentlichung registrieren, angezeigt."
|
forExistingUsersDescription: "Ist diese Option aktiviert, wird diese Ankündigung nur Nutzern angezeigt, die zum Zeitpunkt der Ankündigung bereits registriert sind. Ist sie deaktiviert, wird sie auch Nutzern, die sich nach dessen Veröffentlichung registrieren, angezeigt."
|
||||||
|
@ -1263,8 +1335,18 @@ _initialTutorial:
|
||||||
reply: "Klicke auf diesen Button, um auf eine Nachricht zu antworten. Es ist auch möglich, auf Antworten zu antworten und die Unterhaltung wie einen Thread fortzusetzen."
|
reply: "Klicke auf diesen Button, um auf eine Nachricht zu antworten. Es ist auch möglich, auf Antworten zu antworten und die Unterhaltung wie einen Thread fortzusetzen."
|
||||||
_reaction:
|
_reaction:
|
||||||
title: "Was sind Reaktionen?"
|
title: "Was sind Reaktionen?"
|
||||||
|
description: "Auf Notizen kann mit verschiedenen Emojis reagiert werden. Reaktionen ermöglichen es dir, Nuancen auszudrücken, die mit einem einfachen „Gefällt mir“ vielleicht nicht ausgedrückt werden können."
|
||||||
|
letsTryReacting: "Reaktionen können durch Klicken auf die Schaltfläche „+“ in der Notiz hinzugefügt werden. Versuche, auf diese Beispielnotiz zu reagieren!"
|
||||||
reactToContinue: "Füge eine Reaktion hinzu, um fortzufahren."
|
reactToContinue: "Füge eine Reaktion hinzu, um fortzufahren."
|
||||||
reactNotification: "Du erhältst Echtzeit-Benachrichtigungen, wenn jemand auf deine Notiz reagiert."
|
reactNotification: "Du erhältst Echtzeit-Benachrichtigungen, wenn jemand auf deine Notiz reagiert."
|
||||||
|
reactDone: "Du kannst eine Reaktion zurücknehmen, indem du auf den '-' Button drückst."
|
||||||
|
_timeline:
|
||||||
|
title: "So funktionieren die Chroniken"
|
||||||
|
home: "Du kannst Beiträge von den Konten sehen, denen du folgst."
|
||||||
|
local: "Du kannst Beiträge aller Benutzer auf diesem Server sehen."
|
||||||
|
social: "Notizen von der Startseite und der lokalen Chronik werden angezeigt."
|
||||||
|
global: "Du kannst Notizen von allen föderierten Servern sehen."
|
||||||
|
description2: "Du kannst jederzeit am oberen Rand des Bildschirms zwischen den jeweiligen Chroniken wechseln."
|
||||||
_postNote:
|
_postNote:
|
||||||
_visibility:
|
_visibility:
|
||||||
description: "Du kannst einschränken, wer deine Notiz sehen kann."
|
description: "Du kannst einschränken, wer deine Notiz sehen kann."
|
||||||
|
@ -1272,8 +1354,16 @@ _initialTutorial:
|
||||||
doNotSendConfidencialOnDirect1: "Sei vorsichtig, wenn du sensible Informationen verschickst!"
|
doNotSendConfidencialOnDirect1: "Sei vorsichtig, wenn du sensible Informationen verschickst!"
|
||||||
_cw:
|
_cw:
|
||||||
title: "Inhaltswarnung"
|
title: "Inhaltswarnung"
|
||||||
|
_exampleNote:
|
||||||
|
note: "Ich hatte gerade einen Donut mit Schokoladenüberzug 🍩😋"
|
||||||
|
_howToMakeAttachmentsSensitive:
|
||||||
|
tryThisFile: "Versuche, das angehängte Bild als sensibel zu markieren!"
|
||||||
|
method: "Um einen Anhang als sensibel zu kennzeichnen, klicke auf das Vorschaubild der Datei, um das Menü zu öffnen, und klicke auf „Als sensibel markieren“."
|
||||||
|
sensitiveSucceeded: "Wenn du Dateien anhängst, stelle bitte die Sensibilität entsprechend der Serverrichtlinien ein."
|
||||||
|
doItToContinue: "Markiere die angehängte Datei als sensibel, um fortzufahren."
|
||||||
_done:
|
_done:
|
||||||
title: "Du hast das Tutorial abgeschlossen! 🎉"
|
title: "Du hast das Tutorial abgeschlossen! 🎉"
|
||||||
|
description: "Die hier beschriebenen Funktionen sind nur ein kleiner Teil dessen, was Misskey zu bieten hat; um mehr darüber zu erfahren, wie du Misskey benutzen kannst, besuche bitte {link}."
|
||||||
_timelineDescription:
|
_timelineDescription:
|
||||||
local: "In der lokalen Chronik siehst du Notizen von allen Benutzern auf diesem Server."
|
local: "In der lokalen Chronik siehst du Notizen von allen Benutzern auf diesem Server."
|
||||||
global: "In der globalen Chronik siehst du Notizen von allen föderierten Servern."
|
global: "In der globalen Chronik siehst du Notizen von allen föderierten Servern."
|
||||||
|
@ -1291,6 +1381,7 @@ _serverSettings:
|
||||||
fanoutTimelineDescription: "Ist diese Option aktiviert, kann eine erhebliche Verbesserung im Abrufen von Chroniken und eine Reduzierung der Datenbankbelastung erzielt werden, im Gegenzug zu einer Steigerung in der Speichernutzung von Redis. Bei geringem Serverspeicher oder Serverinstabilität kann diese Option deaktiviert werden."
|
fanoutTimelineDescription: "Ist diese Option aktiviert, kann eine erhebliche Verbesserung im Abrufen von Chroniken und eine Reduzierung der Datenbankbelastung erzielt werden, im Gegenzug zu einer Steigerung in der Speichernutzung von Redis. Bei geringem Serverspeicher oder Serverinstabilität kann diese Option deaktiviert werden."
|
||||||
fanoutTimelineDbFallback: "Auf die Datenbank zurückfallen"
|
fanoutTimelineDbFallback: "Auf die Datenbank zurückfallen"
|
||||||
fanoutTimelineDbFallbackDescription: "Ist diese Option aktiviert, wird die Chronik auf zusätzliche Abfragen in der Datenbank zurückgreifen, wenn sich die Chronik nicht im Cache befindet. Eine Deaktivierung führt zu geringerer Serverlast, aber schränkt den Zeitraum der abrufbaren Chronik ein. "
|
fanoutTimelineDbFallbackDescription: "Ist diese Option aktiviert, wird die Chronik auf zusätzliche Abfragen in der Datenbank zurückgreifen, wenn sich die Chronik nicht im Cache befindet. Eine Deaktivierung führt zu geringerer Serverlast, aber schränkt den Zeitraum der abrufbaren Chronik ein. "
|
||||||
|
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "Wenn über einen bestimmten Zeitraum keine Moderatorenaktivität festgestellt wird, wird diese Einstellung automatisch deaktiviert, um Spam zu verhindern."
|
||||||
_accountMigration:
|
_accountMigration:
|
||||||
moveFrom: "Von einem anderen Konto zu diesem migrieren"
|
moveFrom: "Von einem anderen Konto zu diesem migrieren"
|
||||||
moveFromSub: "Alias für ein anderes Konto erstellen"
|
moveFromSub: "Alias für ein anderes Konto erstellen"
|
||||||
|
@ -1549,6 +1640,7 @@ _achievements:
|
||||||
title: "Testüberfluss"
|
title: "Testüberfluss"
|
||||||
description: "Betätige den Benachrichtigungstest mehrfach innerhalb einer extrem kurzen Zeitspanne"
|
description: "Betätige den Benachrichtigungstest mehrfach innerhalb einer extrem kurzen Zeitspanne"
|
||||||
_tutorialCompleted:
|
_tutorialCompleted:
|
||||||
|
title: "Misskey Grundkurs-Diplom"
|
||||||
description: "Tutorial abgeschlossen"
|
description: "Tutorial abgeschlossen"
|
||||||
_bubbleGameExplodingHead:
|
_bubbleGameExplodingHead:
|
||||||
title: "🤯"
|
title: "🤯"
|
||||||
|
@ -1594,6 +1686,7 @@ _role:
|
||||||
gtlAvailable: "Kann auf die globale Chronik zugreifen"
|
gtlAvailable: "Kann auf die globale Chronik zugreifen"
|
||||||
ltlAvailable: "Kann auf die lokale Chronik zugreifen"
|
ltlAvailable: "Kann auf die lokale Chronik zugreifen"
|
||||||
canPublicNote: "Kann öffentliche Notizen erstellen"
|
canPublicNote: "Kann öffentliche Notizen erstellen"
|
||||||
|
mentionMax: "Maximale Anzahl von Erwähnungen in einer Notiz"
|
||||||
canInvite: "Erstellung von Einladungscodes für diese Instanz"
|
canInvite: "Erstellung von Einladungscodes für diese Instanz"
|
||||||
inviteLimit: "Maximalanzahl an Einladungen"
|
inviteLimit: "Maximalanzahl an Einladungen"
|
||||||
inviteLimitCycle: "Zyklus des Einladungslimits"
|
inviteLimitCycle: "Zyklus des Einladungslimits"
|
||||||
|
@ -1616,9 +1709,12 @@ _role:
|
||||||
canSearchNotes: "Nutzung der Notizsuchfunktion"
|
canSearchNotes: "Nutzung der Notizsuchfunktion"
|
||||||
canUseTranslator: "Verwendung des Übersetzers"
|
canUseTranslator: "Verwendung des Übersetzers"
|
||||||
avatarDecorationLimit: "Maximale Anzahl an Profilbilddekorationen, die angebracht werden können"
|
avatarDecorationLimit: "Maximale Anzahl an Profilbilddekorationen, die angebracht werden können"
|
||||||
|
canImportAntennas: "Importieren von Antennen erlauben"
|
||||||
_condition:
|
_condition:
|
||||||
isLocal: "Lokaler Benutzer"
|
isLocal: "Lokaler Benutzer"
|
||||||
isRemote: "Benutzer fremder Instanz"
|
isRemote: "Benutzer fremder Instanz"
|
||||||
|
isCat: "Katzen-Benutzer"
|
||||||
|
isBot: "Bot-Benutzer"
|
||||||
createdLessThan: "Kontoerstellung liegt weniger als X zurück"
|
createdLessThan: "Kontoerstellung liegt weniger als X zurück"
|
||||||
createdMoreThan: "Kontoerstellung liegt mehr als X zurück"
|
createdMoreThan: "Kontoerstellung liegt mehr als X zurück"
|
||||||
followersLessThanOrEq: "Hat X oder weniger Follower"
|
followersLessThanOrEq: "Hat X oder weniger Follower"
|
||||||
|
@ -1834,6 +1930,12 @@ _sfx:
|
||||||
note: "Notizen"
|
note: "Notizen"
|
||||||
noteMy: "Meine Notizen"
|
noteMy: "Meine Notizen"
|
||||||
notification: "Benachrichtigungen"
|
notification: "Benachrichtigungen"
|
||||||
|
_soundSettings:
|
||||||
|
driveFile: "Audiodatei aus dem Drive verwenden"
|
||||||
|
driveFileWarn: "Wähle eine Audiodatei aus dem Drive"
|
||||||
|
driveFileTypeWarn: "Diese Datei wird nicht unterstützt"
|
||||||
|
driveFileTypeWarnDescription: "Bitte wähle eine Audiodatei"
|
||||||
|
driveFileDurationWarn: "Audio zu lang."
|
||||||
_ago:
|
_ago:
|
||||||
future: "Zukunft"
|
future: "Zukunft"
|
||||||
justNow: "Gerade eben"
|
justNow: "Gerade eben"
|
||||||
|
@ -1915,6 +2017,23 @@ _permissions:
|
||||||
"write:flash": "Deine Plays bearbeiten oder löschen"
|
"write:flash": "Deine Plays bearbeiten oder löschen"
|
||||||
"read:flash-likes": "Liste der Plays, die mir gefallen, lesen"
|
"read:flash-likes": "Liste der Plays, die mir gefallen, lesen"
|
||||||
"write:flash-likes": "Liste der Plays, die mir gefallen, bearbeiten"
|
"write:flash-likes": "Liste der Plays, die mir gefallen, bearbeiten"
|
||||||
|
"write:admin:delete-account": "Benutzerkonto löschen"
|
||||||
|
"write:admin:delete-all-files-of-a-user": "Alle Dateien eines Benutzers löschen"
|
||||||
|
"read:admin:index-stats": "Statistiken zu Datenbankindizes einsehen"
|
||||||
|
"read:admin:table-stats": "Statistiken zu Datenbanktabellen einsehen"
|
||||||
|
"read:admin:user-ips": "IP-Adressen von Benutzern anzeigen"
|
||||||
|
"read:admin:meta": "Metadaten der Instanz einsehen"
|
||||||
|
"write:admin:reset-password": "Benutzerpasswort zurücksetzen"
|
||||||
|
"write:admin:send-email": "E-Mail versenden"
|
||||||
|
"read:admin:server-info": "Serverinformationen anzeigen"
|
||||||
|
"read:admin:show-moderation-log": "Moderationsprotokoll einsehen"
|
||||||
|
"read:admin:show-user": "Private Benutzerinformationen einsehen"
|
||||||
|
"write:admin:invite-codes": "Einladungscodes verwalten"
|
||||||
|
"read:admin:invite-codes": "Einladungscodes anzeigen"
|
||||||
|
"write:admin:announcements": "Ankündigungen verwalten"
|
||||||
|
"read:admin:announcements": "Ankündigungen einsehen"
|
||||||
|
"write:admin:avatar-decorations": "Kann Avatar-Dekorationen verwalten"
|
||||||
|
"read:admin:avatar-decorations": "Avatar-Dekorationen ansehen"
|
||||||
_auth:
|
_auth:
|
||||||
shareAccessTitle: "Verteilung von App-Berechtigungen"
|
shareAccessTitle: "Verteilung von App-Berechtigungen"
|
||||||
shareAccess: "Möchtest du „{name}“ authorisieren, auf dieses Benutzerkonto zugreifen zu können?"
|
shareAccess: "Möchtest du „{name}“ authorisieren, auf dieses Benutzerkonto zugreifen zu können?"
|
||||||
|
@ -2329,3 +2448,31 @@ _reversi:
|
||||||
black: "Schwarz"
|
black: "Schwarz"
|
||||||
white: "Weiß"
|
white: "Weiß"
|
||||||
total: "Gesamt"
|
total: "Gesamt"
|
||||||
|
_offlineScreen:
|
||||||
|
header: "Verbindung zum Server nicht möglich"
|
||||||
|
_urlPreviewSetting:
|
||||||
|
title: "Einstellungen der URL-Vorschau"
|
||||||
|
enable: "URL-Vorschau aktivieren"
|
||||||
|
timeout: "Zeitüberschreitung beim Abrufen der Vorschau (ms)"
|
||||||
|
maximumContentLength: "Maximale Content-Length (Bytes)"
|
||||||
|
_mediaControls:
|
||||||
|
playbackRate: "Wiedergabegeschwindigkeit"
|
||||||
|
_contextMenu:
|
||||||
|
title: "Kontextmenü"
|
||||||
|
app: "Anwendung"
|
||||||
|
_embedCodeGen:
|
||||||
|
title: "Einbettungscode anpassen"
|
||||||
|
header: "Kopfzeile anzeigen"
|
||||||
|
autoload: "Automatisch mehr laden (veraltet)"
|
||||||
|
maxHeight: "Maximale Höhe"
|
||||||
|
maxHeightDescription: "Der Wert 0 deaktiviert die Einstellung der maximalen Höhe. Gib einen Wert an, um zu verhindern, dass das Widget weiterhin vertikal vergrößert wird."
|
||||||
|
maxHeightWarn: "Die Begrenzung der maximalen Höhe ist deaktiviert (0). Wenn dies nicht beabsichtigt war, setze die maximale Höhe auf einen Wert fest."
|
||||||
|
applyToPreview: "Auf die Vorschau anwenden"
|
||||||
|
generateCode: "Einbettungscode generieren"
|
||||||
|
codeGenerated: "Der Code wurde generiert"
|
||||||
|
codeGeneratedDescription: "Füge den generierten Code in deine Website ein, um den Inhalt einzubetten."
|
||||||
|
_selfXssPrevention:
|
||||||
|
warning: "WARNUNG"
|
||||||
|
title: "„Füge in diesen Bereich etwas ein“ ist eine Betrugsmasche."
|
||||||
|
description1: "Wenn du hier etwas einfügst, könnte ein böswilliger Benutzer dein Konto übernehmen oder deine persönlichen Daten stehlen."
|
||||||
|
description3: "Weitere Informationen findest du hier. {link}"
|
||||||
|
|
|
@ -382,7 +382,6 @@ enableLocalTimeline: "Enable local timeline"
|
||||||
enableGlobalTimeline: "Enable global timeline"
|
enableGlobalTimeline: "Enable global timeline"
|
||||||
disablingTimelinesInfo: "Adminstrators and Moderators will always have access to all timelines, even if they are not enabled."
|
disablingTimelinesInfo: "Adminstrators and Moderators will always have access to all timelines, even if they are not enabled."
|
||||||
registration: "Register"
|
registration: "Register"
|
||||||
enableRegistration: "Enable new user registration"
|
|
||||||
invite: "Invite"
|
invite: "Invite"
|
||||||
driveCapacityPerLocalAccount: "Drive capacity per local user"
|
driveCapacityPerLocalAccount: "Drive capacity per local user"
|
||||||
driveCapacityPerRemoteAccount: "Drive capacity per remote user"
|
driveCapacityPerRemoteAccount: "Drive capacity per remote user"
|
||||||
|
@ -587,6 +586,7 @@ masterVolume: "Master volume"
|
||||||
notUseSound: "Disable sound"
|
notUseSound: "Disable sound"
|
||||||
useSoundOnlyWhenActive: "Output sounds only if Misskey is active."
|
useSoundOnlyWhenActive: "Output sounds only if Misskey is active."
|
||||||
details: "Details"
|
details: "Details"
|
||||||
|
renoteDetails: "Renote details"
|
||||||
chooseEmoji: "Select an emoji"
|
chooseEmoji: "Select an emoji"
|
||||||
unableToProcess: "The operation could not be completed"
|
unableToProcess: "The operation could not be completed"
|
||||||
recentUsed: "Recently used"
|
recentUsed: "Recently used"
|
||||||
|
@ -1309,7 +1309,7 @@ _accountSettings:
|
||||||
makeNotesFollowersOnlyBeforeDescription: "While this feature is enabled, only followers can see notes past the set date and time or have been visible for a set time. When it is deactivated, the note publication status will also be restored."
|
makeNotesFollowersOnlyBeforeDescription: "While this feature is enabled, only followers can see notes past the set date and time or have been visible for a set time. When it is deactivated, the note publication status will also be restored."
|
||||||
makeNotesHiddenBefore: "Make past notes private"
|
makeNotesHiddenBefore: "Make past notes private"
|
||||||
makeNotesHiddenBeforeDescription: "While this feature is enabled, notes that are past the set date and time or have been visible only to you. When it is deactivated, the note publication status will also be restored."
|
makeNotesHiddenBeforeDescription: "While this feature is enabled, notes that are past the set date and time or have been visible only to you. When it is deactivated, the note publication status will also be restored."
|
||||||
mayNotEffectForFederatedNotes: "Notes federated to a remote server may not be effective."
|
mayNotEffectForFederatedNotes: "Notes federated to a remote server may not be affected."
|
||||||
notesHavePassedSpecifiedPeriod: "Note that the specified time has passed"
|
notesHavePassedSpecifiedPeriod: "Note that the specified time has passed"
|
||||||
notesOlderThanSpecifiedDateAndTime: "Notes before the specified date and time"
|
notesOlderThanSpecifiedDateAndTime: "Notes before the specified date and time"
|
||||||
_abuseUserReport:
|
_abuseUserReport:
|
||||||
|
|
|
@ -373,7 +373,6 @@ enableLocalTimeline: "Habilitar linea de tiempo local"
|
||||||
enableGlobalTimeline: "Habilitar linea de tiempo global"
|
enableGlobalTimeline: "Habilitar linea de tiempo global"
|
||||||
disablingTimelinesInfo: "Aunque se desactiven estas lineas de tiempo, por conveniencia el administrador y los moderadores pueden seguir usándolos"
|
disablingTimelinesInfo: "Aunque se desactiven estas lineas de tiempo, por conveniencia el administrador y los moderadores pueden seguir usándolos"
|
||||||
registration: "Registro"
|
registration: "Registro"
|
||||||
enableRegistration: "Permitir nuevos registros"
|
|
||||||
invite: "Invitar"
|
invite: "Invitar"
|
||||||
driveCapacityPerLocalAccount: "Capacidad del drive por usuario local"
|
driveCapacityPerLocalAccount: "Capacidad del drive por usuario local"
|
||||||
driveCapacityPerRemoteAccount: "Capacidad del drive por usuario remoto"
|
driveCapacityPerRemoteAccount: "Capacidad del drive por usuario remoto"
|
||||||
|
|
|
@ -8,6 +8,9 @@ search: "Rechercher"
|
||||||
notifications: "Notifications"
|
notifications: "Notifications"
|
||||||
username: "Nom d’utilisateur·rice"
|
username: "Nom d’utilisateur·rice"
|
||||||
password: "Mot de passe"
|
password: "Mot de passe"
|
||||||
|
initialPasswordForSetup: "Mot de passe initial pour la configuration"
|
||||||
|
initialPasswordIsIncorrect: "Mot de passe initial pour la configuration est incorrecte"
|
||||||
|
initialPasswordForSetupDescription: "Utilisez le mot de passe que vous avez entré pour le fichier de configuration si vous avez installé Misskey vous-même.\nSi vous utilisez un service d'hébergement Misskey, utilisez le mot de passe fourni.\nSi vous n'avez pas défini de mot de passe, laissez le champ vide pour continuer."
|
||||||
forgotPassword: "Mot de passe oublié"
|
forgotPassword: "Mot de passe oublié"
|
||||||
fetchingAsApObject: "Récupération depuis le fédiverse …"
|
fetchingAsApObject: "Récupération depuis le fédiverse …"
|
||||||
ok: "OK"
|
ok: "OK"
|
||||||
|
@ -60,6 +63,7 @@ copyFileId: "Copier l'identifiant du fichier"
|
||||||
copyFolderId: "Copier l'identifiant du dossier"
|
copyFolderId: "Copier l'identifiant du dossier"
|
||||||
copyProfileUrl: "Copier l'URL du profil"
|
copyProfileUrl: "Copier l'URL du profil"
|
||||||
searchUser: "Chercher un·e utilisateur·rice"
|
searchUser: "Chercher un·e utilisateur·rice"
|
||||||
|
searchThisUsersNotes: "Cherchez les notes de cet·te utilisateur·rice"
|
||||||
reply: "Répondre"
|
reply: "Répondre"
|
||||||
loadMore: "Afficher plus …"
|
loadMore: "Afficher plus …"
|
||||||
showMore: "Voir plus"
|
showMore: "Voir plus"
|
||||||
|
@ -108,6 +112,7 @@ enterEmoji: "Insérer un émoji"
|
||||||
renote: "Renoter"
|
renote: "Renoter"
|
||||||
unrenote: "Annuler la Renote"
|
unrenote: "Annuler la Renote"
|
||||||
renoted: "Renoté !"
|
renoted: "Renoté !"
|
||||||
|
renotedToX: "Renoté en {name}"
|
||||||
cantRenote: "Ce message ne peut pas être renoté."
|
cantRenote: "Ce message ne peut pas être renoté."
|
||||||
cantReRenote: "Impossible de renoter une Renote."
|
cantReRenote: "Impossible de renoter une Renote."
|
||||||
quote: "Citer"
|
quote: "Citer"
|
||||||
|
@ -151,6 +156,7 @@ editList: "Modifier la liste"
|
||||||
selectChannel: "Sélectionner un canal"
|
selectChannel: "Sélectionner un canal"
|
||||||
selectAntenna: "Sélectionner une antenne"
|
selectAntenna: "Sélectionner une antenne"
|
||||||
editAntenna: "Modifier l'antenne"
|
editAntenna: "Modifier l'antenne"
|
||||||
|
createAntenna: "Créer une antenne"
|
||||||
selectWidget: "Sélectionner un widget"
|
selectWidget: "Sélectionner un widget"
|
||||||
editWidgets: "Modifier les widgets"
|
editWidgets: "Modifier les widgets"
|
||||||
editWidgetsExit: "Valider les modifications"
|
editWidgetsExit: "Valider les modifications"
|
||||||
|
@ -177,6 +183,7 @@ addAccount: "Ajouter un compte"
|
||||||
reloadAccountsList: "Rafraichir la liste des comptes"
|
reloadAccountsList: "Rafraichir la liste des comptes"
|
||||||
loginFailed: "Échec de la connexion"
|
loginFailed: "Échec de la connexion"
|
||||||
showOnRemote: "Voir sur l’instance distante"
|
showOnRemote: "Voir sur l’instance distante"
|
||||||
|
continueOnRemote: "Continuer sur l'instance distante"
|
||||||
general: "Général"
|
general: "Général"
|
||||||
wallpaper: "Fond d’écran"
|
wallpaper: "Fond d’écran"
|
||||||
setWallpaper: "Définir le fond d’écran"
|
setWallpaper: "Définir le fond d’écran"
|
||||||
|
@ -187,6 +194,7 @@ followConfirm: "Êtes-vous sûr·e de vouloir suivre {name} ?"
|
||||||
proxyAccount: "Compte proxy"
|
proxyAccount: "Compte proxy"
|
||||||
proxyAccountDescription: "Un compte proxy se comporte, dans certaines conditions, comme un·e abonné·e distant·e pour les utilisateurs d'autres instances. Par exemple, quand un·e utilisateur·rice ajoute un·e utilisateur·rice distant·e à une liste, ses notes ne seront pas visibles sur l'instance si personne ne suit cet·te utilisateur·rice. Le compte proxy va donc suivre cet·te utilisateur·rice pour que ses notes soient acheminées."
|
proxyAccountDescription: "Un compte proxy se comporte, dans certaines conditions, comme un·e abonné·e distant·e pour les utilisateurs d'autres instances. Par exemple, quand un·e utilisateur·rice ajoute un·e utilisateur·rice distant·e à une liste, ses notes ne seront pas visibles sur l'instance si personne ne suit cet·te utilisateur·rice. Le compte proxy va donc suivre cet·te utilisateur·rice pour que ses notes soient acheminées."
|
||||||
host: "Serveur distant"
|
host: "Serveur distant"
|
||||||
|
selectSelf: "Sélectionner manuellement"
|
||||||
selectUser: "Sélectionner un·e utilisateur·rice"
|
selectUser: "Sélectionner un·e utilisateur·rice"
|
||||||
recipient: "Destinataire"
|
recipient: "Destinataire"
|
||||||
annotation: "Commentaires"
|
annotation: "Commentaires"
|
||||||
|
@ -320,6 +328,7 @@ renameFolder: "Renommer le dossier"
|
||||||
deleteFolder: "Supprimer le dossier"
|
deleteFolder: "Supprimer le dossier"
|
||||||
folder: "Dossier"
|
folder: "Dossier"
|
||||||
addFile: "Ajouter un fichier"
|
addFile: "Ajouter un fichier"
|
||||||
|
showFile: "Voir les fichiers"
|
||||||
emptyDrive: "Le Disque est vide"
|
emptyDrive: "Le Disque est vide"
|
||||||
emptyFolder: "Le dossier est vide"
|
emptyFolder: "Le dossier est vide"
|
||||||
unableToDelete: "Suppression impossible"
|
unableToDelete: "Suppression impossible"
|
||||||
|
@ -362,7 +371,6 @@ enableLocalTimeline: "Activer le fil local"
|
||||||
enableGlobalTimeline: "Activer le fil global"
|
enableGlobalTimeline: "Activer le fil global"
|
||||||
disablingTimelinesInfo: "Même si vous désactivez ces fils, les administrateur·rice·s et les modérateur·rice·s pourront toujours y accéder."
|
disablingTimelinesInfo: "Même si vous désactivez ces fils, les administrateur·rice·s et les modérateur·rice·s pourront toujours y accéder."
|
||||||
registration: "S’inscrire"
|
registration: "S’inscrire"
|
||||||
enableRegistration: "Autoriser les nouvelles inscriptions"
|
|
||||||
invite: "Inviter"
|
invite: "Inviter"
|
||||||
driveCapacityPerLocalAccount: "Capacité de stockage du Disque par utilisateur local"
|
driveCapacityPerLocalAccount: "Capacité de stockage du Disque par utilisateur local"
|
||||||
driveCapacityPerRemoteAccount: "Capacité de stockage du Disque par utilisateur distant"
|
driveCapacityPerRemoteAccount: "Capacité de stockage du Disque par utilisateur distant"
|
||||||
|
@ -430,10 +438,11 @@ token: "Jeton"
|
||||||
2fa: "Authentification à deux facteurs"
|
2fa: "Authentification à deux facteurs"
|
||||||
setupOf2fa: "Configuration de l’authentification à deux facteurs"
|
setupOf2fa: "Configuration de l’authentification à deux facteurs"
|
||||||
totp: "Application d'authentification"
|
totp: "Application d'authentification"
|
||||||
totpDescription: "Entrez un mot de passe à usage unique à l'aide d'une application d'authentification"
|
totpDescription: "Entrer un mot de passe à usage unique à l'aide d'une application d'authentification"
|
||||||
moderator: "Modérateur·rice·s"
|
moderator: "Modérateur·rice·s"
|
||||||
moderation: "Modérations"
|
moderation: "Modérations"
|
||||||
moderationNote: "Note de modération"
|
moderationNote: "Note de modération"
|
||||||
|
moderationNoteDescription: "Vous pouvez remplir des notes qui seront partagés seulement entre modérateurs."
|
||||||
addModerationNote: "Ajouter une note de modération"
|
addModerationNote: "Ajouter une note de modération"
|
||||||
moderationLogs: "Journal de modération"
|
moderationLogs: "Journal de modération"
|
||||||
nUsersMentioned: "{n} utilisateur·rice·s mentionné·e·s"
|
nUsersMentioned: "{n} utilisateur·rice·s mentionné·e·s"
|
||||||
|
@ -493,6 +502,10 @@ uiLanguage: "Langue d’affichage de l’interface"
|
||||||
aboutX: "À propos de {x}"
|
aboutX: "À propos de {x}"
|
||||||
emojiStyle: "Style des émojis"
|
emojiStyle: "Style des émojis"
|
||||||
native: "Natif"
|
native: "Natif"
|
||||||
|
menuStyle: "Style du menu"
|
||||||
|
style: "Style"
|
||||||
|
drawer: "Sélecteur"
|
||||||
|
popup: "Pop-up"
|
||||||
showNoteActionsOnlyHover: "Afficher les actions de note uniquement au survol"
|
showNoteActionsOnlyHover: "Afficher les actions de note uniquement au survol"
|
||||||
showReactionsCount: "Afficher le nombre de réactions des notes"
|
showReactionsCount: "Afficher le nombre de réactions des notes"
|
||||||
noHistory: "Pas d'historique"
|
noHistory: "Pas d'historique"
|
||||||
|
@ -575,6 +588,7 @@ ascendingOrder: "Ascendant"
|
||||||
descendingOrder: "Descendant"
|
descendingOrder: "Descendant"
|
||||||
scratchpad: "ScratchPad"
|
scratchpad: "ScratchPad"
|
||||||
scratchpadDescription: "ScratchPad fournit un environnement expérimental pour AiScript. Vous pouvez vérifier la rédaction de votre code, sa bonne exécution et le résultat de son interaction avec Misskey."
|
scratchpadDescription: "ScratchPad fournit un environnement expérimental pour AiScript. Vous pouvez vérifier la rédaction de votre code, sa bonne exécution et le résultat de son interaction avec Misskey."
|
||||||
|
uiInspector: "Inspecteur UI"
|
||||||
output: "Sortie"
|
output: "Sortie"
|
||||||
script: "Script"
|
script: "Script"
|
||||||
disablePagesScript: "Désactiver AiScript sur les Pages"
|
disablePagesScript: "Désactiver AiScript sur les Pages"
|
||||||
|
@ -618,7 +632,7 @@ description: "Description"
|
||||||
describeFile: "Ajouter une description d'image"
|
describeFile: "Ajouter une description d'image"
|
||||||
enterFileDescription: "Saisissez une description"
|
enterFileDescription: "Saisissez une description"
|
||||||
author: "Auteur·rice"
|
author: "Auteur·rice"
|
||||||
leaveConfirm: "Vous avez des modifications non-sauvegardées. Voulez-vous les ignorer ?"
|
leaveConfirm: "Vous avez des modifications non sauvegardées. Voulez-vous les ignorer ?"
|
||||||
manage: "Gestion"
|
manage: "Gestion"
|
||||||
plugins: "Extensions"
|
plugins: "Extensions"
|
||||||
preferencesBackups: "Sauvegarder les paramètres"
|
preferencesBackups: "Sauvegarder les paramètres"
|
||||||
|
@ -828,6 +842,7 @@ administration: "Gestion"
|
||||||
accounts: "Comptes"
|
accounts: "Comptes"
|
||||||
switch: "Remplacer"
|
switch: "Remplacer"
|
||||||
noMaintainerInformationWarning: "Informations administrateur non configurées."
|
noMaintainerInformationWarning: "Informations administrateur non configurées."
|
||||||
|
noInquiryUrlWarning: "L'URL demandé n'est pas définie"
|
||||||
noBotProtectionWarning: "La protection contre les bots n'est pas configurée."
|
noBotProtectionWarning: "La protection contre les bots n'est pas configurée."
|
||||||
configure: "Configurer"
|
configure: "Configurer"
|
||||||
postToGallery: "Publier dans la galerie"
|
postToGallery: "Publier dans la galerie"
|
||||||
|
@ -892,6 +907,7 @@ followersVisibility: "Visibilité des abonnés"
|
||||||
continueThread: "Afficher la suite du fil"
|
continueThread: "Afficher la suite du fil"
|
||||||
deleteAccountConfirm: "Votre compte sera supprimé. Êtes vous certain ?"
|
deleteAccountConfirm: "Votre compte sera supprimé. Êtes vous certain ?"
|
||||||
incorrectPassword: "Le mot de passe est incorrect."
|
incorrectPassword: "Le mot de passe est incorrect."
|
||||||
|
incorrectTotp: "Le mot de passe à usage unique est incorrect ou a expiré."
|
||||||
voteConfirm: "Confirmez-vous votre vote pour « {choice} » ?"
|
voteConfirm: "Confirmez-vous votre vote pour « {choice} » ?"
|
||||||
hide: "Masquer"
|
hide: "Masquer"
|
||||||
useDrawerReactionPickerForMobile: "Afficher le sélecteur de réactions en tant que panneau sur mobile"
|
useDrawerReactionPickerForMobile: "Afficher le sélecteur de réactions en tant que panneau sur mobile"
|
||||||
|
@ -916,6 +932,9 @@ oneHour: "1 heure"
|
||||||
oneDay: "1 jour"
|
oneDay: "1 jour"
|
||||||
oneWeek: "1 semaine"
|
oneWeek: "1 semaine"
|
||||||
oneMonth: "Un mois"
|
oneMonth: "Un mois"
|
||||||
|
threeMonths: "3 mois"
|
||||||
|
oneYear: "1 an"
|
||||||
|
threeDays: "3 jours"
|
||||||
reflectMayTakeTime: "Cela peut prendre un certain temps avant que cela ne se termine."
|
reflectMayTakeTime: "Cela peut prendre un certain temps avant que cela ne se termine."
|
||||||
failedToFetchAccountInformation: "Impossible de récupérer les informations du compte."
|
failedToFetchAccountInformation: "Impossible de récupérer les informations du compte."
|
||||||
rateLimitExceeded: "Limite de taux dépassée"
|
rateLimitExceeded: "Limite de taux dépassée"
|
||||||
|
@ -923,7 +942,7 @@ cropImage: "Recadrer l'image"
|
||||||
cropImageAsk: "Voulez-vous recadrer cette image ?"
|
cropImageAsk: "Voulez-vous recadrer cette image ?"
|
||||||
cropYes: "Rogner"
|
cropYes: "Rogner"
|
||||||
cropNo: "Utiliser en l'état"
|
cropNo: "Utiliser en l'état"
|
||||||
file: "Fichiers"
|
file: "Fichier"
|
||||||
recentNHours: "Dernières {n} heures"
|
recentNHours: "Dernières {n} heures"
|
||||||
recentNDays: "Derniers {n} jours"
|
recentNDays: "Derniers {n} jours"
|
||||||
noEmailServerWarning: "Serveur de courrier non configuré."
|
noEmailServerWarning: "Serveur de courrier non configuré."
|
||||||
|
@ -1055,6 +1074,7 @@ retryAllQueuesConfirmTitle: "Vraiment réessayer ?"
|
||||||
retryAllQueuesConfirmText: "Cela peut augmenter temporairement la charge du serveur."
|
retryAllQueuesConfirmText: "Cela peut augmenter temporairement la charge du serveur."
|
||||||
enableChartsForRemoteUser: "Générer les graphiques pour les utilisateurs distants"
|
enableChartsForRemoteUser: "Générer les graphiques pour les utilisateurs distants"
|
||||||
enableChartsForFederatedInstances: "Générer les graphiques pour les instances distantes"
|
enableChartsForFederatedInstances: "Générer les graphiques pour les instances distantes"
|
||||||
|
enableStatsForFederatedInstances: "Recevoir les statistiques des instances distantes"
|
||||||
showClipButtonInNoteFooter: "Ajouter « Clip » au menu d'action de la note"
|
showClipButtonInNoteFooter: "Ajouter « Clip » au menu d'action de la note"
|
||||||
reactionsDisplaySize: "Taille de l'affichage des réactions"
|
reactionsDisplaySize: "Taille de l'affichage des réactions"
|
||||||
limitWidthOfReaction: "Limiter la largeur maximale des réactions et les afficher en taille réduite"
|
limitWidthOfReaction: "Limiter la largeur maximale des réactions et les afficher en taille réduite"
|
||||||
|
@ -1102,6 +1122,8 @@ preventAiLearning: "Refuser l'usage dans l'apprentissage automatique d'IA géné
|
||||||
preventAiLearningDescription: "Demander aux robots d'indexation de ne pas utiliser le contenu publié, tel que les notes et les images, dans l'apprentissage automatique d'IA générative. Cela est réalisé en incluant le drapeau « noai » dans la réponse HTML. Une prévention complète n'est toutefois pas possible, car il est au robot d'indexation de respecter cette demande."
|
preventAiLearningDescription: "Demander aux robots d'indexation de ne pas utiliser le contenu publié, tel que les notes et les images, dans l'apprentissage automatique d'IA générative. Cela est réalisé en incluant le drapeau « noai » dans la réponse HTML. Une prévention complète n'est toutefois pas possible, car il est au robot d'indexation de respecter cette demande."
|
||||||
options: "Options"
|
options: "Options"
|
||||||
specifyUser: "Spécifier l'utilisateur·rice"
|
specifyUser: "Spécifier l'utilisateur·rice"
|
||||||
|
openTagPageConfirm: "Ouvrir une page d'hashtags ?"
|
||||||
|
specifyHost: "Spécifier un serveur distant"
|
||||||
failedToPreviewUrl: "Aperçu d'URL échoué"
|
failedToPreviewUrl: "Aperçu d'URL échoué"
|
||||||
update: "Mettre à jour"
|
update: "Mettre à jour"
|
||||||
rolesThatCanBeUsedThisEmojiAsReaction: "Rôles qui peuvent utiliser cet émoji comme réaction"
|
rolesThatCanBeUsedThisEmojiAsReaction: "Rôles qui peuvent utiliser cet émoji comme réaction"
|
||||||
|
@ -1222,13 +1244,55 @@ enableHorizontalSwipe: "Glisser pour changer d'onglet"
|
||||||
loading: "Chargement en cours"
|
loading: "Chargement en cours"
|
||||||
surrender: "Annuler"
|
surrender: "Annuler"
|
||||||
gameRetry: "Réessayer"
|
gameRetry: "Réessayer"
|
||||||
|
notUsePleaseLeaveBlank: "Laisser vide si non utilisé"
|
||||||
|
useTotp: "Entrer un mot de passe à usage unique"
|
||||||
|
useBackupCode: "Utiliser le codes de secours"
|
||||||
launchApp: "Lancer l'app"
|
launchApp: "Lancer l'app"
|
||||||
|
useNativeUIForVideoAudioPlayer: "Lire les vidéos et audios en utilisant l'UI du navigateur"
|
||||||
|
keepOriginalFilename: "Garder le nom original du fichier"
|
||||||
|
keepOriginalFilenameDescription: "Si vous désactivez ce paramètre, les noms de fichiers seront automatiquement remplacés par des noms aléatoires lorsque vous téléchargerez des fichiers."
|
||||||
|
noDescription: "Il n'y a pas de description"
|
||||||
|
alwaysConfirmFollow: "Confirmer lors d'un abonnement"
|
||||||
inquiry: "Contact"
|
inquiry: "Contact"
|
||||||
|
tryAgain: "Veuillez réessayer plus tard"
|
||||||
|
confirmWhenRevealingSensitiveMedia: "Confirmer pour révéler du contenu sensible"
|
||||||
|
sensitiveMediaRevealConfirm: "Ceci pourrait être du contenu sensible. Voulez-vous l'afficher ?"
|
||||||
|
createdLists: "Listes créées"
|
||||||
|
createdAntennas: "Antennes créées"
|
||||||
|
fromX: "De {x}"
|
||||||
|
genEmbedCode: "Générer le code d'intégration"
|
||||||
|
noteOfThisUser: "Notes de cet·te utilisateur·rice"
|
||||||
|
clipNoteLimitExceeded: "Aucune note supplémentaire ne peut être ajoutée à ce clip."
|
||||||
|
performance: "Performance"
|
||||||
|
modified: "Modifié"
|
||||||
|
discard: "Annuler"
|
||||||
|
thereAreNChanges: "Il y a {n} modification(s)"
|
||||||
|
signinWithPasskey: "Se connecter avec une clé d'accès"
|
||||||
|
unknownWebAuthnKey: "Clé d'accès inconnue."
|
||||||
|
passkeyVerificationFailed: "La vérification de la clé d'accès a échoué."
|
||||||
|
passkeyVerificationSucceededButPasswordlessLoginDisabled: "La vérification de la clé d'accès a réussi, mais la connexion sans mot de passe est désactivée."
|
||||||
|
messageToFollower: "Message aux abonné·es"
|
||||||
|
target: "Destinataire"
|
||||||
|
prohibitedWordsForNameOfUser: "Mots interdits pour les noms d'utilisateur·rices"
|
||||||
|
lockdown: "Verrouiller"
|
||||||
|
pleaseSelectAccount: "Sélectionner un compte"
|
||||||
|
availableRoles: "Rôles disponibles"
|
||||||
|
_abuseUserReport:
|
||||||
|
forward: "Transférer"
|
||||||
|
forwardDescription: "Transférer le signalement vers une instance distante en tant qu'anonyme."
|
||||||
|
resolve: "Résoudre"
|
||||||
|
accept: "Accepter"
|
||||||
|
reject: "Rejeter"
|
||||||
|
resolveTutorial: "Si le signalement est légitime dans son contenu, sélectionnez « Accepter » pour marquer le cas comme résolu par l'affirmative.\nSi le contenu du rapport n'est pas légitime, sélectionnez « Rejeter » pour marquer le cas comme résolu par la négative."
|
||||||
_delivery:
|
_delivery:
|
||||||
status: "Statut de la diffusion"
|
status: "Statut de la diffusion"
|
||||||
stop: "Suspendu·e"
|
stop: "Suspendu·e"
|
||||||
|
resume: "Reprendre"
|
||||||
_type:
|
_type:
|
||||||
none: "Publié"
|
none: "Publié"
|
||||||
|
manuallySuspended: "Suspendre manuellement"
|
||||||
|
goneSuspended: "L'instance est suspendue en raison de la suppression de ce dernier"
|
||||||
|
autoSuspendedForNotResponding: "L'instance est suspendue car elle ne répond pas"
|
||||||
_bubbleGame:
|
_bubbleGame:
|
||||||
howToPlay: "Comment jouer"
|
howToPlay: "Comment jouer"
|
||||||
hold: "Réserver"
|
hold: "Réserver"
|
||||||
|
@ -1239,6 +1303,7 @@ _bubbleGame:
|
||||||
maxChain: "Nombre maximum de chaînes"
|
maxChain: "Nombre maximum de chaînes"
|
||||||
yen: "{yen} yens"
|
yen: "{yen} yens"
|
||||||
estimatedQty: "{qty} pièces"
|
estimatedQty: "{qty} pièces"
|
||||||
|
scoreSweets: "{onigiriQtyWithUnit} Onigiri(s)"
|
||||||
_announcement:
|
_announcement:
|
||||||
forExistingUsers: "Pour les utilisateurs existants seulement"
|
forExistingUsers: "Pour les utilisateurs existants seulement"
|
||||||
needConfirmationToRead: "Exiger la confirmation de la lecture"
|
needConfirmationToRead: "Exiger la confirmation de la lecture"
|
||||||
|
@ -1258,6 +1323,7 @@ _initialAccountSetting:
|
||||||
profileSetting: "Paramètres du profil"
|
profileSetting: "Paramètres du profil"
|
||||||
privacySetting: "Paramètres de confidentialité"
|
privacySetting: "Paramètres de confidentialité"
|
||||||
initialAccountSettingCompleted: "Configuration du profil terminée avec succès !"
|
initialAccountSettingCompleted: "Configuration du profil terminée avec succès !"
|
||||||
|
haveFun: "Profitez de {name} !"
|
||||||
youCanContinueTutorial: "Vous pouvez procéder au tutoriel sur l'utilisation de {name}(Misskey) ou vous arrêter ici et commencer à l'utiliser immédiatement."
|
youCanContinueTutorial: "Vous pouvez procéder au tutoriel sur l'utilisation de {name}(Misskey) ou vous arrêter ici et commencer à l'utiliser immédiatement."
|
||||||
startTutorial: "Démarrer le tutoriel"
|
startTutorial: "Démarrer le tutoriel"
|
||||||
skipAreYouSure: "Désirez-vous ignorer la configuration du profil ?"
|
skipAreYouSure: "Désirez-vous ignorer la configuration du profil ?"
|
||||||
|
@ -1351,18 +1417,60 @@ _achievements:
|
||||||
flavor: "Passez un bon moment avec Misskey !"
|
flavor: "Passez un bon moment avec Misskey !"
|
||||||
_notes10:
|
_notes10:
|
||||||
title: "Quelques notes"
|
title: "Quelques notes"
|
||||||
|
description: "Poster 10 notes"
|
||||||
_notes100:
|
_notes100:
|
||||||
title: "Beaucoup de notes"
|
title: "Beaucoup de notes"
|
||||||
|
description: "Poster 100 notes"
|
||||||
|
_notes500:
|
||||||
|
title: "Couvert de notes"
|
||||||
|
description: "Poster 500 notes"
|
||||||
|
_notes1000:
|
||||||
|
title: "Une montagne de notes"
|
||||||
|
description: "Poster 1000 notes"
|
||||||
|
_notes5000:
|
||||||
|
title: "Débordement de notes"
|
||||||
|
description: "Poster 5 000 notes"
|
||||||
|
_notes10000:
|
||||||
|
title: "Super note"
|
||||||
|
description: "Poster 10 000 notes"
|
||||||
|
_notes20000:
|
||||||
|
title: "Encore... plus... de... notes..."
|
||||||
|
description: "Poster 20 000 notes"
|
||||||
|
_notes30000:
|
||||||
|
title: "Notes notes notes !"
|
||||||
|
description: "Poster 30 000 notes"
|
||||||
|
_notes40000:
|
||||||
|
title: "Usine de notes"
|
||||||
|
description: "Poster 40 000 notes"
|
||||||
|
_notes50000:
|
||||||
|
title: "Planète des notes"
|
||||||
|
description: "Poster 50 000 notes"
|
||||||
|
_notes60000:
|
||||||
|
title: "Quasar de note"
|
||||||
|
description: "Poster 50 000 notes"
|
||||||
|
_notes70000:
|
||||||
|
title: "Trou noir de notes"
|
||||||
|
description: "Poster 70 000 notes"
|
||||||
|
_notes80000:
|
||||||
|
title: "Galaxie de notes"
|
||||||
|
description: "Poster 80 000 notes"
|
||||||
|
_notes90000:
|
||||||
|
title: "Univers de notes"
|
||||||
|
description: "Poster 90 000 notes"
|
||||||
_notes100000:
|
_notes100000:
|
||||||
title: "ALL YOUR NOTE ARE BELONG TO US"
|
title: "ALL YOUR NOTE ARE BELONG TO US"
|
||||||
|
description: "Poster 100 000 notes"
|
||||||
|
flavor: "Avez-vous tant de choses à dire ?"
|
||||||
_login3:
|
_login3:
|
||||||
title: "Débutant Ⅰ"
|
title: "Débutant I"
|
||||||
description: "Se connecter pour un total de 3 jours"
|
description: "Se connecter pour un total de 3 jours"
|
||||||
|
flavor: "Dès maintenant, appelez-moi Misskeynaute"
|
||||||
_login7:
|
_login7:
|
||||||
title: "Débutant Ⅱ"
|
title: "Débutant II"
|
||||||
description: "Se connecter pour un total de 7 jours"
|
description: "Se connecter pour un total de 7 jours"
|
||||||
|
flavor: "On s'habitue ?"
|
||||||
_login15:
|
_login15:
|
||||||
title: "Débutant Ⅲ"
|
title: "Débutant III"
|
||||||
description: "Se connecter pour un total de 15 jours"
|
description: "Se connecter pour un total de 15 jours"
|
||||||
_login30:
|
_login30:
|
||||||
title: "Misskeynaute I"
|
title: "Misskeynaute I"
|
||||||
|
@ -1386,6 +1494,7 @@ _achievements:
|
||||||
_login500:
|
_login500:
|
||||||
title: "Expert I"
|
title: "Expert I"
|
||||||
description: "Se connecter pour un total de 500 jours"
|
description: "Se connecter pour un total de 500 jours"
|
||||||
|
flavor: "Non, mes amis, j'aime les notes"
|
||||||
_login600:
|
_login600:
|
||||||
title: "Expert II"
|
title: "Expert II"
|
||||||
description: "Se connecter pour un total de 600 jours"
|
description: "Se connecter pour un total de 600 jours"
|
||||||
|
@ -1393,11 +1502,18 @@ _achievements:
|
||||||
title: "Expert III"
|
title: "Expert III"
|
||||||
description: "Se connecter pour un total de 700 jours"
|
description: "Se connecter pour un total de 700 jours"
|
||||||
_login800:
|
_login800:
|
||||||
|
title: "Maître des notes I"
|
||||||
description: "Se connecter pour un total de 800 jours"
|
description: "Se connecter pour un total de 800 jours"
|
||||||
_login900:
|
_login900:
|
||||||
|
title: "Maître des notes II"
|
||||||
description: "Se connecter pour un total de 900 jours"
|
description: "Se connecter pour un total de 900 jours"
|
||||||
_login1000:
|
_login1000:
|
||||||
|
title: "Maître des notes III"
|
||||||
|
description: "Se connecter pour un total de 1 000 jours"
|
||||||
flavor: "Merci d'utiliser Misskey !"
|
flavor: "Merci d'utiliser Misskey !"
|
||||||
|
_noteClipped1:
|
||||||
|
title: "Je... dois... clip..."
|
||||||
|
description: "Ajouter sa première note aux clips"
|
||||||
_profileFilled:
|
_profileFilled:
|
||||||
title: "Bien préparé"
|
title: "Bien préparé"
|
||||||
description: "Configuration de votre profil"
|
description: "Configuration de votre profil"
|
||||||
|
@ -1456,21 +1572,31 @@ _achievements:
|
||||||
_driveFolderCircularReference:
|
_driveFolderCircularReference:
|
||||||
title: "Référence circulaire"
|
title: "Référence circulaire"
|
||||||
_setNameToSyuilo:
|
_setNameToSyuilo:
|
||||||
|
title: "Complexe de dieu"
|
||||||
description: "Vous avez spécifié « syuilo » comme nom"
|
description: "Vous avez spécifié « syuilo » comme nom"
|
||||||
_passedSinceAccountCreated1:
|
_passedSinceAccountCreated1:
|
||||||
title: "Premier anniversaire"
|
title: "Premier anniversaire"
|
||||||
|
description: "Un an est passé depuis la création du compte"
|
||||||
_passedSinceAccountCreated2:
|
_passedSinceAccountCreated2:
|
||||||
title: "Second anniversaire"
|
title: "Second anniversaire"
|
||||||
|
description: "Deux ans sont passés depuis la création du compte"
|
||||||
_passedSinceAccountCreated3:
|
_passedSinceAccountCreated3:
|
||||||
title: "3ème anniversaire"
|
title: "3ème anniversaire"
|
||||||
|
description: "Trois ans sont passés depuis la création du compte"
|
||||||
_loggedInOnBirthday:
|
_loggedInOnBirthday:
|
||||||
title: "Joyeux Anniversaire !"
|
title: "Joyeux Anniversaire !"
|
||||||
description: "Vous vous êtes connecté à la date de votre anniversaire"
|
description: "Vous vous êtes connecté à la date de votre anniversaire"
|
||||||
_loggedInOnNewYearsDay:
|
_loggedInOnNewYearsDay:
|
||||||
title: "Bonne année !"
|
title: "Bonne année !"
|
||||||
|
description: "Vous vous êtes connecté le premier jour de l'année"
|
||||||
|
flavor: "Merci pour le soutient continue sur cette instance."
|
||||||
_cookieClicked:
|
_cookieClicked:
|
||||||
|
title: "Jeu de clic sur des cookies"
|
||||||
|
description: "Cliqué sur un cookie"
|
||||||
flavor: "Attendez une minute, vous êtes sur le mauvais site web ?"
|
flavor: "Attendez une minute, vous êtes sur le mauvais site web ?"
|
||||||
_brainDiver:
|
_brainDiver:
|
||||||
|
title: "Brain Diver"
|
||||||
|
description: "Poster le lien sur Brain Diver"
|
||||||
flavor: "Misskey-Misskey La-Tu-Ma"
|
flavor: "Misskey-Misskey La-Tu-Ma"
|
||||||
_smashTestNotificationButton:
|
_smashTestNotificationButton:
|
||||||
title: "Débordement de tests"
|
title: "Débordement de tests"
|
||||||
|
@ -1478,6 +1604,11 @@ _achievements:
|
||||||
_tutorialCompleted:
|
_tutorialCompleted:
|
||||||
title: "Diplôme de la course élémentaire de Misskey"
|
title: "Diplôme de la course élémentaire de Misskey"
|
||||||
description: "Terminer le tutoriel"
|
description: "Terminer le tutoriel"
|
||||||
|
_bubbleGameExplodingHead:
|
||||||
|
title: "🤯"
|
||||||
|
description: "Le plus gros objet du jeu de bulles"
|
||||||
|
_bubbleGameDoubleExplodingHead:
|
||||||
|
title: "Double🤯"
|
||||||
_role:
|
_role:
|
||||||
new: "Nouveau rôle"
|
new: "Nouveau rôle"
|
||||||
edit: "Modifier le rôle"
|
edit: "Modifier le rôle"
|
||||||
|
@ -1508,9 +1639,11 @@ _role:
|
||||||
canManageCustomEmojis: "Gestion des émojis personnalisés"
|
canManageCustomEmojis: "Gestion des émojis personnalisés"
|
||||||
canManageAvatarDecorations: "Gestion des décorations d'avatar"
|
canManageAvatarDecorations: "Gestion des décorations d'avatar"
|
||||||
driveCapacity: "Capacité de stockage du Disque"
|
driveCapacity: "Capacité de stockage du Disque"
|
||||||
|
antennaMax: "Nombre maximum d'antennes"
|
||||||
wordMuteMax: "Nombre maximal de caractères dans le filtre de mots"
|
wordMuteMax: "Nombre maximal de caractères dans le filtre de mots"
|
||||||
canUseTranslator: "Usage de la fonctionnalité de traduction"
|
canUseTranslator: "Usage de la fonctionnalité de traduction"
|
||||||
avatarDecorationLimit: "Nombre maximal de décorations d'avatar"
|
avatarDecorationLimit: "Nombre maximal de décorations d'avatar"
|
||||||
|
canImportAntennas: "Autoriser l'importation d'antennes"
|
||||||
_sensitiveMediaDetection:
|
_sensitiveMediaDetection:
|
||||||
description: "L'apprentissage automatique peut être utilisé pour détecter automatiquement les médias sensibles à modérer. La sollicitation des serveurs augmente légèrement."
|
description: "L'apprentissage automatique peut être utilisé pour détecter automatiquement les médias sensibles à modérer. La sollicitation des serveurs augmente légèrement."
|
||||||
sensitivity: "Sensibilité de la détection"
|
sensitivity: "Sensibilité de la détection"
|
||||||
|
@ -1793,6 +1926,29 @@ _permissions:
|
||||||
"write:gallery": "Éditer la galerie"
|
"write:gallery": "Éditer la galerie"
|
||||||
"read:gallery-likes": "Voir les mentions « J'aime » dans la galerie"
|
"read:gallery-likes": "Voir les mentions « J'aime » dans la galerie"
|
||||||
"write:gallery-likes": "Gérer les mentions « J'aime » dans la galerie"
|
"write:gallery-likes": "Gérer les mentions « J'aime » dans la galerie"
|
||||||
|
"read:flash": "Voir le Play"
|
||||||
|
"write:flash": "Modifier le Play"
|
||||||
|
"read:flash-likes": "Lire vos mentions j'aime des Play"
|
||||||
|
"write:flash-likes": "Modifier vos mentions j'aime des Play"
|
||||||
|
"read:admin:abuse-user-reports": "Voir les utilisateurs signalés"
|
||||||
|
"write:admin:delete-account": "Supprimer le compte d'utilisateur"
|
||||||
|
"write:admin:delete-all-files-of-a-user": "Supprimer tous les fichiers d'un utilisateur"
|
||||||
|
"read:admin:index-stats": "Voir les statistiques sur les index de base de données"
|
||||||
|
"read:admin:table-stats": "Voir les statistiques sur les index de base de données"
|
||||||
|
"read:admin:user-ips": "Voir l'adresse IP de l'utilisateur"
|
||||||
|
"read:admin:meta": "Voir les métadonnées de l'instance"
|
||||||
|
"write:admin:reset-password": "Réinitialiser le mot de passe de l'utilisateur"
|
||||||
|
"write:admin:resolve-abuse-user-report": "Résoudre le signalement d'un utilisateur"
|
||||||
|
"write:admin:send-email": "Envoyer un mail"
|
||||||
|
"read:admin:server-info": "Voir les informations de l'instance"
|
||||||
|
"read:admin:show-moderation-log": "Voir les logs de modération"
|
||||||
|
"read:admin:show-user": "Voir les informations privées de l'utilisateur"
|
||||||
|
"write:admin:suspend-user": "Suspendre l'utilisateur"
|
||||||
|
"write:admin:unset-user-avatar": "Retirer l'avatar de l'utilisateur"
|
||||||
|
"write:admin:unset-user-banner": "Retirer la bannière de l'utilisateur"
|
||||||
|
"write:admin:unsuspend-user": "Lever la suspension d'un utilisateur"
|
||||||
|
"write:admin:meta": "Gérer les métadonnées de l'instance"
|
||||||
|
"write:admin:roles": "Gérer les rôles"
|
||||||
_auth:
|
_auth:
|
||||||
shareAccess: "Autoriser \"{name}\" à accéder à votre compte ?"
|
shareAccess: "Autoriser \"{name}\" à accéder à votre compte ?"
|
||||||
shareAccessAsk: "Voulez-vous vraiment autoriser cette application à accéder à votre compte?"
|
shareAccessAsk: "Voulez-vous vraiment autoriser cette application à accéder à votre compte?"
|
||||||
|
@ -1944,7 +2100,16 @@ _timelines:
|
||||||
social: "Social"
|
social: "Social"
|
||||||
global: "Global"
|
global: "Global"
|
||||||
_play:
|
_play:
|
||||||
|
new: "Créer un Play"
|
||||||
|
edit: "Modifier un Play"
|
||||||
|
created: "Play créé"
|
||||||
|
updated: "Play édité"
|
||||||
|
deleted: "Play supprimé"
|
||||||
|
pageSetting: "Configuration du Play"
|
||||||
|
editThisPage: "Modifier ce Play"
|
||||||
viewSource: "Afficher la source"
|
viewSource: "Afficher la source"
|
||||||
|
my: "Mes Play"
|
||||||
|
liked: "Play aimés"
|
||||||
featured: "Populaire"
|
featured: "Populaire"
|
||||||
title: "Titre"
|
title: "Titre"
|
||||||
script: "Script"
|
script: "Script"
|
||||||
|
@ -2018,10 +2183,13 @@ _notification:
|
||||||
achievementEarned: "Accomplissement déverrouillé"
|
achievementEarned: "Accomplissement déverrouillé"
|
||||||
testNotification: "Tester la notification"
|
testNotification: "Tester la notification"
|
||||||
reactedBySomeUsers: "{n} utilisateur·rice·s ont réagi"
|
reactedBySomeUsers: "{n} utilisateur·rice·s ont réagi"
|
||||||
|
likedBySomeUsers: "{n} utilisateurs ont aimé votre note"
|
||||||
renotedBySomeUsers: "{n} utilisateur·rice·s ont renoté"
|
renotedBySomeUsers: "{n} utilisateur·rice·s ont renoté"
|
||||||
followedBySomeUsers: "{n} utilisateur·rice·s se sont abonné·e·s à vous"
|
followedBySomeUsers: "{n} utilisateur·rice·s se sont abonné·e·s à vous"
|
||||||
|
login: "Quelqu'un s'est connecté"
|
||||||
_types:
|
_types:
|
||||||
all: "Toutes"
|
all: "Toutes"
|
||||||
|
note: "Nouvelles notes"
|
||||||
follow: "Nouvel·le abonné·e"
|
follow: "Nouvel·le abonné·e"
|
||||||
mention: "Mentions"
|
mention: "Mentions"
|
||||||
reply: "Réponses"
|
reply: "Réponses"
|
||||||
|
@ -2071,11 +2239,14 @@ _drivecleaner:
|
||||||
orderByCreatedAtAsc: "Date d'ajout ascendante"
|
orderByCreatedAtAsc: "Date d'ajout ascendante"
|
||||||
_webhookSettings:
|
_webhookSettings:
|
||||||
name: "Nom"
|
name: "Nom"
|
||||||
|
secret: "Secret"
|
||||||
|
trigger: "Activateur"
|
||||||
active: "Activé"
|
active: "Activé"
|
||||||
_abuseReport:
|
_abuseReport:
|
||||||
_notificationRecipient:
|
_notificationRecipient:
|
||||||
_recipientType:
|
_recipientType:
|
||||||
mail: "E-mail "
|
mail: "E-mail "
|
||||||
|
keywords: "Mots clés "
|
||||||
_moderationLogTypes:
|
_moderationLogTypes:
|
||||||
createRole: "Rôle créé"
|
createRole: "Rôle créé"
|
||||||
deleteRole: "Rôle supprimé"
|
deleteRole: "Rôle supprimé"
|
||||||
|
@ -2112,6 +2283,7 @@ _moderationLogTypes:
|
||||||
deleteAvatarDecoration: "Décoration d'avatar supprimée"
|
deleteAvatarDecoration: "Décoration d'avatar supprimée"
|
||||||
unsetUserAvatar: "Supprimer l'avatar de l'utilisateur·rice"
|
unsetUserAvatar: "Supprimer l'avatar de l'utilisateur·rice"
|
||||||
unsetUserBanner: "Supprimer la bannière de l'utilisateur·rice"
|
unsetUserBanner: "Supprimer la bannière de l'utilisateur·rice"
|
||||||
|
deleteFlash: "Supprimer le Play"
|
||||||
_fileViewer:
|
_fileViewer:
|
||||||
title: "Détails du fichier"
|
title: "Détails du fichier"
|
||||||
type: "Type du fichier"
|
type: "Type du fichier"
|
||||||
|
@ -2175,5 +2347,20 @@ _dataSaver:
|
||||||
title: "Mise en évidence du code"
|
title: "Mise en évidence du code"
|
||||||
description: "Si la notation de mise en évidence du code est utilisée, par exemple dans la MFM, elle ne sera pas chargée tant qu'elle n'aura pas été tapée. La mise en évidence du code nécessite le chargement du fichier de définition de chaque langue à mettre en évidence, mais comme ces fichiers ne sont plus chargés automatiquement, on peut s'attendre à une réduction du trafic de données."
|
description: "Si la notation de mise en évidence du code est utilisée, par exemple dans la MFM, elle ne sera pas chargée tant qu'elle n'aura pas été tapée. La mise en évidence du code nécessite le chargement du fichier de définition de chaque langue à mettre en évidence, mais comme ces fichiers ne sont plus chargés automatiquement, on peut s'attendre à une réduction du trafic de données."
|
||||||
_reversi:
|
_reversi:
|
||||||
|
reversi: "Reversi"
|
||||||
|
blackIs: "{name} joue les noirs"
|
||||||
|
rules: "Règles"
|
||||||
waitingBoth: "Préparez-vous"
|
waitingBoth: "Préparez-vous"
|
||||||
|
myTurn: "C’est votre tour"
|
||||||
|
turnOf: "C'est le tour de {name}"
|
||||||
|
pastTurnOf: "Tour de {name}"
|
||||||
|
surrender: "Se rendre"
|
||||||
|
surrendered: "Par abandon"
|
||||||
total: "Total"
|
total: "Total"
|
||||||
|
playing: "En cours"
|
||||||
|
lookingForPlayer: "Recherche d'adversaire"
|
||||||
|
_mediaControls:
|
||||||
|
playbackRate: "Vitesse de lecture"
|
||||||
|
_embedCodeGen:
|
||||||
|
title: "Personnaliser le code d'intégration"
|
||||||
|
generateCode: "Générer le code d'intégration"
|
||||||
|
|
|
@ -375,7 +375,6 @@ enableLocalTimeline: "Nyalakan lini masa lokal"
|
||||||
enableGlobalTimeline: "Nyalakan lini masa global"
|
enableGlobalTimeline: "Nyalakan lini masa global"
|
||||||
disablingTimelinesInfo: "Admin dan Moderator akan selalu memiliki akses ke semua lini masa meskipun lini masa tersebut tidak diaktifkan."
|
disablingTimelinesInfo: "Admin dan Moderator akan selalu memiliki akses ke semua lini masa meskipun lini masa tersebut tidak diaktifkan."
|
||||||
registration: "Pendaftaran"
|
registration: "Pendaftaran"
|
||||||
enableRegistration: "Nyalakan pendaftaran pengguna baru"
|
|
||||||
invite: "Undang"
|
invite: "Undang"
|
||||||
driveCapacityPerLocalAccount: "Kapasitas drive per pengguna lokal"
|
driveCapacityPerLocalAccount: "Kapasitas drive per pengguna lokal"
|
||||||
driveCapacityPerRemoteAccount: "Kapasitas drive per pengguna remote"
|
driveCapacityPerRemoteAccount: "Kapasitas drive per pengguna remote"
|
||||||
|
|
|
@ -2362,6 +2362,10 @@ export interface Locale extends ILocale {
|
||||||
* 詳細
|
* 詳細
|
||||||
*/
|
*/
|
||||||
"details": string;
|
"details": string;
|
||||||
|
/**
|
||||||
|
* リノートの詳細
|
||||||
|
*/
|
||||||
|
"renoteDetails": string;
|
||||||
/**
|
/**
|
||||||
* 絵文字を選択
|
* 絵文字を選択
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -382,7 +382,6 @@ enableLocalTimeline: "Abilita la timeline locale"
|
||||||
enableGlobalTimeline: "Abilita la timeline federata"
|
enableGlobalTimeline: "Abilita la timeline federata"
|
||||||
disablingTimelinesInfo: "Anche disabilitandole, gli Amministratori e i Moderatori potranno comunque accedervi."
|
disablingTimelinesInfo: "Anche disabilitandole, gli Amministratori e i Moderatori potranno comunque accedervi."
|
||||||
registration: "Iscriviti"
|
registration: "Iscriviti"
|
||||||
enableRegistration: "Consenti a chiunque di registrarsi"
|
|
||||||
invite: "Invita"
|
invite: "Invita"
|
||||||
driveCapacityPerLocalAccount: "Capienza del Drive per profilo locale"
|
driveCapacityPerLocalAccount: "Capienza del Drive per profilo locale"
|
||||||
driveCapacityPerRemoteAccount: "Capienza del Drive per profilo remoto"
|
driveCapacityPerRemoteAccount: "Capienza del Drive per profilo remoto"
|
||||||
|
|
|
@ -586,6 +586,7 @@ masterVolume: "マスター音量"
|
||||||
notUseSound: "サウンドを出力しない"
|
notUseSound: "サウンドを出力しない"
|
||||||
useSoundOnlyWhenActive: "Misskeyがアクティブな時のみサウンドを出力する"
|
useSoundOnlyWhenActive: "Misskeyがアクティブな時のみサウンドを出力する"
|
||||||
details: "詳細"
|
details: "詳細"
|
||||||
|
renoteDetails: "リノートの詳細"
|
||||||
chooseEmoji: "絵文字を選択"
|
chooseEmoji: "絵文字を選択"
|
||||||
unableToProcess: "操作を完了できません"
|
unableToProcess: "操作を完了できません"
|
||||||
recentUsed: "最近使用"
|
recentUsed: "最近使用"
|
||||||
|
|
|
@ -382,7 +382,6 @@ enableLocalTimeline: "ローカルタイムラインを使えるようにする
|
||||||
enableGlobalTimeline: "グローバルタイムラインを使えるようにするわ"
|
enableGlobalTimeline: "グローバルタイムラインを使えるようにするわ"
|
||||||
disablingTimelinesInfo: "ここらへんのタイムラインを使えんようにしてしもても、管理者とモデレーターは使えるままになってるで、そうやなかったら不便やからな。"
|
disablingTimelinesInfo: "ここらへんのタイムラインを使えんようにしてしもても、管理者とモデレーターは使えるままになってるで、そうやなかったら不便やからな。"
|
||||||
registration: "登録"
|
registration: "登録"
|
||||||
enableRegistration: "一見さんでも誰でもいらっしゃ~い"
|
|
||||||
invite: "来てや"
|
invite: "来てや"
|
||||||
driveCapacityPerLocalAccount: "ローカルユーザーはんひとりあたりのドライブ容量"
|
driveCapacityPerLocalAccount: "ローカルユーザーはんひとりあたりのドライブ容量"
|
||||||
driveCapacityPerRemoteAccount: "リモートユーザーはんひとりあたりのドライブ容量"
|
driveCapacityPerRemoteAccount: "リモートユーザーはんひとりあたりのドライブ容量"
|
||||||
|
|
|
@ -356,7 +356,6 @@ enableLocalTimeline: "로컬 타임라인 키기"
|
||||||
enableGlobalTimeline: "글로벌 타임라인 키기"
|
enableGlobalTimeline: "글로벌 타임라인 키기"
|
||||||
disablingTimelinesInfo: "요 타임라인얼 꺼도 간리자하고 중재자넌 고대로 설 수 잇십니다."
|
disablingTimelinesInfo: "요 타임라인얼 꺼도 간리자하고 중재자넌 고대로 설 수 잇십니다."
|
||||||
registration: "맨걸기"
|
registration: "맨걸기"
|
||||||
enableRegistration: "누라도 새로 맨걸 수 잇거로 하기"
|
|
||||||
invite: "초대하기"
|
invite: "초대하기"
|
||||||
driveCapacityPerLocalAccount: "로컬 사용자 하나마중 드라이브 커기"
|
driveCapacityPerLocalAccount: "로컬 사용자 하나마중 드라이브 커기"
|
||||||
driveCapacityPerRemoteAccount: "웬겍 사용자 하나마중 드라이브 커기"
|
driveCapacityPerRemoteAccount: "웬겍 사용자 하나마중 드라이브 커기"
|
||||||
|
|
|
@ -382,7 +382,6 @@ enableLocalTimeline: "로컬 타임라인 활성화"
|
||||||
enableGlobalTimeline: "글로벌 타임라인 활성화"
|
enableGlobalTimeline: "글로벌 타임라인 활성화"
|
||||||
disablingTimelinesInfo: "특정 타임라인을 비활성화하더라도 관리자 및 모더레이터는 계속 사용할 수 있습니다."
|
disablingTimelinesInfo: "특정 타임라인을 비활성화하더라도 관리자 및 모더레이터는 계속 사용할 수 있습니다."
|
||||||
registration: "등록"
|
registration: "등록"
|
||||||
enableRegistration: "신규 회원가입을 활성화"
|
|
||||||
invite: "초대"
|
invite: "초대"
|
||||||
driveCapacityPerLocalAccount: "로컬 유저 한 명당 드라이브 용량"
|
driveCapacityPerLocalAccount: "로컬 유저 한 명당 드라이브 용량"
|
||||||
driveCapacityPerRemoteAccount: "원격 사용자별 드라이브 용량"
|
driveCapacityPerRemoteAccount: "원격 사용자별 드라이브 용량"
|
||||||
|
@ -587,6 +586,7 @@ masterVolume: "마스터 볼륨"
|
||||||
notUseSound: "음소거 하기"
|
notUseSound: "음소거 하기"
|
||||||
useSoundOnlyWhenActive: "Misskey를 활성화한 때에만 소리를 출력하기"
|
useSoundOnlyWhenActive: "Misskey를 활성화한 때에만 소리를 출력하기"
|
||||||
details: "자세히"
|
details: "자세히"
|
||||||
|
renoteDetails: "리노트 상세 내용"
|
||||||
chooseEmoji: "이모지 선택"
|
chooseEmoji: "이모지 선택"
|
||||||
unableToProcess: "작업을 완료할 수 없습니다"
|
unableToProcess: "작업을 완료할 수 없습니다"
|
||||||
recentUsed: "최근 사용"
|
recentUsed: "최근 사용"
|
||||||
|
@ -1257,7 +1257,7 @@ lastNDays: "최근 {n}일"
|
||||||
backToTitle: "타이틀로 가기"
|
backToTitle: "타이틀로 가기"
|
||||||
hemisphere: "거주 지역"
|
hemisphere: "거주 지역"
|
||||||
withSensitive: "민감한 파일이 포함된 노트 보기"
|
withSensitive: "민감한 파일이 포함된 노트 보기"
|
||||||
userSaysSomethingSensitive: "{name} 같은 민감한 파일이 포함된 글"
|
userSaysSomethingSensitive: "{name}의 민감한 파일이 포함된 게시물"
|
||||||
enableHorizontalSwipe: "스와이프하여 탭 전환"
|
enableHorizontalSwipe: "스와이프하여 탭 전환"
|
||||||
loading: "불러오는 중"
|
loading: "불러오는 중"
|
||||||
surrender: "그만두기"
|
surrender: "그만두기"
|
||||||
|
@ -1300,6 +1300,7 @@ thisContentsAreMarkedAsSigninRequiredByAuthor: "게시자에 의해 로그인해
|
||||||
lockdown: "잠금"
|
lockdown: "잠금"
|
||||||
pleaseSelectAccount: "계정을 선택해주세요."
|
pleaseSelectAccount: "계정을 선택해주세요."
|
||||||
availableRoles: "사용 가능한 역할"
|
availableRoles: "사용 가능한 역할"
|
||||||
|
acknowledgeNotesAndEnable: "활성화 하기 전에 주의 사항을 확인했습니다."
|
||||||
_accountSettings:
|
_accountSettings:
|
||||||
requireSigninToViewContents: "콘텐츠 열람을 위해 로그인으 필수로 설정하기"
|
requireSigninToViewContents: "콘텐츠 열람을 위해 로그인으 필수로 설정하기"
|
||||||
requireSigninToViewContentsDescription1: "자신이 작성한 모든 노트 등의 콘텐츠를 보기 위해 로그인을 필수로 설정합니다. 크롤러가 정보 수집하는 것을 방지하는 효과를 기대할 수 있습니다."
|
requireSigninToViewContentsDescription1: "자신이 작성한 모든 노트 등의 콘텐츠를 보기 위해 로그인을 필수로 설정합니다. 크롤러가 정보 수집하는 것을 방지하는 효과를 기대할 수 있습니다."
|
||||||
|
@ -1456,6 +1457,8 @@ _serverSettings:
|
||||||
reactionsBufferingDescription: "활성화 한 경우, 리액션 작성 퍼포먼스가 대폭 향상되어 DB의 부하를 줄일 수 있으나, Redis의 메모리 사용량이 많아집니다."
|
reactionsBufferingDescription: "활성화 한 경우, 리액션 작성 퍼포먼스가 대폭 향상되어 DB의 부하를 줄일 수 있으나, Redis의 메모리 사용량이 많아집니다."
|
||||||
inquiryUrl: "문의처 URL"
|
inquiryUrl: "문의처 URL"
|
||||||
inquiryUrlDescription: "서버 운영자에게 보내는 문의 양식의 URL이나 운영자의 연락처 등이 적힌 웹 페이지의 URL을 설정합니다."
|
inquiryUrlDescription: "서버 운영자에게 보내는 문의 양식의 URL이나 운영자의 연락처 등이 적힌 웹 페이지의 URL을 설정합니다."
|
||||||
|
openRegistration: "회원 가입을 활성화 하기"
|
||||||
|
openRegistrationWarning: "회원 가입을 개방하는 것은 리스크가 따릅니다. 서버를 항상 감시할 수 있고, 문제가 발생했을 때 바로 대응할 수 있는 상태에서만 활성화 하는 것을 권장합니다."
|
||||||
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "일정 기간동안 모더레이터의 활동이 감지되지 않는 경우, 스팸 방지를 위해 이 설정은 자동으로 꺼집니다."
|
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "일정 기간동안 모더레이터의 활동이 감지되지 않는 경우, 스팸 방지를 위해 이 설정은 자동으로 꺼집니다."
|
||||||
_accountMigration:
|
_accountMigration:
|
||||||
moveFrom: "다른 계정에서 이 계정으로 이사"
|
moveFrom: "다른 계정에서 이 계정으로 이사"
|
||||||
|
@ -2738,3 +2741,6 @@ _selfXssPrevention:
|
||||||
description1: "여기에 무언가를 붙여넣으면 악의적인 사용자에게 계정을 탈취당하거나 개인정보를 도용당할 수 있습니다."
|
description1: "여기에 무언가를 붙여넣으면 악의적인 사용자에게 계정을 탈취당하거나 개인정보를 도용당할 수 있습니다."
|
||||||
description2: "붙여 넣으려는 항목이 무엇인지 정확히 이해하지 못하는 경우, %c지금 바로 작업을 중단하고 이 창을 닫으십시오."
|
description2: "붙여 넣으려는 항목이 무엇인지 정확히 이해하지 못하는 경우, %c지금 바로 작업을 중단하고 이 창을 닫으십시오."
|
||||||
description3: "자세한 내용은 여기를 확인해 주세요. {link}"
|
description3: "자세한 내용은 여기를 확인해 주세요. {link}"
|
||||||
|
_followRequest:
|
||||||
|
recieved: "받은 신청"
|
||||||
|
sent: "보낸 신청"
|
||||||
|
|
|
@ -299,7 +299,6 @@ enableLocalTimeline: "ເປີດໃຊ້ທາມລາຍທ້ອງຖິ
|
||||||
enableGlobalTimeline: "ເປີດໃຊ້ທາມລາຍທົ່ວໂລກ"
|
enableGlobalTimeline: "ເປີດໃຊ້ທາມລາຍທົ່ວໂລກ"
|
||||||
disablingTimelinesInfo: "ຜູ້ດູແລລະບບແລະຜູ້ຄວບຄຸມຈະສາມາດເຂົ້າເຖີງໄທມ໌ໄລນ໌ທັ້ງເບີດ ເຖີງວ່າຈະບໍ່ໄດ້ເປີດໃຊ້ງານກໍ່ຕາມ"
|
disablingTimelinesInfo: "ຜູ້ດູແລລະບບແລະຜູ້ຄວບຄຸມຈະສາມາດເຂົ້າເຖີງໄທມ໌ໄລນ໌ທັ້ງເບີດ ເຖີງວ່າຈະບໍ່ໄດ້ເປີດໃຊ້ງານກໍ່ຕາມ"
|
||||||
registration: "ລົງທະບຽນ"
|
registration: "ລົງທະບຽນ"
|
||||||
enableRegistration: "ເປີດໃຊ້ການລົງທະບຽນຜູ້ໃຊ້ໃໝ່"
|
|
||||||
invite: "ເຊີນ"
|
invite: "ເຊີນ"
|
||||||
driveCapacityPerLocalAccount: "ຄວາມຈຸຂອງ drive ຕໍ່ຜູ້ໃຊ້ທ້ອງຖິ່ນ"
|
driveCapacityPerLocalAccount: "ຄວາມຈຸຂອງ drive ຕໍ່ຜູ້ໃຊ້ທ້ອງຖິ່ນ"
|
||||||
driveCapacityPerRemoteAccount: "ຄວາມຈຸຂອງ drive ຕໍ່ຜູ້ໃຊ້ໄລຍະໄກ"
|
driveCapacityPerRemoteAccount: "ຄວາມຈຸຂອງ drive ຕໍ່ຜູ້ໃຊ້ໄລຍະໄກ"
|
||||||
|
|
|
@ -333,7 +333,6 @@ enableLocalTimeline: "Inschakelen lokale tijdlijn"
|
||||||
enableGlobalTimeline: "Inschakelen globale tijdlijn "
|
enableGlobalTimeline: "Inschakelen globale tijdlijn "
|
||||||
disablingTimelinesInfo: "Beheerders en moderators hebben altijd toegang tot alle tijdlijnen, ook als ze niet actief zijn."
|
disablingTimelinesInfo: "Beheerders en moderators hebben altijd toegang tot alle tijdlijnen, ook als ze niet actief zijn."
|
||||||
registration: "Registreren"
|
registration: "Registreren"
|
||||||
enableRegistration: "Inschakelen registratie nieuwe gebruikers "
|
|
||||||
invite: "Uitnodigen"
|
invite: "Uitnodigen"
|
||||||
driveCapacityPerLocalAccount: "Opslagruimte per lokale gebruiker"
|
driveCapacityPerLocalAccount: "Opslagruimte per lokale gebruiker"
|
||||||
driveCapacityPerRemoteAccount: "Opslagruimte per externe gebruiker"
|
driveCapacityPerRemoteAccount: "Opslagruimte per externe gebruiker"
|
||||||
|
|
|
@ -260,7 +260,6 @@ enableLocalTimeline: "Aktiver lokal tidslinje"
|
||||||
enableGlobalTimeline: "Aktiver global tidslinje"
|
enableGlobalTimeline: "Aktiver global tidslinje"
|
||||||
disablingTimelinesInfo: "Administratorer og Moderatorer vil alltid ha tilgang til alle tidslinjer, selv om de ikke er aktivert."
|
disablingTimelinesInfo: "Administratorer og Moderatorer vil alltid ha tilgang til alle tidslinjer, selv om de ikke er aktivert."
|
||||||
registration: "Registrer"
|
registration: "Registrer"
|
||||||
enableRegistration: "Aktiver registrering av nye brukere"
|
|
||||||
invite: "Inviter"
|
invite: "Inviter"
|
||||||
basicInfo: "Grunnleggende informasjon"
|
basicInfo: "Grunnleggende informasjon"
|
||||||
pinnedUsers: "Festede brukrere"
|
pinnedUsers: "Festede brukrere"
|
||||||
|
|
|
@ -362,7 +362,6 @@ enableLocalTimeline: "Włącz lokalną oś czasu"
|
||||||
enableGlobalTimeline: "Włącz globalną oś czasu"
|
enableGlobalTimeline: "Włącz globalną oś czasu"
|
||||||
disablingTimelinesInfo: "Administratorzy i moderatorzy będą zawsze mieć dostęp do wszystkich osi czasu, nawet gdy są one wyłączone."
|
disablingTimelinesInfo: "Administratorzy i moderatorzy będą zawsze mieć dostęp do wszystkich osi czasu, nawet gdy są one wyłączone."
|
||||||
registration: "Zarejestruj się"
|
registration: "Zarejestruj się"
|
||||||
enableRegistration: "Włącz rejestrację nowych użytkowników"
|
|
||||||
invite: "Zaproś"
|
invite: "Zaproś"
|
||||||
driveCapacityPerLocalAccount: "Powierzchnia dyskowa na lokalnego użytkownika"
|
driveCapacityPerLocalAccount: "Powierzchnia dyskowa na lokalnego użytkownika"
|
||||||
driveCapacityPerRemoteAccount: "Powierzchnia dyskowa na zdalnego użytkownika"
|
driveCapacityPerRemoteAccount: "Powierzchnia dyskowa na zdalnego użytkownika"
|
||||||
|
@ -492,6 +491,10 @@ uiLanguage: "Język wyświetlania UI"
|
||||||
aboutX: "O {x}"
|
aboutX: "O {x}"
|
||||||
emojiStyle: "Styl emoji"
|
emojiStyle: "Styl emoji"
|
||||||
native: "Natywny"
|
native: "Natywny"
|
||||||
|
menuStyle: "Styl Menu"
|
||||||
|
style: "Styl"
|
||||||
|
drawer: "Schowek"
|
||||||
|
popup: "Wyskakujące okienka"
|
||||||
showNoteActionsOnlyHover: "Pokazuj akcje notatek tylko po najechaniu myszką"
|
showNoteActionsOnlyHover: "Pokazuj akcje notatek tylko po najechaniu myszką"
|
||||||
showReactionsCount: "Wyświetl liczbę reakcji na notatkę"
|
showReactionsCount: "Wyświetl liczbę reakcji na notatkę"
|
||||||
noHistory: "Brak historii"
|
noHistory: "Brak historii"
|
||||||
|
@ -574,6 +577,7 @@ ascendingOrder: "Rosnąco"
|
||||||
descendingOrder: "Malejąco"
|
descendingOrder: "Malejąco"
|
||||||
scratchpad: "Brudnopis"
|
scratchpad: "Brudnopis"
|
||||||
scratchpadDescription: "Brudnopis zawiera eksperymentalne środowisko dla AiScript. Możesz pisać, wykonywać i sprawdzać wyniki w interakcji z Misskey."
|
scratchpadDescription: "Brudnopis zawiera eksperymentalne środowisko dla AiScript. Możesz pisać, wykonywać i sprawdzać wyniki w interakcji z Misskey."
|
||||||
|
uiInspector: "Inspektor UI"
|
||||||
output: "Wyjście"
|
output: "Wyjście"
|
||||||
script: "Skrypt"
|
script: "Skrypt"
|
||||||
disablePagesScript: "Wyłącz AiScript na Stronach"
|
disablePagesScript: "Wyłącz AiScript na Stronach"
|
||||||
|
@ -654,6 +658,7 @@ smtpSecure: "Użyj niejawnego SSL/TLS dla połączeń SMTP"
|
||||||
smtpSecureInfo: "Wyłącz, jeżeli używasz STARTTLS"
|
smtpSecureInfo: "Wyłącz, jeżeli używasz STARTTLS"
|
||||||
testEmail: "Przetestuj dostarczanie wiadomości e-mail"
|
testEmail: "Przetestuj dostarczanie wiadomości e-mail"
|
||||||
wordMute: "Wyciszenie słowa"
|
wordMute: "Wyciszenie słowa"
|
||||||
|
hardWordMute: "Wyciszaj przekleństwa"
|
||||||
regexpError: "Błąd wyrażenia regularnego"
|
regexpError: "Błąd wyrażenia regularnego"
|
||||||
regexpErrorDescription: "Wystąpił błąd w wyrażeniu regularnym w linii {line} twoich {tab} wyciszeń:"
|
regexpErrorDescription: "Wystąpił błąd w wyrażeniu regularnym w linii {line} twoich {tab} wyciszeń:"
|
||||||
instanceMute: "Wyciszone instancje"
|
instanceMute: "Wyciszone instancje"
|
||||||
|
@ -826,6 +831,7 @@ administration: "Zarządzanie"
|
||||||
accounts: "Konta"
|
accounts: "Konta"
|
||||||
switch: "Przełącz"
|
switch: "Przełącz"
|
||||||
noMaintainerInformationWarning: "Informacje o administratorze nie są skonfigurowane."
|
noMaintainerInformationWarning: "Informacje o administratorze nie są skonfigurowane."
|
||||||
|
noInquiryUrlWarning: "Adres URL zapytania nie został ustawiony"
|
||||||
noBotProtectionWarning: "Zabezpieczenie przed botami nie jest skonfigurowane."
|
noBotProtectionWarning: "Zabezpieczenie przed botami nie jest skonfigurowane."
|
||||||
configure: "Skonfiguruj"
|
configure: "Skonfiguruj"
|
||||||
postToGallery: "Opublikuj w galerii"
|
postToGallery: "Opublikuj w galerii"
|
||||||
|
@ -890,6 +896,7 @@ followersVisibility: "Widoczność obserwujących"
|
||||||
continueThread: "Pokaż kontynuację wątku"
|
continueThread: "Pokaż kontynuację wątku"
|
||||||
deleteAccountConfirm: "Spowoduje to nieodwracalne usunięcie Twojego konta. Kontynuować?"
|
deleteAccountConfirm: "Spowoduje to nieodwracalne usunięcie Twojego konta. Kontynuować?"
|
||||||
incorrectPassword: "Nieprawidłowe hasło."
|
incorrectPassword: "Nieprawidłowe hasło."
|
||||||
|
incorrectTotp: "Hasło pojedynczego użytku jest nie poprawne, lub straciło ważność"
|
||||||
voteConfirm: "Potwierdzić swój głos na \"{choice}\"?"
|
voteConfirm: "Potwierdzić swój głos na \"{choice}\"?"
|
||||||
hide: "Ukryj"
|
hide: "Ukryj"
|
||||||
useDrawerReactionPickerForMobile: "Wyświetlaj wybornik reakcji jako szufladę na urządzeniach mobilnych"
|
useDrawerReactionPickerForMobile: "Wyświetlaj wybornik reakcji jako szufladę na urządzeniach mobilnych"
|
||||||
|
@ -914,6 +921,10 @@ oneHour: "1 godzina"
|
||||||
oneDay: "1 dzień"
|
oneDay: "1 dzień"
|
||||||
oneWeek: "1 tydzień"
|
oneWeek: "1 tydzień"
|
||||||
oneMonth: "jeden miesiąc"
|
oneMonth: "jeden miesiąc"
|
||||||
|
threeMonths: "3 miesiące"
|
||||||
|
oneYear: "Rok"
|
||||||
|
threeDays: "3 dni"
|
||||||
|
reflectMayTakeTime: "Może minąć trochę czasu, zanim będzie to uwzględnione"
|
||||||
failedToFetchAccountInformation: "Nie udało się uzyskać informacji o koncie"
|
failedToFetchAccountInformation: "Nie udało się uzyskać informacji o koncie"
|
||||||
rateLimitExceeded: "Limit szybkości przekroczony"
|
rateLimitExceeded: "Limit szybkości przekroczony"
|
||||||
cropImage: "Przytnij obraz"
|
cropImage: "Przytnij obraz"
|
||||||
|
@ -924,9 +935,11 @@ file: "Pliki"
|
||||||
recentNHours: "W ciągu ostatnich {n} godzin"
|
recentNHours: "W ciągu ostatnich {n} godzin"
|
||||||
recentNDays: "W ciągu ostatnich {n} dni"
|
recentNDays: "W ciągu ostatnich {n} dni"
|
||||||
noEmailServerWarning: "Serwer Email nie jest skonfigurowany"
|
noEmailServerWarning: "Serwer Email nie jest skonfigurowany"
|
||||||
|
thereIsUnresolvedAbuseReportWarning: "Istnieją niewyjaśnione raporty"
|
||||||
recommended: "Zalecane"
|
recommended: "Zalecane"
|
||||||
check: "Zweryfikuj"
|
check: "Zweryfikuj"
|
||||||
driveCapOverrideLabel: "Zmień limit pojemności dysku użytkownika"
|
driveCapOverrideLabel: "Zmień limit pojemności dysku użytkownika"
|
||||||
|
driveCapOverrideCaption: "Resetuje pojemność do wartości domyślnej, przez wpisanie wartości 0 lub niższej"
|
||||||
requireAdminForView: "Aby to zobaczyć, musisz być administratorem"
|
requireAdminForView: "Aby to zobaczyć, musisz być administratorem"
|
||||||
isSystemAccount: "To jest konto stworzone i zarządzane przez system"
|
isSystemAccount: "To jest konto stworzone i zarządzane przez system"
|
||||||
typeToConfirm: "Wprowadź {x}, aby potwierdzić"
|
typeToConfirm: "Wprowadź {x}, aby potwierdzić"
|
||||||
|
@ -995,17 +1008,29 @@ unassign: "Cofnij przydzielenie"
|
||||||
color: "Kolor"
|
color: "Kolor"
|
||||||
manageCustomEmojis: "Zarządzaj niestandardowymi Emoji"
|
manageCustomEmojis: "Zarządzaj niestandardowymi Emoji"
|
||||||
manageAvatarDecorations: "Zarządzaj dekoracjami awatara"
|
manageAvatarDecorations: "Zarządzaj dekoracjami awatara"
|
||||||
|
youCannotCreateAnymore: "Limit kreacji został przekroczony"
|
||||||
|
cannotPerformTemporary: "Opcja tymczasowo niedostępna"
|
||||||
|
cannotPerformTemporaryDescription: "Ta akcja nie może zostać wykonana, z powodu przekroczenia limitu wykonań. Prosimy poczekać chwilę i spróbować ponownie"
|
||||||
invalidParamError: "Błąd parametrów"
|
invalidParamError: "Błąd parametrów"
|
||||||
|
invalidParamErrorDescription: "Wartości, które zostały podane są niepoprawne. Zwykle jest to spowodowane bugiem, lecz również może być to spowodowane przekroczeniem limitu wartości, lub podobnym problemem"
|
||||||
permissionDeniedError: "Odrzucono operacje"
|
permissionDeniedError: "Odrzucono operacje"
|
||||||
permissionDeniedErrorDescription: "Konto nie posiada uprawnień"
|
permissionDeniedErrorDescription: "Konto nie posiada uprawnień"
|
||||||
preset: "Konfiguracja"
|
preset: "Konfiguracja"
|
||||||
selectFromPresets: "Wybierz konfiguracje"
|
selectFromPresets: "Wybierz konfiguracje"
|
||||||
achievements: "Osiągnięcia"
|
achievements: "Osiągnięcia"
|
||||||
|
gotInvalidResponseError: "Niepoprawna odpowiedź serwera"
|
||||||
|
gotInvalidResponseErrorDescription: "Wystąpił problem z Twoim połączeniem z Internetem, lub z serwerem. {Spróbuj ponownie} wkrótce."
|
||||||
|
thisPostMayBeAnnoying: "Ten wpis może obrażać pozostałych użytkowników"
|
||||||
|
thisPostMayBeAnnoyingHome: "Opublikuj na domowej osi czasu"
|
||||||
thisPostMayBeAnnoyingCancel: "Odrzuć"
|
thisPostMayBeAnnoyingCancel: "Odrzuć"
|
||||||
|
thisPostMayBeAnnoyingIgnore: "Zignoruj i wyślij"
|
||||||
|
collapseRenotes: "Zwiń wpisy, które już zobaczyłeś"
|
||||||
|
collapseRenotesDescription: "Zwiń wpisy, na które już zareagowałeś lub udostępniłeś"
|
||||||
internalServerError: "Wewnętrzny błąd serwera"
|
internalServerError: "Wewnętrzny błąd serwera"
|
||||||
internalServerErrorDescription: "Niespodziewany błąd po stronie serwera"
|
internalServerErrorDescription: "Niespodziewany błąd po stronie serwera"
|
||||||
copyErrorInfo: "Kopiuj informacje o błędzie"
|
copyErrorInfo: "Kopiuj informacje o błędzie"
|
||||||
joinThisServer: "Dołącz do chaty"
|
joinThisServer: "Dołącz do chaty"
|
||||||
|
exploreOtherServers: "Szukaj innej instancji"
|
||||||
disableFederationOk: "Wyłącz federacje"
|
disableFederationOk: "Wyłącz federacje"
|
||||||
invitationRequiredToRegister: "Ten serwer wymaga zaproszenia. Tylko osoby z zaproszeniem mogą się zarejestrować"
|
invitationRequiredToRegister: "Ten serwer wymaga zaproszenia. Tylko osoby z zaproszeniem mogą się zarejestrować"
|
||||||
emailNotSupported: "Wysyłanie wiadomości E-mail nie jest obsługiwane na tym serwerze"
|
emailNotSupported: "Wysyłanie wiadomości E-mail nie jest obsługiwane na tym serwerze"
|
||||||
|
|
|
@ -376,7 +376,6 @@ enableLocalTimeline: "Ativar linha do tempo local"
|
||||||
enableGlobalTimeline: "Ativar linha do tempo global"
|
enableGlobalTimeline: "Ativar linha do tempo global"
|
||||||
disablingTimelinesInfo: "Se você desabilitar essas linhas do tempo, administradores e moderadores ainda poderão usá-las por conveniência."
|
disablingTimelinesInfo: "Se você desabilitar essas linhas do tempo, administradores e moderadores ainda poderão usá-las por conveniência."
|
||||||
registration: "Registar"
|
registration: "Registar"
|
||||||
enableRegistration: "Permitir que qualquer pessoa se registre"
|
|
||||||
invite: "Convidar"
|
invite: "Convidar"
|
||||||
driveCapacityPerLocalAccount: "Capacidade do drive por usuário local"
|
driveCapacityPerLocalAccount: "Capacidade do drive por usuário local"
|
||||||
driveCapacityPerRemoteAccount: "Capacidade do drive por usuário remoto"
|
driveCapacityPerRemoteAccount: "Capacidade do drive por usuário remoto"
|
||||||
|
|
|
@ -341,7 +341,6 @@ enableLocalTimeline: "Activează cronologia locală"
|
||||||
enableGlobalTimeline: "Activeaza cronologia globală"
|
enableGlobalTimeline: "Activeaza cronologia globală"
|
||||||
disablingTimelinesInfo: "Administratorii și Moderatorii vor avea mereu access la toate cronologiile, chiar dacă nu sunt activate."
|
disablingTimelinesInfo: "Administratorii și Moderatorii vor avea mereu access la toate cronologiile, chiar dacă nu sunt activate."
|
||||||
registration: "Inregistrare"
|
registration: "Inregistrare"
|
||||||
enableRegistration: "Activează înregistrările pentru utilizatori noi"
|
|
||||||
invite: "Invită"
|
invite: "Invită"
|
||||||
driveCapacityPerLocalAccount: "Capacitatea Drive-ului per utilizator local"
|
driveCapacityPerLocalAccount: "Capacitatea Drive-ului per utilizator local"
|
||||||
driveCapacityPerRemoteAccount: "Capacitatea Drive-ului per utilizator extern"
|
driveCapacityPerRemoteAccount: "Capacitatea Drive-ului per utilizator extern"
|
||||||
|
|
|
@ -377,7 +377,6 @@ enableLocalTimeline: "Включить локальную ленту"
|
||||||
enableGlobalTimeline: "Включить глобальную ленту"
|
enableGlobalTimeline: "Включить глобальную ленту"
|
||||||
disablingTimelinesInfo: "У администраторов и модераторов есть доступ ко всем лентам, даже если они отключены."
|
disablingTimelinesInfo: "У администраторов и модераторов есть доступ ко всем лентам, даже если они отключены."
|
||||||
registration: "Регистрация"
|
registration: "Регистрация"
|
||||||
enableRegistration: "Разрешить регистрацию"
|
|
||||||
invite: "Пригласить"
|
invite: "Пригласить"
|
||||||
driveCapacityPerLocalAccount: "Объём Диска на одного локального пользователя"
|
driveCapacityPerLocalAccount: "Объём Диска на одного локального пользователя"
|
||||||
driveCapacityPerRemoteAccount: "Объём Диска на одного пользователя с другого экземпляра"
|
driveCapacityPerRemoteAccount: "Объём Диска на одного пользователя с другого экземпляра"
|
||||||
|
|
|
@ -331,7 +331,6 @@ enableLocalTimeline: "Povoliť lokálnu časovú os"
|
||||||
enableGlobalTimeline: "Povoliť globálnu časovú os"
|
enableGlobalTimeline: "Povoliť globálnu časovú os"
|
||||||
disablingTimelinesInfo: "Administrátori a moderátori majú vždy prístup ku všetkým časovým osiam, aj keď sú vypnuté."
|
disablingTimelinesInfo: "Administrátori a moderátori majú vždy prístup ku všetkým časovým osiam, aj keď sú vypnuté."
|
||||||
registration: "Registrácia"
|
registration: "Registrácia"
|
||||||
enableRegistration: "Povoliť registráciu nových používateľov"
|
|
||||||
invite: "Pozvať"
|
invite: "Pozvať"
|
||||||
driveCapacityPerLocalAccount: "Kapacita disku pre používateľa"
|
driveCapacityPerLocalAccount: "Kapacita disku pre používateľa"
|
||||||
driveCapacityPerRemoteAccount: "Kapacita disku pre vzdialeného používateľa"
|
driveCapacityPerRemoteAccount: "Kapacita disku pre vzdialeného používateľa"
|
||||||
|
|
|
@ -333,7 +333,6 @@ disconnectService: "Koppla från"
|
||||||
enableLocalTimeline: "Aktivera lokal tidslinje"
|
enableLocalTimeline: "Aktivera lokal tidslinje"
|
||||||
enableGlobalTimeline: "Aktivera global tidslinje"
|
enableGlobalTimeline: "Aktivera global tidslinje"
|
||||||
registration: "Registrera"
|
registration: "Registrera"
|
||||||
enableRegistration: "Aktivera registrering av nya användare"
|
|
||||||
invite: "Inbjudan"
|
invite: "Inbjudan"
|
||||||
inMb: "I megabyte"
|
inMb: "I megabyte"
|
||||||
bannerUrl: "URL till banner-bilden"
|
bannerUrl: "URL till banner-bilden"
|
||||||
|
@ -481,6 +480,7 @@ nNotes: "{n} Noter"
|
||||||
backgroundColor: "Bakgrundsbild"
|
backgroundColor: "Bakgrundsbild"
|
||||||
textColor: "Text"
|
textColor: "Text"
|
||||||
saveAs: "Spara som..."
|
saveAs: "Spara som..."
|
||||||
|
saveConfirm: "Spara ändringar?"
|
||||||
youAreRunningUpToDateClient: "Klienten du använder är uppdaterat."
|
youAreRunningUpToDateClient: "Klienten du använder är uppdaterat."
|
||||||
newVersionOfClientAvailable: "Ny version av klienten är tillgänglig."
|
newVersionOfClientAvailable: "Ny version av klienten är tillgänglig."
|
||||||
editCode: "Redigera kod"
|
editCode: "Redigera kod"
|
||||||
|
@ -523,6 +523,7 @@ threeMonths: "3 månader"
|
||||||
oneYear: "1 år"
|
oneYear: "1 år"
|
||||||
threeDays: "3 dagar"
|
threeDays: "3 dagar"
|
||||||
file: "Filer"
|
file: "Filer"
|
||||||
|
deleteAccount: "Radera konto"
|
||||||
label: "Etikett"
|
label: "Etikett"
|
||||||
cannotUploadBecauseNoFreeSpace: "Kan inte ladda upp filen för att det finns inget lagringsutrymme kvar."
|
cannotUploadBecauseNoFreeSpace: "Kan inte ladda upp filen för att det finns inget lagringsutrymme kvar."
|
||||||
cannotUploadBecauseExceedsFileSizeLimit: "Kan inte ladda upp filen för att den är större än filstorleksgränsen."
|
cannotUploadBecauseExceedsFileSizeLimit: "Kan inte ladda upp filen för att den är större än filstorleksgränsen."
|
||||||
|
@ -575,9 +576,13 @@ _achievements:
|
||||||
_open3windows:
|
_open3windows:
|
||||||
title: "Flera Fönster"
|
title: "Flera Fönster"
|
||||||
description: "Ha minst 3 fönster öppna samtidigt"
|
description: "Ha minst 3 fönster öppna samtidigt"
|
||||||
|
_role:
|
||||||
|
edit: "Redigera roll"
|
||||||
_ffVisibility:
|
_ffVisibility:
|
||||||
public: "Publicera"
|
public: "Publicera"
|
||||||
private: "Privat"
|
private: "Privat"
|
||||||
|
_accountDelete:
|
||||||
|
accountDelete: "Radera konto"
|
||||||
_ad:
|
_ad:
|
||||||
back: "Tillbaka"
|
back: "Tillbaka"
|
||||||
_gallery:
|
_gallery:
|
||||||
|
@ -587,6 +592,7 @@ _email:
|
||||||
title: "följde dig"
|
title: "följde dig"
|
||||||
_aboutMisskey:
|
_aboutMisskey:
|
||||||
source: "Källkod"
|
source: "Källkod"
|
||||||
|
projectMembers: "Projektmedlemmar"
|
||||||
_channel:
|
_channel:
|
||||||
setBanner: "Välj banner"
|
setBanner: "Välj banner"
|
||||||
removeBanner: "Ta bort banner"
|
removeBanner: "Ta bort banner"
|
||||||
|
@ -602,8 +608,17 @@ _theme:
|
||||||
_sfx:
|
_sfx:
|
||||||
note: "Noter"
|
note: "Noter"
|
||||||
notification: "Notifikationer"
|
notification: "Notifikationer"
|
||||||
|
_ago:
|
||||||
|
justNow: "Just nu"
|
||||||
_2fa:
|
_2fa:
|
||||||
|
step3Title: "Ange en autentiseringskod"
|
||||||
renewTOTPCancel: "Nej tack"
|
renewTOTPCancel: "Nej tack"
|
||||||
|
_permissions:
|
||||||
|
"read:reactions": "Visa dina reaktioner"
|
||||||
|
"write:reactions": "Redigera dina reaktioner"
|
||||||
|
"write:admin:delete-account": "Radera användarkonto"
|
||||||
|
"write:admin:roles": "Hantera roller"
|
||||||
|
"read:admin:roles": "Visa roller"
|
||||||
_antennaSources:
|
_antennaSources:
|
||||||
all: "Alla noter"
|
all: "Alla noter"
|
||||||
homeTimeline: "Noter från följda användare"
|
homeTimeline: "Noter från följda användare"
|
||||||
|
@ -666,6 +681,8 @@ _notification:
|
||||||
reply: "Svara"
|
reply: "Svara"
|
||||||
renote: "Omnotera"
|
renote: "Omnotera"
|
||||||
_deck:
|
_deck:
|
||||||
|
addColumn: "Lägg till kolumn"
|
||||||
|
deleteProfile: "Radera profil"
|
||||||
_columns:
|
_columns:
|
||||||
notifications: "Notifikationer"
|
notifications: "Notifikationer"
|
||||||
tl: "Tidslinje"
|
tl: "Tidslinje"
|
||||||
|
|
|
@ -382,7 +382,6 @@ enableLocalTimeline: "เปิดใช้งานไทม์ไลน์ท
|
||||||
enableGlobalTimeline: "เปิดใช้งานไทม์ไลน์ทั่วโลก"
|
enableGlobalTimeline: "เปิดใช้งานไทม์ไลน์ทั่วโลก"
|
||||||
disablingTimelinesInfo: "ผู้ดูแลระบบและผู้ควบคุมจะสามารถเข้าถึงไทม์ไลน์ทั้งหมด ถึงแม้ว่าจะไม่ได้เปิดใช้งานก็ตาม"
|
disablingTimelinesInfo: "ผู้ดูแลระบบและผู้ควบคุมจะสามารถเข้าถึงไทม์ไลน์ทั้งหมด ถึงแม้ว่าจะไม่ได้เปิดใช้งานก็ตาม"
|
||||||
registration: "ลงทะเบียน"
|
registration: "ลงทะเบียน"
|
||||||
enableRegistration: "เปิดใช้งานการลงทะเบียนผู้ใช้ใหม่"
|
|
||||||
invite: "คำเชิญ"
|
invite: "คำเชิญ"
|
||||||
driveCapacityPerLocalAccount: "ความจุของไดรฟ์ต่อผู้ใช้ท้องถิ่น"
|
driveCapacityPerLocalAccount: "ความจุของไดรฟ์ต่อผู้ใช้ท้องถิ่น"
|
||||||
driveCapacityPerRemoteAccount: "ความจุของไดรฟ์ต่อผู้ใช้ระยะไกล"
|
driveCapacityPerRemoteAccount: "ความจุของไดรฟ์ต่อผู้ใช้ระยะไกล"
|
||||||
|
|
|
@ -344,7 +344,6 @@ today: "Bugün"
|
||||||
monthX: "{month} ay"
|
monthX: "{month} ay"
|
||||||
pages: "Sayfalar"
|
pages: "Sayfalar"
|
||||||
integration: "Entegrasyon"
|
integration: "Entegrasyon"
|
||||||
enableRegistration: "Kayıtlara izin ver"
|
|
||||||
basicInfo: "Temel bilgiler"
|
basicInfo: "Temel bilgiler"
|
||||||
pinnedUsers: "Sabitlenmiş kullanıcılar"
|
pinnedUsers: "Sabitlenmiş kullanıcılar"
|
||||||
pinnedNotes: "Sabitlenen"
|
pinnedNotes: "Sabitlenen"
|
||||||
|
|
|
@ -334,7 +334,6 @@ enableLocalTimeline: "Увімкнути локальну стрічку"
|
||||||
enableGlobalTimeline: "Увімкнути глобальну стрічку"
|
enableGlobalTimeline: "Увімкнути глобальну стрічку"
|
||||||
disablingTimelinesInfo: "Адміністратори та модератори завжди мають доступ до всіх стрічок, навіть якщо вони вимкнуті."
|
disablingTimelinesInfo: "Адміністратори та модератори завжди мають доступ до всіх стрічок, навіть якщо вони вимкнуті."
|
||||||
registration: "Реєстрація"
|
registration: "Реєстрація"
|
||||||
enableRegistration: "Дозволити реєстрацію"
|
|
||||||
invite: "Запросити"
|
invite: "Запросити"
|
||||||
driveCapacityPerLocalAccount: "Об'єм диска на одного локального користувача"
|
driveCapacityPerLocalAccount: "Об'єм диска на одного локального користувача"
|
||||||
driveCapacityPerRemoteAccount: "Об'єм диска на одного віддаленого користувача"
|
driveCapacityPerRemoteAccount: "Об'єм диска на одного віддаленого користувача"
|
||||||
|
|
|
@ -349,7 +349,6 @@ enableLocalTimeline: "Mahalliy vaqt mintaqasini yoqing"
|
||||||
enableGlobalTimeline: "Global vaqt mintaqasini yoqing"
|
enableGlobalTimeline: "Global vaqt mintaqasini yoqing"
|
||||||
disablingTimelinesInfo: "Administratorlar va Moderatorlar har doim barcha vaqt jadvallariga kirish huquqiga ega bo'ladilar, hatto ular yoqilmagan bo'lsa ham."
|
disablingTimelinesInfo: "Administratorlar va Moderatorlar har doim barcha vaqt jadvallariga kirish huquqiga ega bo'ladilar, hatto ular yoqilmagan bo'lsa ham."
|
||||||
registration: "Ro'yxatdan o'tish"
|
registration: "Ro'yxatdan o'tish"
|
||||||
enableRegistration: "Ro'yxatdan o'tishni yoqing"
|
|
||||||
invite: "Taklif qilish"
|
invite: "Taklif qilish"
|
||||||
driveCapacityPerLocalAccount: "Har bir mahalliy foydalanuvchi uchun disk maydoni"
|
driveCapacityPerLocalAccount: "Har bir mahalliy foydalanuvchi uchun disk maydoni"
|
||||||
driveCapacityPerRemoteAccount: "Har bir masofaviy foydalanuvchi uchun disk maydoni"
|
driveCapacityPerRemoteAccount: "Har bir masofaviy foydalanuvchi uchun disk maydoni"
|
||||||
|
|
|
@ -357,7 +357,6 @@ enableLocalTimeline: "Bật bảng tin máy chủ"
|
||||||
enableGlobalTimeline: "Bật bảng tin liên hợp"
|
enableGlobalTimeline: "Bật bảng tin liên hợp"
|
||||||
disablingTimelinesInfo: "Quản trị viên và Kiểm duyệt viên luôn có quyền truy cập mọi bảng tin, kể cả khi chúng không được bật."
|
disablingTimelinesInfo: "Quản trị viên và Kiểm duyệt viên luôn có quyền truy cập mọi bảng tin, kể cả khi chúng không được bật."
|
||||||
registration: "Đăng ký"
|
registration: "Đăng ký"
|
||||||
enableRegistration: "Cho phép đăng ký mới"
|
|
||||||
invite: "Mời"
|
invite: "Mời"
|
||||||
driveCapacityPerLocalAccount: "Dung lượng ổ đĩa tối đa cho mỗi người dùng"
|
driveCapacityPerLocalAccount: "Dung lượng ổ đĩa tối đa cho mỗi người dùng"
|
||||||
driveCapacityPerRemoteAccount: "Dung lượng ổ đĩa tối đa cho mỗi người dùng từ xa"
|
driveCapacityPerRemoteAccount: "Dung lượng ổ đĩa tối đa cho mỗi người dùng từ xa"
|
||||||
|
|
|
@ -143,8 +143,8 @@ unmarkAsSensitive: "取消标记为敏感内容"
|
||||||
enterFileName: "输入文件名"
|
enterFileName: "输入文件名"
|
||||||
mute: "屏蔽"
|
mute: "屏蔽"
|
||||||
unmute: "解除静音"
|
unmute: "解除静音"
|
||||||
renoteMute: "屏蔽转帖"
|
renoteMute: "隐藏转帖"
|
||||||
renoteUnmute: "解除屏蔽转帖"
|
renoteUnmute: "解除隐藏转帖"
|
||||||
block: "拉黑"
|
block: "拉黑"
|
||||||
unblock: "取消拉黑"
|
unblock: "取消拉黑"
|
||||||
suspend: "冻结"
|
suspend: "冻结"
|
||||||
|
@ -213,7 +213,7 @@ charts: "图表"
|
||||||
perHour: "每小时"
|
perHour: "每小时"
|
||||||
perDay: "每天"
|
perDay: "每天"
|
||||||
stopActivityDelivery: "停止发送活动"
|
stopActivityDelivery: "停止发送活动"
|
||||||
blockThisInstance: "封锁此服务器"
|
blockThisInstance: "屏蔽此服务器"
|
||||||
silenceThisInstance: "静音此服务器"
|
silenceThisInstance: "静音此服务器"
|
||||||
mediaSilenceThisInstance: "隐藏此服务器的媒体文件"
|
mediaSilenceThisInstance: "隐藏此服务器的媒体文件"
|
||||||
operations: "操作"
|
operations: "操作"
|
||||||
|
@ -233,17 +233,17 @@ clearQueueConfirmTitle: "确定清除队列?"
|
||||||
clearQueueConfirmText: "未送达的帖子将不会被投递。 通常无需执行此操作。"
|
clearQueueConfirmText: "未送达的帖子将不会被投递。 通常无需执行此操作。"
|
||||||
clearCachedFiles: "清除缓存"
|
clearCachedFiles: "清除缓存"
|
||||||
clearCachedFilesConfirm: "确定要清除所有缓存的远程文件?"
|
clearCachedFilesConfirm: "确定要清除所有缓存的远程文件?"
|
||||||
blockedInstances: "被封锁的服务器"
|
blockedInstances: "被屏蔽的服务器"
|
||||||
blockedInstancesDescription: "设定要封锁的服务器,以换行分隔。被封锁的服务器将无法与本服务器进行交换通讯。子域名也同样会被封锁。"
|
blockedInstancesDescription: "设定要屏蔽的服务器,以换行分隔。被屏蔽的服务器将无法与本服务器进行交换通讯。子域名也同样会被屏蔽。"
|
||||||
silencedInstances: "被静音的服务器"
|
silencedInstances: "被静音的服务器"
|
||||||
silencedInstancesDescription: "设置要静音的服务器,以换行分隔。被静音的服务器内所有的账户将默认处于「静音」状态,仅能发送关注请求,并且在未关注状态下无法提及本地账户。被阻止的实例不受影响。"
|
silencedInstancesDescription: "设置要静音的服务器,以换行分隔。被静音的服务器内所有的账户将默认处于「静音」状态,仅能发送关注请求,并且在未关注状态下无法提及本地账户。被阻止的实例不受影响。"
|
||||||
mediaSilencedInstances: "已隐藏媒体文件的服务器"
|
mediaSilencedInstances: "已隐藏媒体文件的服务器"
|
||||||
mediaSilencedInstancesDescription: "设置要隐藏媒体文件的服务器,以换行分隔。被设置为隐藏媒体文件服务器内所有账号的文件均按照「敏感内容」处理,且将无法使用自定义表情符号。被阻止的实例不受影响。"
|
mediaSilencedInstancesDescription: "设置要隐藏媒体文件的服务器,以换行分隔。被设置为隐藏媒体文件服务器内所有账号的文件均按照「敏感内容」处理,且将无法使用自定义表情符号。被阻止的实例不受影响。"
|
||||||
federationAllowedHosts: "允许联合的服务器"
|
federationAllowedHosts: "允许联合的服务器"
|
||||||
federationAllowedHostsDescription: "设定允许联合的服务器,以换行分隔。"
|
federationAllowedHostsDescription: "设定允许联合的服务器,以换行分隔。"
|
||||||
muteAndBlock: "静音/拉黑"
|
muteAndBlock: "隐藏和屏蔽"
|
||||||
mutedUsers: "已静音用户"
|
mutedUsers: "已隐藏用户"
|
||||||
blockedUsers: "已拉黑的用户"
|
blockedUsers: "已屏蔽的用户"
|
||||||
noUsers: "无用户"
|
noUsers: "无用户"
|
||||||
editProfile: "编辑资料"
|
editProfile: "编辑资料"
|
||||||
noteDeleteConfirm: "要删除该帖子吗?"
|
noteDeleteConfirm: "要删除该帖子吗?"
|
||||||
|
@ -382,7 +382,6 @@ enableLocalTimeline: "启用本地时间线"
|
||||||
enableGlobalTimeline: "启用全局时间线"
|
enableGlobalTimeline: "启用全局时间线"
|
||||||
disablingTimelinesInfo: "即使时间线功能被禁用,出于方便,管理员和监察员也可以继续使用。"
|
disablingTimelinesInfo: "即使时间线功能被禁用,出于方便,管理员和监察员也可以继续使用。"
|
||||||
registration: "注册"
|
registration: "注册"
|
||||||
enableRegistration: "允许任何人注册"
|
|
||||||
invite: "邀请"
|
invite: "邀请"
|
||||||
driveCapacityPerLocalAccount: "每个用户的网盘容量"
|
driveCapacityPerLocalAccount: "每个用户的网盘容量"
|
||||||
driveCapacityPerRemoteAccount: "每个远程用户的网盘容量"
|
driveCapacityPerRemoteAccount: "每个远程用户的网盘容量"
|
||||||
|
@ -587,6 +586,7 @@ masterVolume: "主音量"
|
||||||
notUseSound: "静音"
|
notUseSound: "静音"
|
||||||
useSoundOnlyWhenActive: "仅在 Misskey 活跃时输出声音"
|
useSoundOnlyWhenActive: "仅在 Misskey 活跃时输出声音"
|
||||||
details: "详情"
|
details: "详情"
|
||||||
|
renoteDetails: "转帖详情"
|
||||||
chooseEmoji: "选择表情符号"
|
chooseEmoji: "选择表情符号"
|
||||||
unableToProcess: "操作无法完成"
|
unableToProcess: "操作无法完成"
|
||||||
recentUsed: "最近使用"
|
recentUsed: "最近使用"
|
||||||
|
@ -683,11 +683,11 @@ emptyToDisableSmtpAuth: "用户名和密码留空可以禁用 SMTP 验证"
|
||||||
smtpSecure: "在 SMTP 连接中使用隐式 SSL / TLS"
|
smtpSecure: "在 SMTP 连接中使用隐式 SSL / TLS"
|
||||||
smtpSecureInfo: "使用 STARTTLS 时关闭。"
|
smtpSecureInfo: "使用 STARTTLS 时关闭。"
|
||||||
testEmail: "邮件发送测试"
|
testEmail: "邮件发送测试"
|
||||||
wordMute: "文字屏蔽"
|
wordMute: "隐藏文字"
|
||||||
hardWordMute: "屏蔽关键词"
|
hardWordMute: "屏蔽关键词"
|
||||||
regexpError: "正则表达式错误"
|
regexpError: "正则表达式错误"
|
||||||
regexpErrorDescription: "{tab} 屏蔽文字的第 {line} 行的正则表达式有错误:"
|
regexpErrorDescription: "{tab} 屏蔽文字的第 {line} 行的正则表达式有错误:"
|
||||||
instanceMute: "被屏蔽的服务器"
|
instanceMute: "已隐藏的服务器"
|
||||||
userSaysSomething: "{name} 说了什么,但是被屏蔽词过滤了"
|
userSaysSomething: "{name} 说了什么,但是被屏蔽词过滤了"
|
||||||
makeActive: "启用"
|
makeActive: "启用"
|
||||||
display: "显示"
|
display: "显示"
|
||||||
|
@ -915,8 +915,8 @@ manageAccounts: "管理账户"
|
||||||
makeReactionsPublic: "将回应设置为公开"
|
makeReactionsPublic: "将回应设置为公开"
|
||||||
makeReactionsPublicDescription: "将您发表过的回应设置成公开可见。"
|
makeReactionsPublicDescription: "将您发表过的回应设置成公开可见。"
|
||||||
classic: "经典"
|
classic: "经典"
|
||||||
muteThread: "屏蔽帖子列表"
|
muteThread: "隐藏帖子列表"
|
||||||
unmuteThread: "取消屏蔽帖子列表"
|
unmuteThread: "取消隐藏帖子列表"
|
||||||
followingVisibility: "关注的人的公开范围"
|
followingVisibility: "关注的人的公开范围"
|
||||||
followersVisibility: "关注者的公开范围"
|
followersVisibility: "关注者的公开范围"
|
||||||
continueThread: "查看更多帖子"
|
continueThread: "查看更多帖子"
|
||||||
|
@ -939,7 +939,7 @@ searchByGoogle: "Google"
|
||||||
instanceDefaultLightTheme: "服务器默认浅色主题"
|
instanceDefaultLightTheme: "服务器默认浅色主题"
|
||||||
instanceDefaultDarkTheme: "服务器默认深色主题"
|
instanceDefaultDarkTheme: "服务器默认深色主题"
|
||||||
instanceDefaultThemeDescription: "以对象格式输入主题代码"
|
instanceDefaultThemeDescription: "以对象格式输入主题代码"
|
||||||
mutePeriod: "屏蔽期限"
|
mutePeriod: "隐藏期限"
|
||||||
period: "截止时间"
|
period: "截止时间"
|
||||||
indefinitely: "永久"
|
indefinitely: "永久"
|
||||||
tenMinutes: "10 分钟"
|
tenMinutes: "10 分钟"
|
||||||
|
@ -1300,6 +1300,7 @@ thisContentsAreMarkedAsSigninRequiredByAuthor: "根据发帖者的设定,需
|
||||||
lockdown: "锁定"
|
lockdown: "锁定"
|
||||||
pleaseSelectAccount: "请选择帐户"
|
pleaseSelectAccount: "请选择帐户"
|
||||||
availableRoles: "可用角色"
|
availableRoles: "可用角色"
|
||||||
|
acknowledgeNotesAndEnable: "理解注意事项后再开启。"
|
||||||
_accountSettings:
|
_accountSettings:
|
||||||
requireSigninToViewContents: "需要登录才能显示内容"
|
requireSigninToViewContents: "需要登录才能显示内容"
|
||||||
requireSigninToViewContentsDescription1: "您发布的所有帖子将变成需要登入后才会显示。有望防止爬虫收集各种信息。"
|
requireSigninToViewContentsDescription1: "您发布的所有帖子将变成需要登入后才会显示。有望防止爬虫收集各种信息。"
|
||||||
|
@ -1456,6 +1457,8 @@ _serverSettings:
|
||||||
reactionsBufferingDescription: "开启时可显著提高发送回应时的性能,及减轻数据库负荷。但 Redis 的内存用量会相应增加。"
|
reactionsBufferingDescription: "开启时可显著提高发送回应时的性能,及减轻数据库负荷。但 Redis 的内存用量会相应增加。"
|
||||||
inquiryUrl: "联络地址"
|
inquiryUrl: "联络地址"
|
||||||
inquiryUrlDescription: "用来指定诸如向服务运营商咨询的论坛地址,或记载了运营商联系方式之类的网页地址。"
|
inquiryUrlDescription: "用来指定诸如向服务运营商咨询的论坛地址,或记载了运营商联系方式之类的网页地址。"
|
||||||
|
openRegistration: "开放注册"
|
||||||
|
openRegistrationWarning: "开放注册有风险。建议仅当能够持续监控服务器并在出现问题时能够立即响应时才打开它。"
|
||||||
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "若在一段时间内没有检测到管理活动,为防止垃圾信息,此设定将自动关闭。"
|
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "若在一段时间内没有检测到管理活动,为防止垃圾信息,此设定将自动关闭。"
|
||||||
_accountMigration:
|
_accountMigration:
|
||||||
moveFrom: "从别的账号迁移到此账户"
|
moveFrom: "从别的账号迁移到此账户"
|
||||||
|
@ -1704,9 +1707,9 @@ _achievements:
|
||||||
description: "在元旦登入"
|
description: "在元旦登入"
|
||||||
flavor: "今年也请对本服务器多多指教!"
|
flavor: "今年也请对本服务器多多指教!"
|
||||||
_cookieClicked:
|
_cookieClicked:
|
||||||
title: "点击饼干小游戏"
|
title: "饼干点点乐"
|
||||||
description: "点击了饼干"
|
description: "点击了饼干"
|
||||||
flavor: "用错软件了?"
|
flavor: "穿越了?"
|
||||||
_brainDiver:
|
_brainDiver:
|
||||||
title: "Brain Diver"
|
title: "Brain Diver"
|
||||||
description: "发布了包含 Brain Diver 链接的帖子"
|
description: "发布了包含 Brain Diver 链接的帖子"
|
||||||
|
@ -1776,7 +1779,7 @@ _role:
|
||||||
canUpdateBioMedia: "可以更新头像和横幅"
|
canUpdateBioMedia: "可以更新头像和横幅"
|
||||||
pinMax: "帖子置顶数量限制"
|
pinMax: "帖子置顶数量限制"
|
||||||
antennaMax: "可创建的最大天线数量"
|
antennaMax: "可创建的最大天线数量"
|
||||||
wordMuteMax: "屏蔽词的字数限制"
|
wordMuteMax: "隐藏词的字数限制"
|
||||||
webhookMax: "Webhook 创建数量限制"
|
webhookMax: "Webhook 创建数量限制"
|
||||||
clipMax: "便签创建数量限制"
|
clipMax: "便签创建数量限制"
|
||||||
noteEachClipsMax: "单个便签内的贴文数量限制"
|
noteEachClipsMax: "单个便签内的贴文数量限制"
|
||||||
|
@ -1789,7 +1792,7 @@ _role:
|
||||||
canUseTranslator: "使用翻译功能"
|
canUseTranslator: "使用翻译功能"
|
||||||
avatarDecorationLimit: "可添加头像挂件的最大个数"
|
avatarDecorationLimit: "可添加头像挂件的最大个数"
|
||||||
canImportAntennas: "允许导入天线"
|
canImportAntennas: "允许导入天线"
|
||||||
canImportBlocking: "允许导入拉黑列表"
|
canImportBlocking: "允许导入屏蔽列表"
|
||||||
canImportFollowing: "允许导入关注列表"
|
canImportFollowing: "允许导入关注列表"
|
||||||
canImportMuting: "允许导入屏蔽列表"
|
canImportMuting: "允许导入屏蔽列表"
|
||||||
canImportUserLists: "允许导入用户列表"
|
canImportUserLists: "允许导入用户列表"
|
||||||
|
@ -1939,14 +1942,14 @@ _menuDisplay:
|
||||||
top: "顶部"
|
top: "顶部"
|
||||||
hide: "隐藏"
|
hide: "隐藏"
|
||||||
_wordMute:
|
_wordMute:
|
||||||
muteWords: "禁用词"
|
muteWords: "要隐藏的词"
|
||||||
muteWordsDescription: "AND 条件用空格分隔,OR 条件用换行符分隔。"
|
muteWordsDescription: "AND 条件用空格分隔,OR 条件用换行符分隔。"
|
||||||
muteWordsDescription2: "正则表达式用斜线包裹"
|
muteWordsDescription2: "正则表达式用斜线包裹"
|
||||||
_instanceMute:
|
_instanceMute:
|
||||||
instanceMuteDescription: "屏蔽服务器中的所有帖子和转帖,包括这些服务器上的用户回复。"
|
instanceMuteDescription: "隐藏服务器中的所有帖子和转帖,包括这些服务器上的用户回复。"
|
||||||
instanceMuteDescription2: "一行一个"
|
instanceMuteDescription2: "一行一个"
|
||||||
title: "隐藏服务器已设置的帖子。"
|
title: "隐藏服务器已设置的帖子。"
|
||||||
heading: "屏蔽服务器"
|
heading: "已隐藏的服务器"
|
||||||
_theme:
|
_theme:
|
||||||
explore: "寻找主题"
|
explore: "寻找主题"
|
||||||
install: "安装主题"
|
install: "安装主题"
|
||||||
|
@ -2086,8 +2089,8 @@ _2fa:
|
||||||
_permissions:
|
_permissions:
|
||||||
"read:account": "查看账户信息"
|
"read:account": "查看账户信息"
|
||||||
"write:account": "更改帐户信息"
|
"write:account": "更改帐户信息"
|
||||||
"read:blocks": "查看黑名单"
|
"read:blocks": "查看屏蔽列表"
|
||||||
"write:blocks": "编辑黑名单"
|
"write:blocks": "编辑屏蔽列表"
|
||||||
"read:drive": "查看网盘"
|
"read:drive": "查看网盘"
|
||||||
"write:drive": "管理网盘文件"
|
"write:drive": "管理网盘文件"
|
||||||
"read:favorites": "查看收藏夹"
|
"read:favorites": "查看收藏夹"
|
||||||
|
@ -2096,8 +2099,8 @@ _permissions:
|
||||||
"write:following": "关注/取消关注"
|
"write:following": "关注/取消关注"
|
||||||
"read:messaging": "查看消息"
|
"read:messaging": "查看消息"
|
||||||
"write:messaging": "撰写或删除消息"
|
"write:messaging": "撰写或删除消息"
|
||||||
"read:mutes": "查看屏蔽列表"
|
"read:mutes": "查看隐藏列表"
|
||||||
"write:mutes": "编辑屏蔽列表"
|
"write:mutes": "编辑隐藏列表"
|
||||||
"write:notes": "撰写或删除帖子"
|
"write:notes": "撰写或删除帖子"
|
||||||
"read:notifications": "查看通知"
|
"read:notifications": "查看通知"
|
||||||
"write:notifications": "管理通知"
|
"write:notifications": "管理通知"
|
||||||
|
@ -2297,8 +2300,8 @@ _exportOrImport:
|
||||||
favoritedNotes: "收藏的帖子"
|
favoritedNotes: "收藏的帖子"
|
||||||
clips: "便签"
|
clips: "便签"
|
||||||
followingList: "关注中"
|
followingList: "关注中"
|
||||||
muteList: "屏蔽"
|
muteList: "隐藏"
|
||||||
blockingList: "拉黑"
|
blockingList: "屏蔽"
|
||||||
userLists: "列表"
|
userLists: "列表"
|
||||||
excludeMutingUsers: "排除屏蔽用户"
|
excludeMutingUsers: "排除屏蔽用户"
|
||||||
excludeInactiveUsers: "排除不活跃用户"
|
excludeInactiveUsers: "排除不活跃用户"
|
||||||
|
@ -2738,3 +2741,6 @@ _selfXssPrevention:
|
||||||
description1: "如果在此处粘贴了什么,恶意用户可能会接管账户或者盗取个人资料。"
|
description1: "如果在此处粘贴了什么,恶意用户可能会接管账户或者盗取个人资料。"
|
||||||
description2: "如果不能完全理解将要粘贴的内容,%c 请立即停止操作并关闭这个窗口。"
|
description2: "如果不能完全理解将要粘贴的内容,%c 请立即停止操作并关闭这个窗口。"
|
||||||
description3: "详情请看这里。{link}"
|
description3: "详情请看这里。{link}"
|
||||||
|
_followRequest:
|
||||||
|
recieved: "已收到申请"
|
||||||
|
sent: "已发送申请"
|
||||||
|
|
|
@ -382,7 +382,6 @@ enableLocalTimeline: "啟用本地時間軸"
|
||||||
enableGlobalTimeline: "啟用全域時間軸"
|
enableGlobalTimeline: "啟用全域時間軸"
|
||||||
disablingTimelinesInfo: "為了方便,即使您關閉了時間軸功能,管理員和審查員仍可以繼續使用。"
|
disablingTimelinesInfo: "為了方便,即使您關閉了時間軸功能,管理員和審查員仍可以繼續使用。"
|
||||||
registration: "註冊"
|
registration: "註冊"
|
||||||
enableRegistration: "開放新使用者註冊"
|
|
||||||
invite: "邀請"
|
invite: "邀請"
|
||||||
driveCapacityPerLocalAccount: "每個本地使用者的雲端硬碟容量"
|
driveCapacityPerLocalAccount: "每個本地使用者的雲端硬碟容量"
|
||||||
driveCapacityPerRemoteAccount: "每個非本地用戶的雲端空間大小"
|
driveCapacityPerRemoteAccount: "每個非本地用戶的雲端空間大小"
|
||||||
|
@ -587,6 +586,7 @@ masterVolume: "主音量"
|
||||||
notUseSound: "關閉音效"
|
notUseSound: "關閉音效"
|
||||||
useSoundOnlyWhenActive: "瀏覽器在前景運作時,Misskey 才會發出音效"
|
useSoundOnlyWhenActive: "瀏覽器在前景運作時,Misskey 才會發出音效"
|
||||||
details: "詳細資訊"
|
details: "詳細資訊"
|
||||||
|
renoteDetails: "轉發貼文的細節"
|
||||||
chooseEmoji: "選擇您的表情符號"
|
chooseEmoji: "選擇您的表情符號"
|
||||||
unableToProcess: "操作無法完成"
|
unableToProcess: "操作無法完成"
|
||||||
recentUsed: "最近使用"
|
recentUsed: "最近使用"
|
||||||
|
@ -1119,7 +1119,7 @@ vertical: "直向"
|
||||||
horizontal: "橫向"
|
horizontal: "橫向"
|
||||||
position: "位置"
|
position: "位置"
|
||||||
serverRules: "伺服器規則"
|
serverRules: "伺服器規則"
|
||||||
pleaseConfirmBelowBeforeSignup: "在本伺服器註冊之前,請確認下列事項。"
|
pleaseConfirmBelowBeforeSignup: "在本伺服器註冊之前,必須確認並同意以下內容。"
|
||||||
pleaseAgreeAllToContinue: "必須全部勾選「同意」才能繼續。"
|
pleaseAgreeAllToContinue: "必須全部勾選「同意」才能繼續。"
|
||||||
continue: "繼續"
|
continue: "繼續"
|
||||||
preservedUsernames: "保留的使用者名稱"
|
preservedUsernames: "保留的使用者名稱"
|
||||||
|
@ -1300,6 +1300,7 @@ thisContentsAreMarkedAsSigninRequiredByAuthor: "作者將其設定為需要登
|
||||||
lockdown: "鎖定"
|
lockdown: "鎖定"
|
||||||
pleaseSelectAccount: "請選擇帳戶"
|
pleaseSelectAccount: "請選擇帳戶"
|
||||||
availableRoles: "可用角色"
|
availableRoles: "可用角色"
|
||||||
|
acknowledgeNotesAndEnable: "了解注意事項後再開啟。"
|
||||||
_accountSettings:
|
_accountSettings:
|
||||||
requireSigninToViewContents: "須登入以顯示內容"
|
requireSigninToViewContents: "須登入以顯示內容"
|
||||||
requireSigninToViewContentsDescription1: "必須登入才會顯示您建立的貼文等內容。可望有效防止資訊被爬蟲蒐集。"
|
requireSigninToViewContentsDescription1: "必須登入才會顯示您建立的貼文等內容。可望有效防止資訊被爬蟲蒐集。"
|
||||||
|
@ -1456,6 +1457,8 @@ _serverSettings:
|
||||||
reactionsBufferingDescription: "啟用時,可以顯著提高建立反應時的效能並減少資料庫的負載。 但是,Redis 記憶體使用量會增加。"
|
reactionsBufferingDescription: "啟用時,可以顯著提高建立反應時的效能並減少資料庫的負載。 但是,Redis 記憶體使用量會增加。"
|
||||||
inquiryUrl: "聯絡表單網址"
|
inquiryUrl: "聯絡表單網址"
|
||||||
inquiryUrlDescription: "指定伺服器運營者的聯絡表單網址,或包含運營者聯絡資訊網頁的網址。"
|
inquiryUrlDescription: "指定伺服器運營者的聯絡表單網址,或包含運營者聯絡資訊網頁的網址。"
|
||||||
|
openRegistration: "允許建立帳戶"
|
||||||
|
openRegistrationWarning: "開放註冊伴隨著風險。 建議只有在伺服器受到持續監控,並準備好在出現問題時能立即處理的情況下才開放註冊。"
|
||||||
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "為了防止 spam,如果一段期間內沒有偵測到審查員的活動,此設定將自動關閉。"
|
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "為了防止 spam,如果一段期間內沒有偵測到審查員的活動,此設定將自動關閉。"
|
||||||
_accountMigration:
|
_accountMigration:
|
||||||
moveFrom: "從其他帳戶遷移到這個帳戶"
|
moveFrom: "從其他帳戶遷移到這個帳戶"
|
||||||
|
@ -2738,3 +2741,6 @@ _selfXssPrevention:
|
||||||
description1: "如果您在此處貼上任何內容,惡意使用者可能會接管您的帳戶或竊取您的個人資訊。"
|
description1: "如果您在此處貼上任何內容,惡意使用者可能會接管您的帳戶或竊取您的個人資訊。"
|
||||||
description2: "如果您不確切知道要貼上的內容,%c 請立即停止工作並關閉此視窗。"
|
description2: "如果您不確切知道要貼上的內容,%c 請立即停止工作並關閉此視窗。"
|
||||||
description3: "細節請看這裡。{link}"
|
description3: "細節請看這裡。{link}"
|
||||||
|
_followRequest:
|
||||||
|
recieved: "收到的請求"
|
||||||
|
sent: "送出的請求"
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "misskey",
|
"name": "misskey",
|
||||||
"version": "2024.11.0-alpha.1",
|
"version": "2024.11.0",
|
||||||
"codename": "nasubi",
|
"codename": "nasubi",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
|
@ -7,6 +7,7 @@ const base = require('./jest.config.cjs')
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
...base,
|
...base,
|
||||||
|
globalSetup: "<rootDir>/test/jest.setup.unit.cjs",
|
||||||
testMatch: [
|
testMatch: [
|
||||||
"<rootDir>/test/unit/**/*.ts",
|
"<rootDir>/test/unit/**/*.ts",
|
||||||
"<rootDir>/src/**/*.test.ts",
|
"<rootDir>/src/**/*.test.ts",
|
||||||
|
|
|
@ -0,0 +1,42 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class AddChannelMuting1718015380000 {
|
||||||
|
name = 'AddChannelMuting1718015380000'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE "channel_muting"
|
||||||
|
(
|
||||||
|
"id" varchar(32) NOT NULL,
|
||||||
|
"userId" varchar(32) NOT NULL,
|
||||||
|
"channelId" varchar(32) NOT NULL,
|
||||||
|
"expiresAt" timestamp with time zone,
|
||||||
|
CONSTRAINT "PK_channel_muting_id" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "FK_channel_muting_userId" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||||
|
CONSTRAINT "FK_channel_muting_channelId" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION
|
||||||
|
);
|
||||||
|
CREATE INDEX "IDX_channel_muting_userId" ON "channel_muting" ("userId");
|
||||||
|
CREATE INDEX "IDX_channel_muting_channelId" ON "channel_muting" ("channelId");
|
||||||
|
|
||||||
|
ALTER TABLE note ADD "renoteChannelId" varchar(32);
|
||||||
|
COMMENT ON COLUMN note."renoteChannelId" is '[Denormalized]';
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE note DROP COLUMN "renoteChannelId";
|
||||||
|
|
||||||
|
ALTER TABLE "channel_muting"
|
||||||
|
DROP CONSTRAINT "FK_channel_muting_userId";
|
||||||
|
ALTER TABLE "channel_muting"
|
||||||
|
DROP CONSTRAINT "FK_channel_muting_channelId";
|
||||||
|
DROP INDEX "IDX_channel_muting_userId";
|
||||||
|
DROP INDEX "IDX_channel_muting_channelId";
|
||||||
|
DROP TABLE "channel_muting";
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -154,9 +154,9 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
||||||
const convertedReports = abuseReports.map(it => {
|
const convertedReports = abuseReports.map(it => {
|
||||||
return {
|
return {
|
||||||
...it,
|
...it,
|
||||||
reporter: usersMap.get(it.reporterId),
|
reporter: usersMap.get(it.reporterId) ?? null,
|
||||||
targetUser: usersMap.get(it.targetUserId),
|
targetUser: usersMap.get(it.targetUserId) ?? null,
|
||||||
assignee: it.assigneeId ? usersMap.get(it.assigneeId) : null,
|
assignee: it.assigneeId ? (usersMap.get(it.assigneeId) ?? null) : null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -72,7 +72,7 @@ export class AnnouncementService {
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
title: values.title,
|
title: values.title,
|
||||||
text: values.text,
|
text: values.text,
|
||||||
imageUrl: values.imageUrl,
|
imageUrl: values.imageUrl || null,
|
||||||
icon: values.icon,
|
icon: values.icon,
|
||||||
display: values.display,
|
display: values.display,
|
||||||
forExistingUsers: values.forExistingUsers,
|
forExistingUsers: values.forExistingUsers,
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
|
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
|
||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { ChannelFollowingsRepository } from '@/models/_.js';
|
import type { ChannelFollowingsRepository, ChannelsRepository, MiUser } from '@/models/_.js';
|
||||||
import { MiChannel } from '@/models/_.js';
|
import { MiChannel } from '@/models/_.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js';
|
import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
|
@ -23,6 +23,8 @@ export class ChannelFollowingService implements OnModuleInit {
|
||||||
private redisClient: Redis.Redis,
|
private redisClient: Redis.Redis,
|
||||||
@Inject(DI.redisForSub)
|
@Inject(DI.redisForSub)
|
||||||
private redisForSub: Redis.Redis,
|
private redisForSub: Redis.Redis,
|
||||||
|
@Inject(DI.channelsRepository)
|
||||||
|
private channelsRepository: ChannelsRepository,
|
||||||
@Inject(DI.channelFollowingsRepository)
|
@Inject(DI.channelFollowingsRepository)
|
||||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
|
@ -45,6 +47,50 @@ export class ChannelFollowingService implements OnModuleInit {
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* フォローしているチャンネルの一覧を取得する.
|
||||||
|
* @param params
|
||||||
|
* @param [opts]
|
||||||
|
* @param {(boolean|undefined)} [opts.idOnly=false] チャンネルIDのみを取得するかどうか. ID以外のフィールドに値がセットされなくなり、他テーブルとのJOINも一切されなくなるので注意.
|
||||||
|
* @param {(boolean|undefined)} [opts.joinUser=undefined] チャンネルオーナーのユーザ情報をJOINするかどうか(falseまたは省略時はJOINしない).
|
||||||
|
* @param {(boolean|undefined)} [opts.joinBannerFile=undefined] バナー画像のドライブファイルをJOINするかどうか(falseまたは省略時はJOINしない).
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public async list(
|
||||||
|
params: {
|
||||||
|
requestUserId: MiUser['id'],
|
||||||
|
},
|
||||||
|
opts?: {
|
||||||
|
idOnly?: boolean;
|
||||||
|
joinUser?: boolean;
|
||||||
|
joinBannerFile?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<MiChannel[]> {
|
||||||
|
if (opts?.idOnly) {
|
||||||
|
const q = this.channelFollowingsRepository.createQueryBuilder('channel_following')
|
||||||
|
.select('channel_following.followeeId')
|
||||||
|
.where('channel_following.followerId = :userId', { userId: params.requestUserId });
|
||||||
|
|
||||||
|
return q
|
||||||
|
.getRawMany<{ channel_following_followeeId: string }>()
|
||||||
|
.then(xs => xs.map(x => ({ id: x.channel_following_followeeId } as MiChannel)));
|
||||||
|
} else {
|
||||||
|
const q = this.channelsRepository.createQueryBuilder('channel')
|
||||||
|
.innerJoin('channel_following', 'channel_following', 'channel_following.followeeId = channel.id')
|
||||||
|
.where('channel_following.followerId = :userId', { userId: params.requestUserId });
|
||||||
|
|
||||||
|
if (opts?.joinUser) {
|
||||||
|
q.innerJoinAndSelect('channel.user', 'user');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts?.joinBannerFile) {
|
||||||
|
q.leftJoinAndSelect('channel.banner', 'drive_file');
|
||||||
|
}
|
||||||
|
|
||||||
|
return q.getMany();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async follow(
|
public async follow(
|
||||||
requestUser: MiLocalUser,
|
requestUser: MiLocalUser,
|
||||||
|
|
|
@ -0,0 +1,224 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { Brackets, In } from 'typeorm';
|
||||||
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import type { ChannelMutingRepository, ChannelsRepository, MiChannel, MiChannelMuting, MiUser } from '@/models/_.js';
|
||||||
|
import { IdService } from '@/core/IdService.js';
|
||||||
|
import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
|
import { bindThis } from '@/decorators.js';
|
||||||
|
import { RedisKVCache } from '@/misc/cache.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ChannelMutingService {
|
||||||
|
public mutingChannelsCache: RedisKVCache<Set<string>>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(DI.redis)
|
||||||
|
private redisClient: Redis.Redis,
|
||||||
|
@Inject(DI.redisForSub)
|
||||||
|
private redisForSub: Redis.Redis,
|
||||||
|
@Inject(DI.channelsRepository)
|
||||||
|
private channelsRepository: ChannelsRepository,
|
||||||
|
@Inject(DI.channelMutingRepository)
|
||||||
|
private channelMutingRepository: ChannelMutingRepository,
|
||||||
|
private idService: IdService,
|
||||||
|
private globalEventService: GlobalEventService,
|
||||||
|
) {
|
||||||
|
this.mutingChannelsCache = new RedisKVCache<Set<string>>(this.redisClient, 'channelMutingChannels', {
|
||||||
|
lifetime: 1000 * 60 * 30, // 30m
|
||||||
|
memoryCacheLifetime: 1000 * 60, // 1m
|
||||||
|
fetcher: (userId) => this.channelMutingRepository.find({
|
||||||
|
where: { userId: userId },
|
||||||
|
select: ['channelId'],
|
||||||
|
}).then(xs => new Set(xs.map(x => x.channelId))),
|
||||||
|
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
|
||||||
|
fromRedisConverter: (value) => new Set(JSON.parse(value)),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.redisForSub.on('message', this.onMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ミュートしているチャンネルの一覧を取得する.
|
||||||
|
* @param params
|
||||||
|
* @param [opts]
|
||||||
|
* @param {(boolean|undefined)} [opts.idOnly=false] チャンネルIDのみを取得するかどうか. ID以外のフィールドに値がセットされなくなり、他テーブルとのJOINも一切されなくなるので注意.
|
||||||
|
* @param {(boolean|undefined)} [opts.joinUser=undefined] チャンネルオーナーのユーザ情報をJOINするかどうか(falseまたは省略時はJOINしない).
|
||||||
|
* @param {(boolean|undefined)} [opts.joinBannerFile=undefined] バナー画像のドライブファイルをJOINするかどうか(falseまたは省略時はJOINしない).
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public async list(
|
||||||
|
params: {
|
||||||
|
requestUserId: MiUser['id'],
|
||||||
|
},
|
||||||
|
opts?: {
|
||||||
|
idOnly?: boolean;
|
||||||
|
joinUser?: boolean;
|
||||||
|
joinBannerFile?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<MiChannel[]> {
|
||||||
|
if (opts?.idOnly) {
|
||||||
|
const q = this.channelMutingRepository.createQueryBuilder('channel_muting')
|
||||||
|
.select('channel_muting.channelId')
|
||||||
|
.where('channel_muting.userId = :userId', { userId: params.requestUserId })
|
||||||
|
.andWhere(new Brackets(qb => {
|
||||||
|
qb.where('channel_muting.expiresAt IS NULL')
|
||||||
|
.orWhere('channel_muting.expiresAt > :now', { now: new Date() });
|
||||||
|
}));
|
||||||
|
|
||||||
|
return q
|
||||||
|
.getRawMany<{ channel_muting_channelId: string }>()
|
||||||
|
.then(xs => xs.map(x => ({ id: x.channel_muting_channelId } as MiChannel)));
|
||||||
|
} else {
|
||||||
|
const q = this.channelsRepository.createQueryBuilder('channel')
|
||||||
|
.innerJoin('channel_muting', 'channel_muting', 'channel_muting.channelId = channel.id')
|
||||||
|
.where('channel_muting.userId = :userId', { userId: params.requestUserId })
|
||||||
|
.andWhere(new Brackets(qb => {
|
||||||
|
qb.where('channel_muting.expiresAt IS NULL')
|
||||||
|
.orWhere('channel_muting.expiresAt > :now', { now: new Date() });
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (opts?.joinUser) {
|
||||||
|
q.innerJoinAndSelect('channel.user', 'user');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts?.joinBannerFile) {
|
||||||
|
q.leftJoinAndSelect('channel.banner', 'drive_file');
|
||||||
|
}
|
||||||
|
|
||||||
|
return q.getMany();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 期限切れのチャンネルミュート情報を取得する.
|
||||||
|
*
|
||||||
|
* @param [opts]
|
||||||
|
* @param {(boolean|undefined)} [opts.joinUser=undefined] チャンネルミュートを設定したユーザ情報をJOINするかどうか(falseまたは省略時はJOINしない).
|
||||||
|
* @param {(boolean|undefined)} [opts.joinChannel=undefined] ミュート先のチャンネル情報をJOINするかどうか(falseまたは省略時はJOINしない).
|
||||||
|
*/
|
||||||
|
public async findExpiredMutings(opts?: {
|
||||||
|
joinUser?: boolean;
|
||||||
|
joinChannel?: boolean;
|
||||||
|
}): Promise<MiChannelMuting[]> {
|
||||||
|
const now = new Date();
|
||||||
|
const q = this.channelMutingRepository.createQueryBuilder('channel_muting')
|
||||||
|
.where('channel_muting.expiresAt < :now', { now });
|
||||||
|
|
||||||
|
if (opts?.joinUser) {
|
||||||
|
q.innerJoinAndSelect('channel_muting.user', 'user');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts?.joinChannel) {
|
||||||
|
q.leftJoinAndSelect('channel_muting.channel', 'channel');
|
||||||
|
}
|
||||||
|
|
||||||
|
return q.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 既にミュートされているかどうかをキャッシュから取得する.
|
||||||
|
* @param params
|
||||||
|
* @param params.requestUserId
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public async isMuted(params: {
|
||||||
|
requestUserId: MiUser['id'],
|
||||||
|
targetChannelId: MiChannel['id'],
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const mutedChannels = await this.mutingChannelsCache.get(params.requestUserId);
|
||||||
|
return (mutedChannels?.has(params.targetChannelId) ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* チャンネルをミュートする.
|
||||||
|
* @param params
|
||||||
|
* @param {(Date|null|undefined)} [params.expiresAt] ミュートの有効期限. nullまたは省略時は無期限.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public async mute(params: {
|
||||||
|
requestUserId: MiUser['id'],
|
||||||
|
targetChannelId: MiChannel['id'],
|
||||||
|
expiresAt?: Date | null,
|
||||||
|
}): Promise<void> {
|
||||||
|
await this.channelMutingRepository.insert({
|
||||||
|
id: this.idService.gen(),
|
||||||
|
userId: params.requestUserId,
|
||||||
|
channelId: params.targetChannelId,
|
||||||
|
expiresAt: params.expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.globalEventService.publishInternalEvent('muteChannel', {
|
||||||
|
userId: params.requestUserId,
|
||||||
|
channelId: params.targetChannelId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* チャンネルのミュートを解除する.
|
||||||
|
* @param params
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public async unmute(params: {
|
||||||
|
requestUserId: MiUser['id'],
|
||||||
|
targetChannelId: MiChannel['id'],
|
||||||
|
}): Promise<void> {
|
||||||
|
await this.channelMutingRepository.delete({
|
||||||
|
userId: params.requestUserId,
|
||||||
|
channelId: params.targetChannelId,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.globalEventService.publishInternalEvent('unmuteChannel', {
|
||||||
|
userId: params.requestUserId,
|
||||||
|
channelId: params.targetChannelId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 期限切れのチャンネルミュート情報を削除する.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public async eraseExpiredMutings(): Promise<void> {
|
||||||
|
const expiredMutings = await this.findExpiredMutings();
|
||||||
|
await this.channelMutingRepository.delete({ id: In(expiredMutings.map(x => x.id)) });
|
||||||
|
|
||||||
|
const userIds = [...new Set(expiredMutings.map(x => x.userId))];
|
||||||
|
for (const userId of userIds) {
|
||||||
|
this.mutingChannelsCache.refresh(userId).then();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private async onMessage(_: string, data: string): Promise<void> {
|
||||||
|
const obj = JSON.parse(data);
|
||||||
|
|
||||||
|
if (obj.channel === 'internal') {
|
||||||
|
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
||||||
|
switch (type) {
|
||||||
|
case 'muteChannel': {
|
||||||
|
this.mutingChannelsCache.refresh(body.userId).then();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'unmuteChannel': {
|
||||||
|
this.mutingChannelsCache.delete(body.userId).then();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public dispose(): void {
|
||||||
|
this.mutingChannelsCache.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public onApplicationShutdown(signal?: string | undefined): void {
|
||||||
|
this.dispose();
|
||||||
|
}
|
||||||
|
}
|
|
@ -15,6 +15,7 @@ import { SystemWebhookService } from '@/core/SystemWebhookService.js';
|
||||||
import { UserSearchService } from '@/core/UserSearchService.js';
|
import { UserSearchService } from '@/core/UserSearchService.js';
|
||||||
import { WebhookTestService } from '@/core/WebhookTestService.js';
|
import { WebhookTestService } from '@/core/WebhookTestService.js';
|
||||||
import { FlashService } from '@/core/FlashService.js';
|
import { FlashService } from '@/core/FlashService.js';
|
||||||
|
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||||
import { AccountMoveService } from './AccountMoveService.js';
|
import { AccountMoveService } from './AccountMoveService.js';
|
||||||
import { AccountUpdateService } from './AccountUpdateService.js';
|
import { AccountUpdateService } from './AccountUpdateService.js';
|
||||||
import { AiService } from './AiService.js';
|
import { AiService } from './AiService.js';
|
||||||
|
@ -225,6 +226,7 @@ const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: Fe
|
||||||
const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', useExisting: FanoutTimelineService };
|
const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', useExisting: FanoutTimelineService };
|
||||||
const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpointService', useExisting: FanoutTimelineEndpointService };
|
const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpointService', useExisting: FanoutTimelineEndpointService };
|
||||||
const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService };
|
const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService };
|
||||||
|
const $ChannelMutingService: Provider = { provide: 'ChannelMutingService', useExisting: ChannelMutingService };
|
||||||
const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
|
const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
|
||||||
const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService };
|
const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService };
|
||||||
|
|
||||||
|
@ -376,6 +378,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
FanoutTimelineService,
|
FanoutTimelineService,
|
||||||
FanoutTimelineEndpointService,
|
FanoutTimelineEndpointService,
|
||||||
ChannelFollowingService,
|
ChannelFollowingService,
|
||||||
|
ChannelMutingService,
|
||||||
RegistryApiService,
|
RegistryApiService,
|
||||||
ReversiService,
|
ReversiService,
|
||||||
|
|
||||||
|
@ -523,6 +526,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
$FanoutTimelineService,
|
$FanoutTimelineService,
|
||||||
$FanoutTimelineEndpointService,
|
$FanoutTimelineEndpointService,
|
||||||
$ChannelFollowingService,
|
$ChannelFollowingService,
|
||||||
|
$ChannelMutingService,
|
||||||
$RegistryApiService,
|
$RegistryApiService,
|
||||||
$ReversiService,
|
$ReversiService,
|
||||||
|
|
||||||
|
@ -671,6 +675,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
FanoutTimelineService,
|
FanoutTimelineService,
|
||||||
FanoutTimelineEndpointService,
|
FanoutTimelineEndpointService,
|
||||||
ChannelFollowingService,
|
ChannelFollowingService,
|
||||||
|
ChannelMutingService,
|
||||||
RegistryApiService,
|
RegistryApiService,
|
||||||
ReversiService,
|
ReversiService,
|
||||||
|
|
||||||
|
@ -816,6 +821,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
$FanoutTimelineService,
|
$FanoutTimelineService,
|
||||||
$FanoutTimelineEndpointService,
|
$FanoutTimelineEndpointService,
|
||||||
$ChannelFollowingService,
|
$ChannelFollowingService,
|
||||||
|
$ChannelMutingService,
|
||||||
$RegistryApiService,
|
$RegistryApiService,
|
||||||
$ReversiService,
|
$ReversiService,
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,6 @@
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as stream from 'node:stream/promises';
|
import * as stream from 'node:stream/promises';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import ipaddr from 'ipaddr.js';
|
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import got, * as Got from 'got';
|
import got, * as Got from 'got';
|
||||||
import { parse } from 'content-disposition';
|
import { parse } from 'content-disposition';
|
||||||
|
@ -70,13 +69,6 @@ export class DownloadService {
|
||||||
},
|
},
|
||||||
enableUnixSockets: false,
|
enableUnixSockets: false,
|
||||||
}).on('response', (res: Got.Response) => {
|
}).on('response', (res: Got.Response) => {
|
||||||
if ((process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test') && !this.config.proxy && res.ip) {
|
|
||||||
if (this.isPrivateIp(res.ip)) {
|
|
||||||
this.logger.warn(`Blocked address: ${res.ip}`);
|
|
||||||
req.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentLength = res.headers['content-length'];
|
const contentLength = res.headers['content-length'];
|
||||||
if (contentLength != null) {
|
if (contentLength != null) {
|
||||||
const size = Number(contentLength);
|
const size = Number(contentLength);
|
||||||
|
@ -139,18 +131,4 @@ export class DownloadService {
|
||||||
cleanup();
|
cleanup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
|
||||||
private isPrivateIp(ip: string): boolean {
|
|
||||||
const parsedIp = ipaddr.parse(ip);
|
|
||||||
|
|
||||||
for (const net of this.config.allowedPrivateNetworks ?? []) {
|
|
||||||
const cidr = ipaddr.parseCIDR(net);
|
|
||||||
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsedIp.range() !== 'unicast';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -312,6 +312,7 @@ export class EmailService {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
Authorization: truemailAuthKey,
|
Authorization: truemailAuthKey,
|
||||||
},
|
},
|
||||||
|
isLocalAddressAllowed: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const json = (await res.json()) as {
|
const json = (await res.json()) as {
|
||||||
|
|
|
@ -17,6 +17,8 @@ import { isQuote, isRenote } from '@/misc/is-renote.js';
|
||||||
import { CacheService } from '@/core/CacheService.js';
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
import { isReply } from '@/misc/is-reply.js';
|
import { isReply } from '@/misc/is-reply.js';
|
||||||
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
|
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
|
||||||
|
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||||
|
import { isChannelRelated } from '@/misc/is-channel-related.js';
|
||||||
|
|
||||||
type TimelineOptions = {
|
type TimelineOptions = {
|
||||||
untilId: string | null,
|
untilId: string | null,
|
||||||
|
@ -33,6 +35,7 @@ type TimelineOptions = {
|
||||||
excludeNoFiles?: boolean;
|
excludeNoFiles?: boolean;
|
||||||
excludeReplies?: boolean;
|
excludeReplies?: boolean;
|
||||||
excludePureRenotes: boolean;
|
excludePureRenotes: boolean;
|
||||||
|
includeMutedChannels?: boolean;
|
||||||
dbFallback: (untilId: string | null, sinceId: string | null, limit: number) => Promise<MiNote[]>,
|
dbFallback: (untilId: string | null, sinceId: string | null, limit: number) => Promise<MiNote[]>,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -45,6 +48,7 @@ export class FanoutTimelineEndpointService {
|
||||||
private noteEntityService: NoteEntityService,
|
private noteEntityService: NoteEntityService,
|
||||||
private cacheService: CacheService,
|
private cacheService: CacheService,
|
||||||
private fanoutTimelineService: FanoutTimelineService,
|
private fanoutTimelineService: FanoutTimelineService,
|
||||||
|
private channelMutingService: ChannelMutingService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -101,11 +105,13 @@ export class FanoutTimelineEndpointService {
|
||||||
userIdsWhoMeMutingRenotes,
|
userIdsWhoMeMutingRenotes,
|
||||||
userIdsWhoBlockingMe,
|
userIdsWhoBlockingMe,
|
||||||
userMutedInstances,
|
userMutedInstances,
|
||||||
|
userMutedChannels,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.cacheService.userMutingsCache.fetch(ps.me.id),
|
this.cacheService.userMutingsCache.fetch(ps.me.id),
|
||||||
this.cacheService.renoteMutingsCache.fetch(ps.me.id),
|
this.cacheService.renoteMutingsCache.fetch(ps.me.id),
|
||||||
this.cacheService.userBlockedCache.fetch(ps.me.id),
|
this.cacheService.userBlockedCache.fetch(ps.me.id),
|
||||||
this.cacheService.userProfileCache.fetch(me.id).then(p => new Set(p.mutedInstances)),
|
this.cacheService.userProfileCache.fetch(me.id).then(p => new Set(p.mutedInstances)),
|
||||||
|
ps.includeMutedChannels ? Promise.resolve(new Set<string>()) : this.channelMutingService.mutingChannelsCache.fetch(me.id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const parentFilter = filter;
|
const parentFilter = filter;
|
||||||
|
@ -114,6 +120,7 @@ export class FanoutTimelineEndpointService {
|
||||||
if (isUserRelated(note, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false;
|
if (isUserRelated(note, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false;
|
||||||
if (!ps.ignoreAuthorFromMute && isRenote(note) && !isQuote(note) && userIdsWhoMeMutingRenotes.has(note.userId)) return false;
|
if (!ps.ignoreAuthorFromMute && isRenote(note) && !isQuote(note) && userIdsWhoMeMutingRenotes.has(note.userId)) return false;
|
||||||
if (isInstanceMuted(note, userMutedInstances)) return false;
|
if (isInstanceMuted(note, userMutedInstances)) return false;
|
||||||
|
if (!ps.includeMutedChannels && isChannelRelated(note, userMutedChannels)) return false;
|
||||||
|
|
||||||
return parentFilter(note);
|
return parentFilter(note);
|
||||||
};
|
};
|
||||||
|
|
|
@ -244,6 +244,8 @@ export interface InternalEventTypes {
|
||||||
metaUpdated: { before?: MiMeta; after: MiMeta; };
|
metaUpdated: { before?: MiMeta; after: MiMeta; };
|
||||||
followChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
|
followChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
|
||||||
unfollowChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
|
unfollowChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
|
||||||
|
muteChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
|
||||||
|
unmuteChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
|
||||||
updateUserProfile: MiUserProfile;
|
updateUserProfile: MiUserProfile;
|
||||||
mute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
|
mute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
|
||||||
unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
|
unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
import * as http from 'node:http';
|
import * as http from 'node:http';
|
||||||
import * as https from 'node:https';
|
import * as https from 'node:https';
|
||||||
import * as net from 'node:net';
|
import * as net from 'node:net';
|
||||||
|
import ipaddr from 'ipaddr.js';
|
||||||
import CacheableLookup from 'cacheable-lookup';
|
import CacheableLookup from 'cacheable-lookup';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
|
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
|
||||||
|
@ -15,6 +16,7 @@ import type { Config } from '@/config.js';
|
||||||
import { StatusError } from '@/misc/status-error.js';
|
import { StatusError } from '@/misc/status-error.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
||||||
|
import { assertActivityMatchesUrls } from '@/core/activitypub/misc/check-against-url.js';
|
||||||
import type { IObject } from '@/core/activitypub/type.js';
|
import type { IObject } from '@/core/activitypub/type.js';
|
||||||
import type { Response } from 'node-fetch';
|
import type { Response } from 'node-fetch';
|
||||||
import type { URL } from 'node:url';
|
import type { URL } from 'node:url';
|
||||||
|
@ -24,8 +26,102 @@ export type HttpRequestSendOptions = {
|
||||||
validators?: ((res: Response) => void)[];
|
validators?: ((res: Response) => void)[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
declare module 'node:http' {
|
||||||
|
interface Agent {
|
||||||
|
createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class HttpRequestServiceAgent extends http.Agent {
|
||||||
|
constructor(
|
||||||
|
private config: Config,
|
||||||
|
options?: http.AgentOptions,
|
||||||
|
) {
|
||||||
|
super(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket {
|
||||||
|
const socket = super.createConnection(options, callback)
|
||||||
|
.on('connect', () => {
|
||||||
|
const address = socket.remoteAddress;
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
if (address && ipaddr.isValid(address)) {
|
||||||
|
if (this.isPrivateIp(address)) {
|
||||||
|
socket.destroy(new Error(`Blocked address: ${address}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private isPrivateIp(ip: string): boolean {
|
||||||
|
const parsedIp = ipaddr.parse(ip);
|
||||||
|
|
||||||
|
for (const net of this.config.allowedPrivateNetworks ?? []) {
|
||||||
|
const cidr = ipaddr.parseCIDR(net);
|
||||||
|
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedIp.range() !== 'unicast';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class HttpsRequestServiceAgent extends https.Agent {
|
||||||
|
constructor(
|
||||||
|
private config: Config,
|
||||||
|
options?: https.AgentOptions,
|
||||||
|
) {
|
||||||
|
super(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket {
|
||||||
|
const socket = super.createConnection(options, callback)
|
||||||
|
.on('connect', () => {
|
||||||
|
const address = socket.remoteAddress;
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
if (address && ipaddr.isValid(address)) {
|
||||||
|
if (this.isPrivateIp(address)) {
|
||||||
|
socket.destroy(new Error(`Blocked address: ${address}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private isPrivateIp(ip: string): boolean {
|
||||||
|
const parsedIp = ipaddr.parse(ip);
|
||||||
|
|
||||||
|
for (const net of this.config.allowedPrivateNetworks ?? []) {
|
||||||
|
const cidr = ipaddr.parseCIDR(net);
|
||||||
|
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedIp.range() !== 'unicast';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class HttpRequestService {
|
export class HttpRequestService {
|
||||||
|
/**
|
||||||
|
* Get http non-proxy agent (without local address filtering)
|
||||||
|
*/
|
||||||
|
private httpNative: http.Agent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get https non-proxy agent (without local address filtering)
|
||||||
|
*/
|
||||||
|
private httpsNative: https.Agent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get http non-proxy agent
|
* Get http non-proxy agent
|
||||||
*/
|
*/
|
||||||
|
@ -56,19 +152,20 @@ export class HttpRequestService {
|
||||||
lookup: false, // nativeのdns.lookupにfallbackしない
|
lookup: false, // nativeのdns.lookupにfallbackしない
|
||||||
});
|
});
|
||||||
|
|
||||||
this.http = new http.Agent({
|
const agentOption = {
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
keepAliveMsecs: 30 * 1000,
|
keepAliveMsecs: 30 * 1000,
|
||||||
lookup: cache.lookup as unknown as net.LookupFunction,
|
lookup: cache.lookup as unknown as net.LookupFunction,
|
||||||
localAddress: config.outgoingAddress,
|
localAddress: config.outgoingAddress,
|
||||||
});
|
};
|
||||||
|
|
||||||
this.https = new https.Agent({
|
this.httpNative = new http.Agent(agentOption);
|
||||||
keepAlive: true,
|
|
||||||
keepAliveMsecs: 30 * 1000,
|
this.httpsNative = new https.Agent(agentOption);
|
||||||
lookup: cache.lookup as unknown as net.LookupFunction,
|
|
||||||
localAddress: config.outgoingAddress,
|
this.http = new HttpRequestServiceAgent(config, agentOption);
|
||||||
});
|
|
||||||
|
this.https = new HttpsRequestServiceAgent(config, agentOption);
|
||||||
|
|
||||||
const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128);
|
const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128);
|
||||||
|
|
||||||
|
@ -103,16 +200,22 @@ export class HttpRequestService {
|
||||||
* @param bypassProxy Allways bypass proxy
|
* @param bypassProxy Allways bypass proxy
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public getAgentByUrl(url: URL, bypassProxy = false): http.Agent | https.Agent {
|
public getAgentByUrl(url: URL, bypassProxy = false, isLocalAddressAllowed = false): http.Agent | https.Agent {
|
||||||
if (bypassProxy || (this.config.proxyBypassHosts ?? []).includes(url.hostname)) {
|
if (bypassProxy || (this.config.proxyBypassHosts ?? []).includes(url.hostname)) {
|
||||||
|
if (isLocalAddressAllowed) {
|
||||||
|
return url.protocol === 'http:' ? this.httpNative : this.httpsNative;
|
||||||
|
}
|
||||||
return url.protocol === 'http:' ? this.http : this.https;
|
return url.protocol === 'http:' ? this.http : this.https;
|
||||||
} else {
|
} else {
|
||||||
|
if (isLocalAddressAllowed && (!this.config.proxy)) {
|
||||||
|
return url.protocol === 'http:' ? this.httpNative : this.httpsNative;
|
||||||
|
}
|
||||||
return url.protocol === 'http:' ? this.httpAgent : this.httpsAgent;
|
return url.protocol === 'http:' ? this.httpAgent : this.httpsAgent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async getActivityJson(url: string): Promise<IObject> {
|
public async getActivityJson(url: string, isLocalAddressAllowed = false): Promise<IObject> {
|
||||||
const res = await this.send(url, {
|
const res = await this.send(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -120,16 +223,22 @@ export class HttpRequestService {
|
||||||
},
|
},
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
size: 1024 * 256,
|
size: 1024 * 256,
|
||||||
|
isLocalAddressAllowed: isLocalAddressAllowed,
|
||||||
}, {
|
}, {
|
||||||
throwErrorWhenResponseNotOk: true,
|
throwErrorWhenResponseNotOk: true,
|
||||||
validators: [validateContentTypeSetAsActivityPub],
|
validators: [validateContentTypeSetAsActivityPub],
|
||||||
});
|
});
|
||||||
|
|
||||||
return await res.json() as IObject;
|
const finalUrl = res.url; // redirects may have been involved
|
||||||
|
const activity = await res.json() as IObject;
|
||||||
|
|
||||||
|
assertActivityMatchesUrls(activity, [finalUrl]);
|
||||||
|
|
||||||
|
return activity;
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async getJson<T = unknown>(url: string, accept = 'application/json, */*', headers?: Record<string, string>): Promise<T> {
|
public async getJson<T = unknown>(url: string, accept = 'application/json, */*', headers?: Record<string, string>, isLocalAddressAllowed = false): Promise<T> {
|
||||||
const res = await this.send(url, {
|
const res = await this.send(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: Object.assign({
|
headers: Object.assign({
|
||||||
|
@ -137,19 +246,21 @@ export class HttpRequestService {
|
||||||
}, headers ?? {}),
|
}, headers ?? {}),
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
size: 1024 * 256,
|
size: 1024 * 256,
|
||||||
|
isLocalAddressAllowed: isLocalAddressAllowed,
|
||||||
});
|
});
|
||||||
|
|
||||||
return await res.json() as T;
|
return await res.json() as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async getHtml(url: string, accept = 'text/html, */*', headers?: Record<string, string>): Promise<string> {
|
public async getHtml(url: string, accept = 'text/html, */*', headers?: Record<string, string>, isLocalAddressAllowed = false): Promise<string> {
|
||||||
const res = await this.send(url, {
|
const res = await this.send(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: Object.assign({
|
headers: Object.assign({
|
||||||
Accept: accept,
|
Accept: accept,
|
||||||
}, headers ?? {}),
|
}, headers ?? {}),
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
|
isLocalAddressAllowed: isLocalAddressAllowed,
|
||||||
});
|
});
|
||||||
|
|
||||||
return await res.text();
|
return await res.text();
|
||||||
|
@ -164,6 +275,7 @@ export class HttpRequestService {
|
||||||
headers?: Record<string, string>,
|
headers?: Record<string, string>,
|
||||||
timeout?: number,
|
timeout?: number,
|
||||||
size?: number,
|
size?: number,
|
||||||
|
isLocalAddressAllowed?: boolean,
|
||||||
} = {},
|
} = {},
|
||||||
extra: HttpRequestSendOptions = {
|
extra: HttpRequestSendOptions = {
|
||||||
throwErrorWhenResponseNotOk: true,
|
throwErrorWhenResponseNotOk: true,
|
||||||
|
@ -177,6 +289,8 @@ export class HttpRequestService {
|
||||||
controller.abort();
|
controller.abort();
|
||||||
}, timeout);
|
}, timeout);
|
||||||
|
|
||||||
|
const isLocalAddressAllowed = args.isLocalAddressAllowed ?? false;
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: args.method ?? 'GET',
|
method: args.method ?? 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -185,7 +299,7 @@ export class HttpRequestService {
|
||||||
},
|
},
|
||||||
body: args.body,
|
body: args.body,
|
||||||
size: args.size ?? 10 * 1024 * 1024,
|
size: args.size ?? 10 * 1024 * 1024,
|
||||||
agent: (url) => this.getAgentByUrl(url),
|
agent: (url) => this.getAgentByUrl(url, false, isLocalAddressAllowed),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -56,6 +56,7 @@ import { isReply } from '@/misc/is-reply.js';
|
||||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
import { CollapsedQueue } from '@/misc/collapsed-queue.js';
|
import { CollapsedQueue } from '@/misc/collapsed-queue.js';
|
||||||
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
|
|
||||||
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
||||||
|
|
||||||
|
@ -217,6 +218,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
private instanceChart: InstanceChart,
|
private instanceChart: InstanceChart,
|
||||||
private utilityService: UtilityService,
|
private utilityService: UtilityService,
|
||||||
private userBlockingService: UserBlockingService,
|
private userBlockingService: UserBlockingService,
|
||||||
|
private cacheService: CacheService,
|
||||||
) {
|
) {
|
||||||
this.updateNotesCountQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount);
|
this.updateNotesCountQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount);
|
||||||
}
|
}
|
||||||
|
@ -436,6 +438,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
replyUserHost: data.reply ? data.reply.userHost : null,
|
replyUserHost: data.reply ? data.reply.userHost : null,
|
||||||
renoteUserId: data.renote ? data.renote.userId : null,
|
renoteUserId: data.renote ? data.renote.userId : null,
|
||||||
renoteUserHost: data.renote ? data.renote.userHost : null,
|
renoteUserHost: data.renote ? data.renote.userHost : null,
|
||||||
|
renoteChannelId: data.renote ? data.renote.channelId : null,
|
||||||
userHost: user.host,
|
userHost: user.host,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -543,13 +546,21 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
this.followingsRepository.findBy({
|
this.followingsRepository.findBy({
|
||||||
followeeId: user.id,
|
followeeId: user.id,
|
||||||
notify: 'normal',
|
notify: 'normal',
|
||||||
}).then(followings => {
|
}).then(async followings => {
|
||||||
if (note.visibility !== 'specified') {
|
if (note.visibility !== 'specified') {
|
||||||
|
const isPureRenote = this.isRenote(data) && !this.isQuote(data) ? true : false;
|
||||||
for (const following of followings) {
|
for (const following of followings) {
|
||||||
// TODO: ワードミュート考慮
|
// TODO: ワードミュート考慮
|
||||||
this.notificationService.createNotification(following.followerId, 'note', {
|
let isRenoteMuted = false;
|
||||||
noteId: note.id,
|
if (isPureRenote) {
|
||||||
}, user.id);
|
const userIdsWhoMeMutingRenotes = await this.cacheService.renoteMutingsCache.fetch(following.followerId);
|
||||||
|
isRenoteMuted = userIdsWhoMeMutingRenotes.has(user.id);
|
||||||
|
}
|
||||||
|
if (!isRenoteMuted) {
|
||||||
|
this.notificationService.createNotification(following.followerId, 'note', {
|
||||||
|
noteId: note.id,
|
||||||
|
}, user.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -7,13 +7,15 @@ import { randomUUID } from 'node:crypto';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import type { IActivity } from '@/core/activitypub/type.js';
|
import type { IActivity } from '@/core/activitypub/type.js';
|
||||||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||||
import type { MiWebhook, WebhookEventTypes, webhookEventTypes } from '@/models/Webhook.js';
|
import type { MiWebhook, WebhookEventTypes } from '@/models/Webhook.js';
|
||||||
import type { MiSystemWebhook, SystemWebhookEventType } from '@/models/SystemWebhook.js';
|
import type { MiSystemWebhook, SystemWebhookEventType } from '@/models/SystemWebhook.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js';
|
import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js';
|
||||||
import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js';
|
import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js';
|
||||||
|
import { type SystemWebhookPayload } from '@/core/SystemWebhookService.js';
|
||||||
|
import { type UserWebhookPayload } from './UserWebhookService.js';
|
||||||
import type {
|
import type {
|
||||||
DbJobData,
|
DbJobData,
|
||||||
DeliverJobData,
|
DeliverJobData,
|
||||||
|
@ -30,12 +32,11 @@ import type {
|
||||||
ObjectStorageQueue,
|
ObjectStorageQueue,
|
||||||
RelationshipQueue,
|
RelationshipQueue,
|
||||||
SystemQueue,
|
SystemQueue,
|
||||||
UserWebhookDeliverQueue,
|
|
||||||
SystemWebhookDeliverQueue,
|
SystemWebhookDeliverQueue,
|
||||||
|
UserWebhookDeliverQueue,
|
||||||
} from './QueueModule.js';
|
} from './QueueModule.js';
|
||||||
import type httpSignature from '@peertube/http-signature';
|
import type httpSignature from '@peertube/http-signature';
|
||||||
import type * as Bull from 'bullmq';
|
import type * as Bull from 'bullmq';
|
||||||
import { type UserWebhookPayload } from './UserWebhookService.js';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class QueueService {
|
export class QueueService {
|
||||||
|
@ -501,10 +502,10 @@ export class QueueService {
|
||||||
* @see SystemWebhookDeliverProcessorService
|
* @see SystemWebhookDeliverProcessorService
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public systemWebhookDeliver(
|
public systemWebhookDeliver<T extends SystemWebhookEventType>(
|
||||||
webhook: MiSystemWebhook,
|
webhook: MiSystemWebhook,
|
||||||
type: SystemWebhookEventType,
|
type: T,
|
||||||
content: unknown,
|
content: SystemWebhookPayload<T>,
|
||||||
opts?: { attempts?: number },
|
opts?: { attempts?: number },
|
||||||
) {
|
) {
|
||||||
const data: SystemWebhookDeliverJobData = {
|
const data: SystemWebhookDeliverJobData = {
|
||||||
|
|
|
@ -56,7 +56,7 @@ export class RemoteUserResolveService {
|
||||||
|
|
||||||
host = this.utilityService.toPuny(host);
|
host = this.utilityService.toPuny(host);
|
||||||
|
|
||||||
if (this.config.host === host) {
|
if (host === this.utilityService.toPuny(this.config.host)) {
|
||||||
this.logger.info(`return local user: ${usernameLower}`);
|
this.logger.info(`return local user: ${usernameLower}`);
|
||||||
return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => {
|
return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => {
|
||||||
if (u == null) {
|
if (u == null) {
|
||||||
|
|
|
@ -15,8 +15,39 @@ import { QueueService } from '@/core/QueueService.js';
|
||||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||||
import { LoggerService } from '@/core/LoggerService.js';
|
import { LoggerService } from '@/core/LoggerService.js';
|
||||||
import Logger from '@/logger.js';
|
import Logger from '@/logger.js';
|
||||||
|
import { Packed } from '@/misc/json-schema.js';
|
||||||
|
import { AbuseReportResolveType } from '@/models/AbuseUserReport.js';
|
||||||
|
import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
|
||||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||||
|
|
||||||
|
export type AbuseReportPayload = {
|
||||||
|
id: string;
|
||||||
|
targetUserId: string;
|
||||||
|
targetUser: Packed<'UserLite'> | null;
|
||||||
|
targetUserHost: string | null;
|
||||||
|
reporterId: string;
|
||||||
|
reporter: Packed<'UserLite'> | null;
|
||||||
|
reporterHost: string | null;
|
||||||
|
assigneeId: string | null;
|
||||||
|
assignee: Packed<'UserLite'> | null;
|
||||||
|
resolved: boolean;
|
||||||
|
forwarded: boolean;
|
||||||
|
comment: string;
|
||||||
|
moderationNote: string;
|
||||||
|
resolvedAs: AbuseReportResolveType | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InactiveModeratorsWarningPayload = {
|
||||||
|
remainingTime: ModeratorInactivityRemainingTime;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SystemWebhookPayload<T extends SystemWebhookEventType> =
|
||||||
|
T extends 'abuseReport' | 'abuseReportResolved' ? AbuseReportPayload :
|
||||||
|
T extends 'userCreated' ? Packed<'UserLite'> :
|
||||||
|
T extends 'inactiveModeratorsWarning' ? InactiveModeratorsWarningPayload :
|
||||||
|
T extends 'inactiveModeratorsInvitationOnlyChanged' ? Record<string, never> :
|
||||||
|
never;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SystemWebhookService implements OnApplicationShutdown {
|
export class SystemWebhookService implements OnApplicationShutdown {
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
@ -168,7 +199,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
|
||||||
public async enqueueSystemWebhook<T extends SystemWebhookEventType>(
|
public async enqueueSystemWebhook<T extends SystemWebhookEventType>(
|
||||||
webhook: MiSystemWebhook | MiSystemWebhook['id'],
|
webhook: MiSystemWebhook | MiSystemWebhook['id'],
|
||||||
type: T,
|
type: T,
|
||||||
content: unknown,
|
content: SystemWebhookPayload<T>,
|
||||||
) {
|
) {
|
||||||
const webhookEntity = typeof webhook === 'string'
|
const webhookEntity = typeof webhook === 'string'
|
||||||
? (await this.fetchActiveSystemWebhooks()).find(a => a.id === webhook)
|
? (await this.fetchActiveSystemWebhooks()).find(a => a.id === webhook)
|
||||||
|
|
|
@ -34,6 +34,11 @@ export class UtilityService {
|
||||||
return this.toPuny(this.config.host) === this.toPuny(host);
|
return this.toPuny(this.config.host) === this.toPuny(host);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public isUriLocal(uri: string): boolean {
|
||||||
|
return this.punyHost(uri) === this.toPuny(this.config.host);
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public isBlockedHost(blockedHosts: string[], host: string | null): boolean {
|
public isBlockedHost(blockedHosts: string[], host: string | null): boolean {
|
||||||
if (host == null) return false;
|
if (host == null) return false;
|
||||||
|
@ -96,7 +101,7 @@ export class UtilityService {
|
||||||
@bindThis
|
@bindThis
|
||||||
public extractDbHost(uri: string): string {
|
public extractDbHost(uri: string): string {
|
||||||
const url = new URL(uri);
|
const url = new URL(uri);
|
||||||
return this.toPuny(url.hostname);
|
return this.toPuny(url.host);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
@ -110,6 +115,13 @@ export class UtilityService {
|
||||||
return toASCII(host.toLowerCase());
|
return toASCII(host.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public punyHost(url: string): string {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const host = `${this.toPuny(urlObj.hostname)}${urlObj.port.length > 0 ? ':' + urlObj.port : ''}`;
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public isFederationAllowedHost(host: string): boolean {
|
public isFederationAllowedHost(host: string): boolean {
|
||||||
if (this.meta.federation === 'none') return false;
|
if (this.meta.federation === 'none') return false;
|
||||||
|
|
|
@ -246,14 +246,12 @@ export class WebAuthnService {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async verifyAuthentication(userId: MiUser['id'], response: AuthenticationResponseJSON): Promise<boolean> {
|
public async verifyAuthentication(userId: MiUser['id'], response: AuthenticationResponseJSON): Promise<boolean> {
|
||||||
const challenge = await this.redisClient.get(`webauthn:challenge:${userId}`);
|
const challenge = await this.redisClient.getdel(`webauthn:challenge:${userId}`);
|
||||||
|
|
||||||
if (!challenge) {
|
if (!challenge) {
|
||||||
throw new IdentifiableError('2d16e51c-007b-4edd-afd2-f7dd02c947f6', 'challenge not found');
|
throw new IdentifiableError('2d16e51c-007b-4edd-afd2-f7dd02c947f6', 'challenge not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.redisClient.del(`webauthn:challenge:${userId}`);
|
|
||||||
|
|
||||||
const key = await this.userSecurityKeysRepository.findOneBy({
|
const key = await this.userSecurityKeysRepository.findOneBy({
|
||||||
id: response.id,
|
id: response.id,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { Injectable } from '@nestjs/common';
|
||||||
import { MiAbuseUserReport, MiNote, MiUser, MiWebhook } from '@/models/_.js';
|
import { MiAbuseUserReport, MiNote, MiUser, MiWebhook } from '@/models/_.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { MiSystemWebhook, type SystemWebhookEventType } from '@/models/SystemWebhook.js';
|
import { MiSystemWebhook, type SystemWebhookEventType } from '@/models/SystemWebhook.js';
|
||||||
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
|
import { AbuseReportPayload, SystemWebhookPayload, SystemWebhookService } from '@/core/SystemWebhookService.js';
|
||||||
import { Packed } from '@/misc/json-schema.js';
|
import { Packed } from '@/misc/json-schema.js';
|
||||||
import { type WebhookEventTypes } from '@/models/Webhook.js';
|
import { type WebhookEventTypes } from '@/models/Webhook.js';
|
||||||
import { type UserWebhookPayload, UserWebhookService } from '@/core/UserWebhookService.js';
|
import { type UserWebhookPayload, UserWebhookService } from '@/core/UserWebhookService.js';
|
||||||
|
@ -16,13 +16,7 @@ import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModera
|
||||||
|
|
||||||
const oneDayMillis = 24 * 60 * 60 * 1000;
|
const oneDayMillis = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
type AbuseUserReportDto = Omit<MiAbuseUserReport, 'targetUser' | 'reporter' | 'assignee'> & {
|
function generateAbuseReport(override?: Partial<MiAbuseUserReport>): AbuseReportPayload {
|
||||||
targetUser: Packed<'UserLite'> | null,
|
|
||||||
reporter: Packed<'UserLite'> | null,
|
|
||||||
assignee: Packed<'UserLite'> | null,
|
|
||||||
};
|
|
||||||
|
|
||||||
function generateAbuseReport(override?: Partial<MiAbuseUserReport>): AbuseUserReportDto {
|
|
||||||
const result: MiAbuseUserReport = {
|
const result: MiAbuseUserReport = {
|
||||||
id: 'dummy-abuse-report1',
|
id: 'dummy-abuse-report1',
|
||||||
targetUserId: 'dummy-target-user',
|
targetUserId: 'dummy-target-user',
|
||||||
|
@ -137,6 +131,7 @@ function generateDummyNote(override?: Partial<MiNote>): MiNote {
|
||||||
replyUserHost: null,
|
replyUserHost: null,
|
||||||
renoteUserId: null,
|
renoteUserId: null,
|
||||||
renoteUserHost: null,
|
renoteUserHost: null,
|
||||||
|
renoteChannelId: null,
|
||||||
...override,
|
...override,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -389,7 +384,8 @@ export class WebhookTestService {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// まだ実装されていない (#9485)
|
// まだ実装されていない (#9485)
|
||||||
case 'reaction': return;
|
case 'reaction':
|
||||||
|
return;
|
||||||
default: {
|
default: {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const _exhaustiveAssertion: never = params.type;
|
const _exhaustiveAssertion: never = params.type;
|
||||||
|
@ -407,10 +403,10 @@ export class WebhookTestService {
|
||||||
* - 送信対象イベント(on)に関する設定
|
* - 送信対象イベント(on)に関する設定
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public async testSystemWebhook(
|
public async testSystemWebhook<T extends SystemWebhookEventType>(
|
||||||
params: {
|
params: {
|
||||||
webhookId: MiSystemWebhook['id'],
|
webhookId: MiSystemWebhook['id'],
|
||||||
type: SystemWebhookEventType,
|
type: T,
|
||||||
override?: Partial<Omit<MiSystemWebhook, 'id'>>,
|
override?: Partial<Omit<MiSystemWebhook, 'id'>>,
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
@ -420,7 +416,7 @@ export class WebhookTestService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const webhook = webhooks[0];
|
const webhook = webhooks[0];
|
||||||
const send = (contents: unknown) => {
|
const send = <U extends SystemWebhookEventType>(type: U, contents: SystemWebhookPayload<U>) => {
|
||||||
const merged = {
|
const merged = {
|
||||||
...webhook,
|
...webhook,
|
||||||
...params.override,
|
...params.override,
|
||||||
|
@ -428,12 +424,12 @@ export class WebhookTestService {
|
||||||
|
|
||||||
// テスト目的なのでSystemWebhookServiceの機能を経由せず直接キューに追加する(チェック処理などをスキップする意図).
|
// テスト目的なのでSystemWebhookServiceの機能を経由せず直接キューに追加する(チェック処理などをスキップする意図).
|
||||||
// また、Jobの試行回数も1回だけ.
|
// また、Jobの試行回数も1回だけ.
|
||||||
this.queueService.systemWebhookDeliver(merged, params.type, contents, { attempts: 1 });
|
this.queueService.systemWebhookDeliver(merged, type, contents, { attempts: 1 });
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (params.type) {
|
switch (params.type) {
|
||||||
case 'abuseReport': {
|
case 'abuseReport': {
|
||||||
send(generateAbuseReport({
|
send('abuseReport', generateAbuseReport({
|
||||||
targetUserId: dummyUser1.id,
|
targetUserId: dummyUser1.id,
|
||||||
targetUser: dummyUser1,
|
targetUser: dummyUser1,
|
||||||
reporterId: dummyUser2.id,
|
reporterId: dummyUser2.id,
|
||||||
|
@ -442,7 +438,7 @@ export class WebhookTestService {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'abuseReportResolved': {
|
case 'abuseReportResolved': {
|
||||||
send(generateAbuseReport({
|
send('abuseReportResolved', generateAbuseReport({
|
||||||
targetUserId: dummyUser1.id,
|
targetUserId: dummyUser1.id,
|
||||||
targetUser: dummyUser1,
|
targetUser: dummyUser1,
|
||||||
reporterId: dummyUser2.id,
|
reporterId: dummyUser2.id,
|
||||||
|
@ -454,7 +450,7 @@ export class WebhookTestService {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'userCreated': {
|
case 'userCreated': {
|
||||||
send(toPackedUserLite(dummyUser1));
|
send('userCreated', toPackedUserLite(dummyUser1));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'inactiveModeratorsWarning': {
|
case 'inactiveModeratorsWarning': {
|
||||||
|
@ -464,15 +460,20 @@ export class WebhookTestService {
|
||||||
asHours: 24,
|
asHours: 24,
|
||||||
};
|
};
|
||||||
|
|
||||||
send({
|
send('inactiveModeratorsWarning', {
|
||||||
remainingTime: dummyTime,
|
remainingTime: dummyTime,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'inactiveModeratorsInvitationOnlyChanged': {
|
case 'inactiveModeratorsInvitationOnlyChanged': {
|
||||||
send({});
|
send('inactiveModeratorsInvitationOnlyChanged', {});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
default: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const _exhaustiveAssertion: never = params.type;
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import type { Config } from '@/config.js';
|
||||||
import { MemoryKVCache } from '@/misc/cache.js';
|
import { MemoryKVCache } from '@/misc/cache.js';
|
||||||
import type { MiUserPublickey } from '@/models/UserPublickey.js';
|
import type { MiUserPublickey } from '@/models/UserPublickey.js';
|
||||||
import { CacheService } from '@/core/CacheService.js';
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
|
import { UtilityService } from '@/core/UtilityService.js';
|
||||||
import type { MiNote } from '@/models/Note.js';
|
import type { MiNote } from '@/models/Note.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
import { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
||||||
|
@ -53,6 +54,7 @@ export class ApDbResolverService implements OnApplicationShutdown {
|
||||||
|
|
||||||
private cacheService: CacheService,
|
private cacheService: CacheService,
|
||||||
private apPersonService: ApPersonService,
|
private apPersonService: ApPersonService,
|
||||||
|
private utilityService: UtilityService,
|
||||||
) {
|
) {
|
||||||
this.publicKeyCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
|
this.publicKeyCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
|
||||||
this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
|
this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
|
||||||
|
@ -63,7 +65,9 @@ export class ApDbResolverService implements OnApplicationShutdown {
|
||||||
const separator = '/';
|
const separator = '/';
|
||||||
|
|
||||||
const uri = new URL(getApId(value));
|
const uri = new URL(getApId(value));
|
||||||
if (uri.origin !== this.config.url) return { local: false, uri: uri.href };
|
if (this.utilityService.toPuny(uri.host) !== this.utilityService.toPuny(this.config.host)) {
|
||||||
|
return { local: false, uri: uri.href };
|
||||||
|
}
|
||||||
|
|
||||||
const [, type, id, ...rest] = uri.pathname.split(separator);
|
const [, type, id, ...rest] = uri.pathname.split(separator);
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -28,6 +28,7 @@ import { bindThis } from '@/decorators.js';
|
||||||
import type { MiRemoteUser } from '@/models/User.js';
|
import type { MiRemoteUser } from '@/models/User.js';
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
import { AbuseReportService } from '@/core/AbuseReportService.js';
|
import { AbuseReportService } from '@/core/AbuseReportService.js';
|
||||||
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
|
import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
|
||||||
import { ApNoteService } from './models/ApNoteService.js';
|
import { ApNoteService } from './models/ApNoteService.js';
|
||||||
import { ApLoggerService } from './ApLoggerService.js';
|
import { ApLoggerService } from './ApLoggerService.js';
|
||||||
|
@ -89,15 +90,26 @@ export class ApInboxService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async performActivity(actor: MiRemoteUser, activity: IObject): Promise<string | void> {
|
public async performActivity(actor: MiRemoteUser, activity: IObject, resolver?: Resolver): Promise<string | void> {
|
||||||
let result = undefined as string | void;
|
let result = undefined as string | void;
|
||||||
if (isCollectionOrOrderedCollection(activity)) {
|
if (isCollectionOrOrderedCollection(activity)) {
|
||||||
const results = [] as [string, string | void][];
|
const results = [] as [string, string | void][];
|
||||||
const resolver = this.apResolverService.createResolver();
|
// eslint-disable-next-line no-param-reassign
|
||||||
for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) {
|
resolver ??= this.apResolverService.createResolver();
|
||||||
|
|
||||||
|
const items = toArray(isCollection(activity) ? activity.items : activity.orderedItems);
|
||||||
|
if (items.length >= resolver.getRecursionLimit()) {
|
||||||
|
throw new Error(`skipping activity: collection would surpass recursion limit: ${this.utilityService.extractDbHost(actor.uri)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
const act = await resolver.resolve(item);
|
const act = await resolver.resolve(item);
|
||||||
|
if (act.id == null || this.utilityService.extractDbHost(act.id) !== this.utilityService.extractDbHost(actor.uri)) {
|
||||||
|
this.logger.debug('skipping activity: activity id is null or mismatching');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
results.push([getApId(item), await this.performOneActivity(actor, act)]);
|
results.push([getApId(item), await this.performOneActivity(actor, act, resolver)]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error || typeof err === 'string') {
|
if (err instanceof Error || typeof err === 'string') {
|
||||||
this.logger.error(err);
|
this.logger.error(err);
|
||||||
|
@ -112,13 +124,14 @@ export class ApInboxService {
|
||||||
result = results.map(([id, reason]) => `${id}: ${reason}`).join('\n');
|
result = results.map(([id, reason]) => `${id}: ${reason}`).join('\n');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
result = await this.performOneActivity(actor, activity);
|
result = await this.performOneActivity(actor, activity, resolver);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ついでにリモートユーザーの情報が古かったら更新しておく
|
// ついでにリモートユーザーの情報が古かったら更新しておく
|
||||||
if (actor.uri) {
|
if (actor.uri) {
|
||||||
if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
|
if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
|
// 同一ユーザーの情報を再度処理するので、使用済みのresolverを再利用してはいけない
|
||||||
this.apPersonService.updatePerson(actor.uri);
|
this.apPersonService.updatePerson(actor.uri);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -127,37 +140,37 @@ export class ApInboxService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async performOneActivity(actor: MiRemoteUser, activity: IObject): Promise<string | void> {
|
public async performOneActivity(actor: MiRemoteUser, activity: IObject, resolver?: Resolver): Promise<string | void> {
|
||||||
if (actor.isSuspended) return;
|
if (actor.isSuspended) return;
|
||||||
|
|
||||||
if (isCreate(activity)) {
|
if (isCreate(activity)) {
|
||||||
return await this.create(actor, activity);
|
return await this.create(actor, activity, resolver);
|
||||||
} else if (isDelete(activity)) {
|
} else if (isDelete(activity)) {
|
||||||
return await this.delete(actor, activity);
|
return await this.delete(actor, activity);
|
||||||
} else if (isUpdate(activity)) {
|
} else if (isUpdate(activity)) {
|
||||||
return await this.update(actor, activity);
|
return await this.update(actor, activity, resolver);
|
||||||
} else if (isFollow(activity)) {
|
} else if (isFollow(activity)) {
|
||||||
return await this.follow(actor, activity);
|
return await this.follow(actor, activity);
|
||||||
} else if (isAccept(activity)) {
|
} else if (isAccept(activity)) {
|
||||||
return await this.accept(actor, activity);
|
return await this.accept(actor, activity, resolver);
|
||||||
} else if (isReject(activity)) {
|
} else if (isReject(activity)) {
|
||||||
return await this.reject(actor, activity);
|
return await this.reject(actor, activity, resolver);
|
||||||
} else if (isAdd(activity)) {
|
} else if (isAdd(activity)) {
|
||||||
return await this.add(actor, activity);
|
return await this.add(actor, activity, resolver);
|
||||||
} else if (isRemove(activity)) {
|
} else if (isRemove(activity)) {
|
||||||
return await this.remove(actor, activity);
|
return await this.remove(actor, activity, resolver);
|
||||||
} else if (isAnnounce(activity)) {
|
} else if (isAnnounce(activity)) {
|
||||||
return await this.announce(actor, activity);
|
return await this.announce(actor, activity, resolver);
|
||||||
} else if (isLike(activity)) {
|
} else if (isLike(activity)) {
|
||||||
return await this.like(actor, activity);
|
return await this.like(actor, activity);
|
||||||
} else if (isUndo(activity)) {
|
} else if (isUndo(activity)) {
|
||||||
return await this.undo(actor, activity);
|
return await this.undo(actor, activity, resolver);
|
||||||
} else if (isBlock(activity)) {
|
} else if (isBlock(activity)) {
|
||||||
return await this.block(actor, activity);
|
return await this.block(actor, activity);
|
||||||
} else if (isFlag(activity)) {
|
} else if (isFlag(activity)) {
|
||||||
return await this.flag(actor, activity);
|
return await this.flag(actor, activity);
|
||||||
} else if (isMove(activity)) {
|
} else if (isMove(activity)) {
|
||||||
return await this.move(actor, activity);
|
return await this.move(actor, activity, resolver);
|
||||||
} else {
|
} else {
|
||||||
return `unrecognized activity type: ${activity.type}`;
|
return `unrecognized activity type: ${activity.type}`;
|
||||||
}
|
}
|
||||||
|
@ -189,22 +202,26 @@ export class ApInboxService {
|
||||||
|
|
||||||
await this.apNoteService.extractEmojis(activity.tag ?? [], actor.host).catch(() => null);
|
await this.apNoteService.extractEmojis(activity.tag ?? [], actor.host).catch(() => null);
|
||||||
|
|
||||||
return await this.reactionService.create(actor, note, activity._misskey_reaction ?? activity.content ?? activity.name).catch(err => {
|
try {
|
||||||
if (err.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') {
|
await this.reactionService.create(actor, note, activity._misskey_reaction ?? activity.content ?? activity.name);
|
||||||
|
return 'ok';
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof IdentifiableError && err.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') {
|
||||||
return 'skip: already reacted';
|
return 'skip: already reacted';
|
||||||
} else {
|
} else {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}).then(() => 'ok');
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async accept(actor: MiRemoteUser, activity: IAccept): Promise<string> {
|
private async accept(actor: MiRemoteUser, activity: IAccept, resolver?: Resolver): Promise<string> {
|
||||||
const uri = activity.id ?? activity;
|
const uri = activity.id ?? activity;
|
||||||
|
|
||||||
this.logger.info(`Accept: ${uri}`);
|
this.logger.info(`Accept: ${uri}`);
|
||||||
|
|
||||||
const resolver = this.apResolverService.createResolver();
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
resolver ??= this.apResolverService.createResolver();
|
||||||
|
|
||||||
const object = await resolver.resolve(activity.object).catch(err => {
|
const object = await resolver.resolve(activity.object).catch(err => {
|
||||||
this.logger.error(`Resolution failed: ${err}`);
|
this.logger.error(`Resolution failed: ${err}`);
|
||||||
|
@ -241,7 +258,7 @@ export class ApInboxService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async add(actor: MiRemoteUser, activity: IAdd): Promise<string | void> {
|
private async add(actor: MiRemoteUser, activity: IAdd, resolver?: Resolver): Promise<string | void> {
|
||||||
if (actor.uri !== activity.actor) {
|
if (actor.uri !== activity.actor) {
|
||||||
return 'invalid actor';
|
return 'invalid actor';
|
||||||
}
|
}
|
||||||
|
@ -251,7 +268,7 @@ export class ApInboxService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activity.target === actor.featured) {
|
if (activity.target === actor.featured) {
|
||||||
const note = await this.apNoteService.resolveNote(activity.object);
|
const note = await this.apNoteService.resolveNote(activity.object, { resolver });
|
||||||
if (note == null) return 'note not found';
|
if (note == null) return 'note not found';
|
||||||
await this.notePiningService.addPinned(actor, note.id);
|
await this.notePiningService.addPinned(actor, note.id);
|
||||||
return;
|
return;
|
||||||
|
@ -261,12 +278,13 @@ export class ApInboxService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async announce(actor: MiRemoteUser, activity: IAnnounce): Promise<string | void> {
|
private async announce(actor: MiRemoteUser, activity: IAnnounce, resolver?: Resolver): Promise<string | void> {
|
||||||
const uri = getApId(activity);
|
const uri = getApId(activity);
|
||||||
|
|
||||||
this.logger.info(`Announce: ${uri}`);
|
this.logger.info(`Announce: ${uri}`);
|
||||||
|
|
||||||
const resolver = this.apResolverService.createResolver();
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
resolver ??= this.apResolverService.createResolver();
|
||||||
|
|
||||||
if (!activity.object) return 'skip: activity has no object property';
|
if (!activity.object) return 'skip: activity has no object property';
|
||||||
const targetUri = getApId(activity.object);
|
const targetUri = getApId(activity.object);
|
||||||
|
@ -274,7 +292,7 @@ export class ApInboxService {
|
||||||
|
|
||||||
const target = await resolver.resolve(activity.object).catch(e => {
|
const target = await resolver.resolve(activity.object).catch(e => {
|
||||||
this.logger.error(`Resolution failed: ${e}`);
|
this.logger.error(`Resolution failed: ${e}`);
|
||||||
return e;
|
throw e;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isPost(target)) return await this.announceNote(actor, activity, target);
|
if (isPost(target)) return await this.announceNote(actor, activity, target);
|
||||||
|
@ -283,7 +301,7 @@ export class ApInboxService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async announceNote(actor: MiRemoteUser, activity: IAnnounce, target: IPost): Promise<string | void> {
|
private async announceNote(actor: MiRemoteUser, activity: IAnnounce, target: IPost, resolver?: Resolver): Promise<string | void> {
|
||||||
const uri = getApId(activity);
|
const uri = getApId(activity);
|
||||||
|
|
||||||
if (actor.isSuspended) {
|
if (actor.isSuspended) {
|
||||||
|
@ -305,7 +323,7 @@ export class ApInboxService {
|
||||||
// Announce対象をresolve
|
// Announce対象をresolve
|
||||||
let renote;
|
let renote;
|
||||||
try {
|
try {
|
||||||
renote = await this.apNoteService.resolveNote(target);
|
renote = await this.apNoteService.resolveNote(target, { resolver });
|
||||||
if (renote == null) return 'announce target is null';
|
if (renote == null) return 'announce target is null';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 対象が4xxならスキップ
|
// 対象が4xxならスキップ
|
||||||
|
@ -324,7 +342,7 @@ export class ApInboxService {
|
||||||
|
|
||||||
this.logger.info(`Creating the (Re)Note: ${uri}`);
|
this.logger.info(`Creating the (Re)Note: ${uri}`);
|
||||||
|
|
||||||
const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc);
|
const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc, resolver);
|
||||||
const createdAt = activity.published ? new Date(activity.published) : null;
|
const createdAt = activity.published ? new Date(activity.published) : null;
|
||||||
|
|
||||||
if (createdAt && createdAt < this.idService.parse(renote.id).date) {
|
if (createdAt && createdAt < this.idService.parse(renote.id).date) {
|
||||||
|
@ -362,7 +380,7 @@ export class ApInboxService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async create(actor: MiRemoteUser, activity: ICreate): Promise<string | void> {
|
private async create(actor: MiRemoteUser, activity: ICreate, resolver?: Resolver): Promise<string | void> {
|
||||||
const uri = getApId(activity);
|
const uri = getApId(activity);
|
||||||
|
|
||||||
this.logger.info(`Create: ${uri}`);
|
this.logger.info(`Create: ${uri}`);
|
||||||
|
@ -387,7 +405,8 @@ export class ApInboxService {
|
||||||
activity.object.attributedTo = activity.actor;
|
activity.object.attributedTo = activity.actor;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolver = this.apResolverService.createResolver();
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
resolver ??= this.apResolverService.createResolver();
|
||||||
|
|
||||||
const object = await resolver.resolve(activity.object).catch(e => {
|
const object = await resolver.resolve(activity.object).catch(e => {
|
||||||
this.logger.error(`Resolution failed: ${e}`);
|
this.logger.error(`Resolution failed: ${e}`);
|
||||||
|
@ -414,6 +433,8 @@ export class ApInboxService {
|
||||||
if (this.utilityService.extractDbHost(actor.uri) !== this.utilityService.extractDbHost(note.id)) {
|
if (this.utilityService.extractDbHost(actor.uri) !== this.utilityService.extractDbHost(note.id)) {
|
||||||
return 'skip: host in actor.uri !== note.id';
|
return 'skip: host in actor.uri !== note.id';
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
return 'skip: note.id is not a string';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -423,7 +444,7 @@ export class ApInboxService {
|
||||||
const exist = await this.apNoteService.fetchNote(note);
|
const exist = await this.apNoteService.fetchNote(note);
|
||||||
if (exist) return 'skip: note exists';
|
if (exist) return 'skip: note exists';
|
||||||
|
|
||||||
await this.apNoteService.createNote(note, resolver, silent);
|
await this.apNoteService.createNote(note, actor, resolver, silent);
|
||||||
return 'ok';
|
return 'ok';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof StatusError && !err.isRetryable) {
|
if (err instanceof StatusError && !err.isRetryable) {
|
||||||
|
@ -555,12 +576,13 @@ export class ApInboxService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async reject(actor: MiRemoteUser, activity: IReject): Promise<string> {
|
private async reject(actor: MiRemoteUser, activity: IReject, resolver?: Resolver): Promise<string> {
|
||||||
const uri = activity.id ?? activity;
|
const uri = activity.id ?? activity;
|
||||||
|
|
||||||
this.logger.info(`Reject: ${uri}`);
|
this.logger.info(`Reject: ${uri}`);
|
||||||
|
|
||||||
const resolver = this.apResolverService.createResolver();
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
resolver ??= this.apResolverService.createResolver();
|
||||||
|
|
||||||
const object = await resolver.resolve(activity.object).catch(e => {
|
const object = await resolver.resolve(activity.object).catch(e => {
|
||||||
this.logger.error(`Resolution failed: ${e}`);
|
this.logger.error(`Resolution failed: ${e}`);
|
||||||
|
@ -597,7 +619,7 @@ export class ApInboxService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async remove(actor: MiRemoteUser, activity: IRemove): Promise<string | void> {
|
private async remove(actor: MiRemoteUser, activity: IRemove, resolver?: Resolver): Promise<string | void> {
|
||||||
if (actor.uri !== activity.actor) {
|
if (actor.uri !== activity.actor) {
|
||||||
return 'invalid actor';
|
return 'invalid actor';
|
||||||
}
|
}
|
||||||
|
@ -607,7 +629,7 @@ export class ApInboxService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activity.target === actor.featured) {
|
if (activity.target === actor.featured) {
|
||||||
const note = await this.apNoteService.resolveNote(activity.object);
|
const note = await this.apNoteService.resolveNote(activity.object, { resolver });
|
||||||
if (note == null) return 'note not found';
|
if (note == null) return 'note not found';
|
||||||
await this.notePiningService.removePinned(actor, note.id);
|
await this.notePiningService.removePinned(actor, note.id);
|
||||||
return;
|
return;
|
||||||
|
@ -617,7 +639,7 @@ export class ApInboxService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async undo(actor: MiRemoteUser, activity: IUndo): Promise<string> {
|
private async undo(actor: MiRemoteUser, activity: IUndo, resolver?: Resolver): Promise<string> {
|
||||||
if (actor.uri !== activity.actor) {
|
if (actor.uri !== activity.actor) {
|
||||||
return 'invalid actor';
|
return 'invalid actor';
|
||||||
}
|
}
|
||||||
|
@ -626,11 +648,12 @@ export class ApInboxService {
|
||||||
|
|
||||||
this.logger.info(`Undo: ${uri}`);
|
this.logger.info(`Undo: ${uri}`);
|
||||||
|
|
||||||
const resolver = this.apResolverService.createResolver();
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
resolver ??= this.apResolverService.createResolver();
|
||||||
|
|
||||||
const object = await resolver.resolve(activity.object).catch(e => {
|
const object = await resolver.resolve(activity.object).catch(e => {
|
||||||
this.logger.error(`Resolution failed: ${e}`);
|
this.logger.error(`Resolution failed: ${e}`);
|
||||||
return e;
|
throw e;
|
||||||
});
|
});
|
||||||
|
|
||||||
// don't queue because the sender may attempt again when timeout
|
// don't queue because the sender may attempt again when timeout
|
||||||
|
@ -750,14 +773,15 @@ export class ApInboxService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async update(actor: MiRemoteUser, activity: IUpdate): Promise<string> {
|
private async update(actor: MiRemoteUser, activity: IUpdate, resolver?: Resolver): Promise<string> {
|
||||||
if (actor.uri !== activity.actor) {
|
if (actor.uri !== activity.actor) {
|
||||||
return 'skip: invalid actor';
|
return 'skip: invalid actor';
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug('Update');
|
this.logger.debug('Update');
|
||||||
|
|
||||||
const resolver = this.apResolverService.createResolver();
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
resolver ??= this.apResolverService.createResolver();
|
||||||
|
|
||||||
const object = await resolver.resolve(activity.object).catch(e => {
|
const object = await resolver.resolve(activity.object).catch(e => {
|
||||||
this.logger.error(`Resolution failed: ${e}`);
|
this.logger.error(`Resolution failed: ${e}`);
|
||||||
|
@ -768,7 +792,7 @@ export class ApInboxService {
|
||||||
await this.apPersonService.updatePerson(actor.uri, resolver, object);
|
await this.apPersonService.updatePerson(actor.uri, resolver, object);
|
||||||
return 'ok: Person updated';
|
return 'ok: Person updated';
|
||||||
} else if (getApType(object) === 'Question') {
|
} else if (getApType(object) === 'Question') {
|
||||||
await this.apQuestionService.updateQuestion(object, resolver).catch(err => console.error(err));
|
await this.apQuestionService.updateQuestion(object, actor, resolver).catch(err => console.error(err));
|
||||||
return 'ok: Question updated';
|
return 'ok: Question updated';
|
||||||
} else {
|
} else {
|
||||||
return `skip: Unknown type: ${getApType(object)}`;
|
return `skip: Unknown type: ${getApType(object)}`;
|
||||||
|
@ -776,11 +800,11 @@ export class ApInboxService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async move(actor: MiRemoteUser, activity: IMove): Promise<string> {
|
private async move(actor: MiRemoteUser, activity: IMove, resolver?: Resolver): Promise<string> {
|
||||||
// fetch the new and old accounts
|
// fetch the new and old accounts
|
||||||
const targetUri = getApHrefNullable(activity.target);
|
const targetUri = getApHrefNullable(activity.target);
|
||||||
if (!targetUri) return 'skip: invalid activity target';
|
if (!targetUri) return 'skip: invalid activity target';
|
||||||
|
|
||||||
return await this.apPersonService.updatePerson(actor.uri) ?? 'skip: nothing to do';
|
return await this.apPersonService.updatePerson(actor.uri, resolver) ?? 'skip: nothing to do';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,11 +11,14 @@ import { DI } from '@/di-symbols.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import type { MiUser } from '@/models/User.js';
|
import type { MiUser } from '@/models/User.js';
|
||||||
import { UserKeypairService } from '@/core/UserKeypairService.js';
|
import { UserKeypairService } from '@/core/UserKeypairService.js';
|
||||||
|
import { UtilityService } from '@/core/UtilityService.js';
|
||||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||||
import { LoggerService } from '@/core/LoggerService.js';
|
import { LoggerService } from '@/core/LoggerService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import type Logger from '@/logger.js';
|
import type Logger from '@/logger.js';
|
||||||
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
||||||
|
import { assertActivityMatchesUrls } from '@/core/activitypub/misc/check-against-url.js';
|
||||||
|
import type { IObject } from './type.js';
|
||||||
|
|
||||||
type Request = {
|
type Request = {
|
||||||
url: string;
|
url: string;
|
||||||
|
@ -145,6 +148,7 @@ export class ApRequestService {
|
||||||
private userKeypairService: UserKeypairService,
|
private userKeypairService: UserKeypairService,
|
||||||
private httpRequestService: HttpRequestService,
|
private httpRequestService: HttpRequestService,
|
||||||
private loggerService: LoggerService,
|
private loggerService: LoggerService,
|
||||||
|
private utilityService: UtilityService,
|
||||||
) {
|
) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
this.logger = this.loggerService?.getLogger('ap-request'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる
|
this.logger = this.loggerService?.getLogger('ap-request'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる
|
||||||
|
@ -238,7 +242,7 @@ export class ApRequestService {
|
||||||
const alternate = document.querySelector('head > link[rel="alternate"][type="application/activity+json"]');
|
const alternate = document.querySelector('head > link[rel="alternate"][type="application/activity+json"]');
|
||||||
if (alternate) {
|
if (alternate) {
|
||||||
const href = alternate.getAttribute('href');
|
const href = alternate.getAttribute('href');
|
||||||
if (href) {
|
if (href && this.utilityService.punyHost(url) === this.utilityService.punyHost(href)) {
|
||||||
return await this.signedGet(href, user, false);
|
return await this.signedGet(href, user, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -251,7 +255,11 @@ export class ApRequestService {
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
validateContentTypeSetAsActivityPub(res);
|
validateContentTypeSetAsActivityPub(res);
|
||||||
|
const finalUrl = res.url; // redirects may have been involved
|
||||||
|
const activity = await res.json() as IObject;
|
||||||
|
|
||||||
return await res.json();
|
assertActivityMatchesUrls(activity, [finalUrl]);
|
||||||
|
|
||||||
|
return activity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,7 +41,7 @@ export class Resolver {
|
||||||
private apRendererService: ApRendererService,
|
private apRendererService: ApRendererService,
|
||||||
private apDbResolverService: ApDbResolverService,
|
private apDbResolverService: ApDbResolverService,
|
||||||
private loggerService: LoggerService,
|
private loggerService: LoggerService,
|
||||||
private recursionLimit = 100,
|
private recursionLimit = 256,
|
||||||
) {
|
) {
|
||||||
this.history = new Set();
|
this.history = new Set();
|
||||||
this.logger = this.loggerService.getLogger('ap-resolve');
|
this.logger = this.loggerService.getLogger('ap-resolve');
|
||||||
|
@ -52,6 +52,11 @@ export class Resolver {
|
||||||
return Array.from(this.history);
|
return Array.from(this.history);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public getRecursionLimit(): number {
|
||||||
|
return this.recursionLimit;
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async resolveCollection(value: string | IObject): Promise<ICollection | IOrderedCollection> {
|
public async resolveCollection(value: string | IObject): Promise<ICollection | IOrderedCollection> {
|
||||||
const collection = typeof value === 'string'
|
const collection = typeof value === 'string'
|
||||||
|
@ -113,6 +118,18 @@ export class Resolver {
|
||||||
throw new Error('invalid response');
|
throw new Error('invalid response');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HttpRequestService / ApRequestService have already checked that
|
||||||
|
// `object.id` or `object.url` matches the URL used to fetch the
|
||||||
|
// object after redirects; here we double-check that no redirects
|
||||||
|
// bounced between hosts
|
||||||
|
if (object.id == null) {
|
||||||
|
throw new Error('invalid AP object: missing id');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.utilityService.punyHost(object.id) !== this.utilityService.punyHost(value)) {
|
||||||
|
throw new Error(`invalid AP object ${value}: id ${object.id} has different host`);
|
||||||
|
}
|
||||||
|
|
||||||
return object;
|
return object;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: dakkar and sharkey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
import type { IObject } from '../type.js';
|
||||||
|
|
||||||
|
export function assertActivityMatchesUrls(activity: IObject, urls: string[]) {
|
||||||
|
const idOk = activity.id !== undefined && urls.includes(activity.id);
|
||||||
|
|
||||||
|
// technically `activity.url` could be an `ApObject = IObject |
|
||||||
|
// string | (IObject | string)[]`, but if it's a complicated thing
|
||||||
|
// and the `activity.id` doesn't match, I think we're fine
|
||||||
|
// rejecting the activity
|
||||||
|
const urlOk = typeof(activity.url) === 'string' && urls.includes(activity.url);
|
||||||
|
|
||||||
|
if (!idOk && !urlOk) {
|
||||||
|
throw new Error(`bad Activity: neither id(${activity?.id}) nor url(${activity?.url}) match location(${urls})`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -77,7 +77,7 @@ export class ApNoteService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public validateNote(object: IObject, uri: string): Error | null {
|
public validateNote(object: IObject, uri: string, actor?: MiRemoteUser): Error | null {
|
||||||
const expectHost = this.utilityService.extractDbHost(uri);
|
const expectHost = this.utilityService.extractDbHost(uri);
|
||||||
const apType = getApType(object);
|
const apType = getApType(object);
|
||||||
|
|
||||||
|
@ -98,6 +98,14 @@ export class ApNoteService {
|
||||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', 'invalid Note: published timestamp is malformed');
|
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', 'invalid Note: published timestamp is malformed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (actor) {
|
||||||
|
const attribution = (object.attributedTo) ? getOneApId(object.attributedTo) : actor.uri;
|
||||||
|
|
||||||
|
if (attribution !== actor.uri) {
|
||||||
|
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attribution does not match the actor that send it. attribution: ${attribution}, actor: ${actor.uri}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -115,14 +123,14 @@ export class ApNoteService {
|
||||||
* Noteを作成します。
|
* Noteを作成します。
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public async createNote(value: string | IObject, resolver?: Resolver, silent = false): Promise<MiNote | null> {
|
public async createNote(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver, silent = false): Promise<MiNote | null> {
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||||
|
|
||||||
const object = await resolver.resolve(value);
|
const object = await resolver.resolve(value);
|
||||||
|
|
||||||
const entryUri = getApId(value);
|
const entryUri = getApId(value);
|
||||||
const err = this.validateNote(object, entryUri);
|
const err = this.validateNote(object, entryUri, actor);
|
||||||
if (err) {
|
if (err) {
|
||||||
this.logger.error(err.message, {
|
this.logger.error(err.message, {
|
||||||
resolver: { history: resolver.getHistory() },
|
resolver: { history: resolver.getHistory() },
|
||||||
|
@ -136,14 +144,24 @@ export class ApNoteService {
|
||||||
|
|
||||||
this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
|
this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
|
||||||
|
|
||||||
if (note.id && !checkHttps(note.id)) {
|
if (note.id == null) {
|
||||||
|
throw new Error('Refusing to create note without id');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checkHttps(note.id)) {
|
||||||
throw new Error('unexpected schema of note.id: ' + note.id);
|
throw new Error('unexpected schema of note.id: ' + note.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = getOneApHrefNullable(note.url);
|
const url = getOneApHrefNullable(note.url);
|
||||||
|
|
||||||
if (url && !checkHttps(url)) {
|
if (url != null) {
|
||||||
throw new Error('unexpected schema of note url: ' + url);
|
if (!checkHttps(url)) {
|
||||||
|
throw new Error('unexpected schema of note url: ' + url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.utilityService.punyHost(url) !== this.utilityService.punyHost(note.id)) {
|
||||||
|
throw new Error(`note url & uri host mismatch: note url: ${url}, note uri: ${note.id}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.info(`Creating the Note: ${note.id}`);
|
this.logger.info(`Creating the Note: ${note.id}`);
|
||||||
|
@ -156,8 +174,9 @@ export class ApNoteService {
|
||||||
const uri = getOneApId(note.attributedTo);
|
const uri = getOneApId(note.attributedTo);
|
||||||
|
|
||||||
// ローカルで投稿者を検索し、もし凍結されていたらスキップ
|
// ローカルで投稿者を検索し、もし凍結されていたらスキップ
|
||||||
const cachedActor = await this.apPersonService.fetchPerson(uri) as MiRemoteUser;
|
// eslint-disable-next-line no-param-reassign
|
||||||
if (cachedActor && cachedActor.isSuspended) {
|
actor ??= await this.apPersonService.fetchPerson(uri) as MiRemoteUser | undefined;
|
||||||
|
if (actor && actor.isSuspended) {
|
||||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
|
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -189,7 +208,8 @@ export class ApNoteService {
|
||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
const actor = cachedActor ?? await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser;
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
actor ??= await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser;
|
||||||
|
|
||||||
// 解決した投稿者が凍結されていたらスキップ
|
// 解決した投稿者が凍結されていたらスキップ
|
||||||
if (actor.isSuspended) {
|
if (actor.isSuspended) {
|
||||||
|
@ -348,7 +368,7 @@ export class ApNoteService {
|
||||||
if (exist) return exist;
|
if (exist) return exist;
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
if (uri.startsWith(this.config.url)) {
|
if (this.utilityService.isUriLocal(uri)) {
|
||||||
throw new StatusError('cannot resolve local note', 400, 'cannot resolve local note');
|
throw new StatusError('cannot resolve local note', 400, 'cannot resolve local note');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -356,7 +376,7 @@ export class ApNoteService {
|
||||||
// ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにノートが生成されるが
|
// ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにノートが生成されるが
|
||||||
// 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。
|
// 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。
|
||||||
const createFrom = options.sentFrom?.origin === new URL(uri).origin ? value : uri;
|
const createFrom = options.sentFrom?.origin === new URL(uri).origin ? value : uri;
|
||||||
return await this.createNote(createFrom, options.resolver, true);
|
return await this.createNote(createFrom, undefined, options.resolver, true);
|
||||||
} finally {
|
} finally {
|
||||||
unlock();
|
unlock();
|
||||||
}
|
}
|
||||||
|
|
|
@ -129,12 +129,6 @@ export class ApPersonService implements OnModuleInit {
|
||||||
this.logger = this.apLoggerService.logger;
|
this.logger = this.apLoggerService.logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
private punyHost(url: string): string {
|
|
||||||
const urlObj = new URL(url);
|
|
||||||
const host = `${this.utilityService.toPuny(urlObj.hostname)}${urlObj.port.length > 0 ? ':' + urlObj.port : ''}`;
|
|
||||||
return host;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate and convert to actor object
|
* Validate and convert to actor object
|
||||||
* @param x Fetched object
|
* @param x Fetched object
|
||||||
|
@ -142,7 +136,7 @@ export class ApPersonService implements OnModuleInit {
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
private validateActor(x: IObject, uri: string): IActor {
|
private validateActor(x: IObject, uri: string): IActor {
|
||||||
const expectHost = this.punyHost(uri);
|
const expectHost = this.utilityService.punyHost(uri);
|
||||||
|
|
||||||
if (!isActor(x)) {
|
if (!isActor(x)) {
|
||||||
throw new Error(`invalid Actor type '${x.type}'`);
|
throw new Error(`invalid Actor type '${x.type}'`);
|
||||||
|
@ -156,6 +150,32 @@ export class ApPersonService implements OnModuleInit {
|
||||||
throw new Error('invalid Actor: wrong inbox');
|
throw new Error('invalid Actor: wrong inbox');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.utilityService.punyHost(x.inbox) !== expectHost) {
|
||||||
|
throw new Error('invalid Actor: inbox has different host');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedInboxObject = x.sharedInbox ?? (x.endpoints ? x.endpoints.sharedInbox : undefined);
|
||||||
|
if (sharedInboxObject != null) {
|
||||||
|
const sharedInbox = getApId(sharedInboxObject);
|
||||||
|
if (!(typeof sharedInbox === 'string' && sharedInbox.length > 0 && this.utilityService.punyHost(sharedInbox) === expectHost)) {
|
||||||
|
throw new Error('invalid Actor: wrong shared inbox');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const collection of ['outbox', 'followers', 'following'] as (keyof IActor)[]) {
|
||||||
|
const xCollection = (x as IActor)[collection];
|
||||||
|
if (xCollection != null) {
|
||||||
|
const collectionUri = getApId(xCollection);
|
||||||
|
if (typeof collectionUri === 'string' && collectionUri.length > 0) {
|
||||||
|
if (this.utilityService.punyHost(collectionUri) !== expectHost) {
|
||||||
|
throw new Error(`invalid Actor: ${collection} has different host`);
|
||||||
|
}
|
||||||
|
} else if (collectionUri != null) {
|
||||||
|
throw new Error(`invalid Actor: wrong ${collection}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!(typeof x.preferredUsername === 'string' && x.preferredUsername.length > 0 && x.preferredUsername.length <= 128 && /^\w([\w-.]*\w)?$/.test(x.preferredUsername))) {
|
if (!(typeof x.preferredUsername === 'string' && x.preferredUsername.length > 0 && x.preferredUsername.length <= 128 && /^\w([\w-.]*\w)?$/.test(x.preferredUsername))) {
|
||||||
throw new Error('invalid Actor: wrong username');
|
throw new Error('invalid Actor: wrong username');
|
||||||
}
|
}
|
||||||
|
@ -179,7 +199,7 @@ export class ApPersonService implements OnModuleInit {
|
||||||
x.summary = truncate(x.summary, summaryLength);
|
x.summary = truncate(x.summary, summaryLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
const idHost = this.punyHost(x.id);
|
const idHost = this.utilityService.punyHost(x.id);
|
||||||
if (idHost !== expectHost) {
|
if (idHost !== expectHost) {
|
||||||
throw new Error('invalid Actor: id has different host');
|
throw new Error('invalid Actor: id has different host');
|
||||||
}
|
}
|
||||||
|
@ -189,7 +209,7 @@ export class ApPersonService implements OnModuleInit {
|
||||||
throw new Error('invalid Actor: publicKey.id is not a string');
|
throw new Error('invalid Actor: publicKey.id is not a string');
|
||||||
}
|
}
|
||||||
|
|
||||||
const publicKeyIdHost = this.punyHost(x.publicKey.id);
|
const publicKeyIdHost = this.utilityService.punyHost(x.publicKey.id);
|
||||||
if (publicKeyIdHost !== expectHost) {
|
if (publicKeyIdHost !== expectHost) {
|
||||||
throw new Error('invalid Actor: publicKey.id has different host');
|
throw new Error('invalid Actor: publicKey.id has different host');
|
||||||
}
|
}
|
||||||
|
@ -280,7 +300,8 @@ export class ApPersonService implements OnModuleInit {
|
||||||
public async createPerson(uri: string, resolver?: Resolver): Promise<MiRemoteUser> {
|
public async createPerson(uri: string, resolver?: Resolver): Promise<MiRemoteUser> {
|
||||||
if (typeof uri !== 'string') throw new Error('uri is not string');
|
if (typeof uri !== 'string') throw new Error('uri is not string');
|
||||||
|
|
||||||
if (uri.startsWith(this.config.url)) {
|
const host = this.utilityService.punyHost(uri);
|
||||||
|
if (host === this.utilityService.toPuny(this.config.host)) {
|
||||||
throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user');
|
throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -294,8 +315,6 @@ export class ApPersonService implements OnModuleInit {
|
||||||
|
|
||||||
this.logger.info(`Creating the Person: ${person.id}`);
|
this.logger.info(`Creating the Person: ${person.id}`);
|
||||||
|
|
||||||
const host = this.punyHost(object.id);
|
|
||||||
|
|
||||||
const fields = this.analyzeAttachments(person.attachment ?? []);
|
const fields = this.analyzeAttachments(person.attachment ?? []);
|
||||||
|
|
||||||
const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32);
|
const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32);
|
||||||
|
@ -321,8 +340,18 @@ export class ApPersonService implements OnModuleInit {
|
||||||
|
|
||||||
const url = getOneApHrefNullable(person.url);
|
const url = getOneApHrefNullable(person.url);
|
||||||
|
|
||||||
if (url && !checkHttps(url)) {
|
if (person.id == null) {
|
||||||
throw new Error('unexpected schema of person url: ' + url);
|
throw new Error('Refusing to create person without id');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url != null) {
|
||||||
|
if (!checkHttps(url)) {
|
||||||
|
throw new Error('unexpected schema of person url: ' + url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.utilityService.punyHost(url) !== this.utilityService.punyHost(person.id)) {
|
||||||
|
throw new Error(`person url <> uri host mismatch: ${url} <> ${person.id}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create user
|
// Create user
|
||||||
|
@ -465,7 +494,7 @@ export class ApPersonService implements OnModuleInit {
|
||||||
if (typeof uri !== 'string') throw new Error('uri is not string');
|
if (typeof uri !== 'string') throw new Error('uri is not string');
|
||||||
|
|
||||||
// URIがこのサーバーを指しているならスキップ
|
// URIがこのサーバーを指しているならスキップ
|
||||||
if (uri.startsWith(`${this.config.url}/`)) return;
|
if (this.utilityService.isUriLocal(uri)) return;
|
||||||
|
|
||||||
//#region このサーバーに既に登録されているか
|
//#region このサーバーに既に登録されているか
|
||||||
const exist = await this.fetchPerson(uri) as MiRemoteUser | null;
|
const exist = await this.fetchPerson(uri) as MiRemoteUser | null;
|
||||||
|
@ -514,8 +543,18 @@ export class ApPersonService implements OnModuleInit {
|
||||||
|
|
||||||
const url = getOneApHrefNullable(person.url);
|
const url = getOneApHrefNullable(person.url);
|
||||||
|
|
||||||
if (url && !checkHttps(url)) {
|
if (person.id == null) {
|
||||||
throw new Error('unexpected schema of person url: ' + url);
|
throw new Error('Refusing to update person without id');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url != null) {
|
||||||
|
if (!checkHttps(url)) {
|
||||||
|
throw new Error('unexpected schema of person url: ' + url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.utilityService.punyHost(url) !== this.utilityService.punyHost(person.id)) {
|
||||||
|
throw new Error(`person url <> uri host mismatch: ${url} <> ${person.id}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updates = {
|
const updates = {
|
||||||
|
@ -728,7 +767,7 @@ export class ApPersonService implements OnModuleInit {
|
||||||
await this.updatePerson(src.movedToUri, undefined, undefined, [...movePreventUris, src.uri]);
|
await this.updatePerson(src.movedToUri, undefined, undefined, [...movePreventUris, src.uri]);
|
||||||
dst = await this.fetchPerson(src.movedToUri) ?? dst;
|
dst = await this.fetchPerson(src.movedToUri) ?? dst;
|
||||||
} else {
|
} else {
|
||||||
if (src.movedToUri.startsWith(`${this.config.url}/`)) {
|
if (this.utilityService.isUriLocal(src.movedToUri)) {
|
||||||
// ローカルユーザーっぽいのにfetchPersonで見つからないということはmovedToUriが間違っている
|
// ローカルユーザーっぽいのにfetchPersonで見つからないということはmovedToUriが間違っている
|
||||||
return 'failed: movedTo is local but not found';
|
return 'failed: movedTo is local but not found';
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,16 +5,18 @@
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { NotesRepository, PollsRepository } from '@/models/_.js';
|
import type { UsersRepository, NotesRepository, PollsRepository } from '@/models/_.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import type { IPoll } from '@/models/Poll.js';
|
import type { IPoll } from '@/models/Poll.js';
|
||||||
|
import type { MiRemoteUser } from '@/models/User.js';
|
||||||
import type Logger from '@/logger.js';
|
import type Logger from '@/logger.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { isQuestion } from '../type.js';
|
import { getOneApId, isQuestion } from '../type.js';
|
||||||
|
import { UtilityService } from '@/core/UtilityService.js';
|
||||||
import { ApLoggerService } from '../ApLoggerService.js';
|
import { ApLoggerService } from '../ApLoggerService.js';
|
||||||
import { ApResolverService } from '../ApResolverService.js';
|
import { ApResolverService } from '../ApResolverService.js';
|
||||||
import type { Resolver } from '../ApResolverService.js';
|
import type { Resolver } from '../ApResolverService.js';
|
||||||
import type { IObject, IQuestion } from '../type.js';
|
import type { IObject } from '../type.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApQuestionService {
|
export class ApQuestionService {
|
||||||
|
@ -24,6 +26,9 @@ export class ApQuestionService {
|
||||||
@Inject(DI.config)
|
@Inject(DI.config)
|
||||||
private config: Config,
|
private config: Config,
|
||||||
|
|
||||||
|
@Inject(DI.usersRepository)
|
||||||
|
private usersRepository: UsersRepository,
|
||||||
|
|
||||||
@Inject(DI.notesRepository)
|
@Inject(DI.notesRepository)
|
||||||
private notesRepository: NotesRepository,
|
private notesRepository: NotesRepository,
|
||||||
|
|
||||||
|
@ -32,6 +37,7 @@ export class ApQuestionService {
|
||||||
|
|
||||||
private apResolverService: ApResolverService,
|
private apResolverService: ApResolverService,
|
||||||
private apLoggerService: ApLoggerService,
|
private apLoggerService: ApLoggerService,
|
||||||
|
private utilityService: UtilityService,
|
||||||
) {
|
) {
|
||||||
this.logger = this.apLoggerService.logger;
|
this.logger = this.apLoggerService.logger;
|
||||||
}
|
}
|
||||||
|
@ -65,12 +71,12 @@ export class ApQuestionService {
|
||||||
* @returns true if updated
|
* @returns true if updated
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public async updateQuestion(value: string | IObject, resolver?: Resolver): Promise<boolean> {
|
public async updateQuestion(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver): Promise<boolean> {
|
||||||
const uri = typeof value === 'string' ? value : value.id;
|
const uri = typeof value === 'string' ? value : value.id;
|
||||||
if (uri == null) throw new Error('uri is null');
|
if (uri == null) throw new Error('uri is null');
|
||||||
|
|
||||||
// URIがこのサーバーを指しているならスキップ
|
// URIがこのサーバーを指しているならスキップ
|
||||||
if (uri.startsWith(this.config.url + '/')) throw new Error('uri points local');
|
if (this.utilityService.isUriLocal(uri)) throw new Error('uri points local');
|
||||||
|
|
||||||
//#region このサーバーに既に登録されているか
|
//#region このサーバーに既に登録されているか
|
||||||
const note = await this.notesRepository.findOneBy({ uri });
|
const note = await this.notesRepository.findOneBy({ uri });
|
||||||
|
@ -78,15 +84,26 @@ export class ApQuestionService {
|
||||||
|
|
||||||
const poll = await this.pollsRepository.findOneBy({ noteId: note.id });
|
const poll = await this.pollsRepository.findOneBy({ noteId: note.id });
|
||||||
if (poll == null) throw new Error('Question is not registered');
|
if (poll == null) throw new Error('Question is not registered');
|
||||||
|
|
||||||
|
const user = await this.usersRepository.findOneBy({ id: poll.userId });
|
||||||
|
if (user == null) throw new Error('Question is not registered');
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
// resolve new Question object
|
// resolve new Question object
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||||
const question = await resolver.resolve(value) as IQuestion;
|
const question = await resolver.resolve(value);
|
||||||
this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`);
|
this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`);
|
||||||
|
|
||||||
if (question.type !== 'Question') throw new Error('object is not a Question');
|
if (!isQuestion(question)) throw new Error('object is not a Question');
|
||||||
|
|
||||||
|
const attribution = (question.attributedTo) ? getOneApId(question.attributedTo) : user.uri;
|
||||||
|
const attributionMatchesExisting = attribution === user.uri;
|
||||||
|
const actorMatchesAttribution = (actor) ? attribution === actor.uri : true;
|
||||||
|
|
||||||
|
if (!attributionMatchesExisting || !actorMatchesAttribution) {
|
||||||
|
throw new Error('Refusing to ingest update for poll by different user');
|
||||||
|
}
|
||||||
|
|
||||||
const apChoices = question.oneOf ?? question.anyOf;
|
const apChoices = question.oneOf ?? question.anyOf;
|
||||||
if (apChoices == null) throw new Error('invalid apChoices: ' + apChoices);
|
if (apChoices == null) throw new Error('invalid apChoices: ' + apChoices);
|
||||||
|
@ -96,7 +113,7 @@ export class ApQuestionService {
|
||||||
for (const choice of poll.choices) {
|
for (const choice of poll.choices) {
|
||||||
const oldCount = poll.votes[poll.choices.indexOf(choice)];
|
const oldCount = poll.votes[poll.choices.indexOf(choice)];
|
||||||
const newCount = apChoices.filter(ap => ap.name === choice).at(0)?.replies?.totalItems;
|
const newCount = apChoices.filter(ap => ap.name === choice).at(0)?.replies?.totalItems;
|
||||||
if (newCount == null) throw new Error('invalid newCount: ' + newCount);
|
if (newCount == null || !(Number.isInteger(newCount) && newCount >= 0)) throw new Error('invalid newCount: ' + newCount);
|
||||||
|
|
||||||
if (oldCount !== newCount) {
|
if (oldCount !== newCount) {
|
||||||
changed = true;
|
changed = true;
|
||||||
|
|
|
@ -4,36 +4,40 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { In } from 'typeorm';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { ChannelFavoritesRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NotesRepository } from '@/models/_.js';
|
import type {
|
||||||
|
ChannelFavoritesRepository,
|
||||||
|
ChannelFollowingsRepository, ChannelMutingRepository,
|
||||||
|
ChannelsRepository,
|
||||||
|
DriveFilesRepository,
|
||||||
|
MiDriveFile,
|
||||||
|
MiNote,
|
||||||
|
NotesRepository,
|
||||||
|
} from '@/models/_.js';
|
||||||
import type { Packed } from '@/misc/json-schema.js';
|
import type { Packed } from '@/misc/json-schema.js';
|
||||||
import type { } from '@/models/Blocking.js';
|
|
||||||
import type { MiUser } from '@/models/User.js';
|
import type { MiUser } from '@/models/User.js';
|
||||||
import type { MiChannel } from '@/models/Channel.js';
|
import type { MiChannel } from '@/models/Channel.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import { DriveFileEntityService } from './DriveFileEntityService.js';
|
import { DriveFileEntityService } from './DriveFileEntityService.js';
|
||||||
import { NoteEntityService } from './NoteEntityService.js';
|
import { NoteEntityService } from './NoteEntityService.js';
|
||||||
import { In } from 'typeorm';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ChannelEntityService {
|
export class ChannelEntityService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.channelsRepository)
|
@Inject(DI.channelsRepository)
|
||||||
private channelsRepository: ChannelsRepository,
|
private channelsRepository: ChannelsRepository,
|
||||||
|
|
||||||
@Inject(DI.channelFollowingsRepository)
|
@Inject(DI.channelFollowingsRepository)
|
||||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||||
|
|
||||||
@Inject(DI.channelFavoritesRepository)
|
@Inject(DI.channelFavoritesRepository)
|
||||||
private channelFavoritesRepository: ChannelFavoritesRepository,
|
private channelFavoritesRepository: ChannelFavoritesRepository,
|
||||||
|
@Inject(DI.channelMutingRepository)
|
||||||
|
private channelMutingRepository: ChannelMutingRepository,
|
||||||
@Inject(DI.notesRepository)
|
@Inject(DI.notesRepository)
|
||||||
private notesRepository: NotesRepository,
|
private notesRepository: NotesRepository,
|
||||||
|
|
||||||
@Inject(DI.driveFilesRepository)
|
@Inject(DI.driveFilesRepository)
|
||||||
private driveFilesRepository: DriveFilesRepository,
|
private driveFilesRepository: DriveFilesRepository,
|
||||||
|
|
||||||
private noteEntityService: NoteEntityService,
|
private noteEntityService: NoteEntityService,
|
||||||
private driveFileEntityService: DriveFileEntityService,
|
private driveFileEntityService: DriveFileEntityService,
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
|
@ -45,31 +49,59 @@ export class ChannelEntityService {
|
||||||
src: MiChannel['id'] | MiChannel,
|
src: MiChannel['id'] | MiChannel,
|
||||||
me?: { id: MiUser['id'] } | null | undefined,
|
me?: { id: MiUser['id'] } | null | undefined,
|
||||||
detailed?: boolean,
|
detailed?: boolean,
|
||||||
|
opts?: {
|
||||||
|
bannerFiles?: Map<MiDriveFile['id'], MiDriveFile>;
|
||||||
|
followings?: Set<MiChannel['id']>;
|
||||||
|
favorites?: Set<MiChannel['id']>;
|
||||||
|
muting?: Set<MiChannel['id']>;
|
||||||
|
pinnedNotes?: Map<MiNote['id'], MiNote>;
|
||||||
|
},
|
||||||
): Promise<Packed<'Channel'>> {
|
): Promise<Packed<'Channel'>> {
|
||||||
const channel = typeof src === 'object' ? src : await this.channelsRepository.findOneByOrFail({ id: src });
|
const channel = typeof src === 'object' ? src : await this.channelsRepository.findOneByOrFail({ id: src });
|
||||||
const meId = me ? me.id : null;
|
|
||||||
|
|
||||||
const banner = channel.bannerId ? await this.driveFilesRepository.findOneBy({ id: channel.bannerId }) : null;
|
let bannerFile: MiDriveFile | null = null;
|
||||||
|
if (channel.bannerId) {
|
||||||
|
bannerFile = opts?.bannerFiles?.get(channel.bannerId)
|
||||||
|
?? await this.driveFilesRepository.findOneByOrFail({ id: channel.bannerId });
|
||||||
|
}
|
||||||
|
|
||||||
const isFollowing = meId ? await this.channelFollowingsRepository.exists({
|
let isFollowing = false;
|
||||||
where: {
|
let isFavorited = false;
|
||||||
followerId: meId,
|
let isMuting = false;
|
||||||
followeeId: channel.id,
|
if (me) {
|
||||||
},
|
isFollowing = opts?.followings?.has(channel.id) ?? await this.channelFollowingsRepository.exists({
|
||||||
}) : false;
|
where: {
|
||||||
|
followerId: me.id,
|
||||||
|
followeeId: channel.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const isFavorited = meId ? await this.channelFavoritesRepository.exists({
|
isFavorited = opts?.favorites?.has(channel.id) ?? await this.channelFavoritesRepository.exists({
|
||||||
where: {
|
where: {
|
||||||
userId: meId,
|
userId: me.id,
|
||||||
channelId: channel.id,
|
channelId: channel.id,
|
||||||
},
|
},
|
||||||
}) : false;
|
});
|
||||||
|
|
||||||
const pinnedNotes = channel.pinnedNoteIds.length > 0 ? await this.notesRepository.find({
|
isMuting = opts?.muting?.has(channel.id) ?? await this.channelMutingRepository.exists({
|
||||||
where: {
|
where: {
|
||||||
id: In(channel.pinnedNoteIds),
|
userId: me.id,
|
||||||
},
|
channelId: channel.id,
|
||||||
}) : [];
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinnedNotes = Array.of<MiNote>();
|
||||||
|
if (channel.pinnedNoteIds.length > 0) {
|
||||||
|
pinnedNotes.push(
|
||||||
|
...(
|
||||||
|
opts?.pinnedNotes
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
? channel.pinnedNoteIds.map(it => opts.pinnedNotes!.get(it)).filter(it => it != null)
|
||||||
|
: await this.notesRepository.findBy({ id: In(channel.pinnedNoteIds) })
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: channel.id,
|
id: channel.id,
|
||||||
|
@ -78,7 +110,7 @@ export class ChannelEntityService {
|
||||||
name: channel.name,
|
name: channel.name,
|
||||||
description: channel.description,
|
description: channel.description,
|
||||||
userId: channel.userId,
|
userId: channel.userId,
|
||||||
bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner) : null,
|
bannerUrl: bannerFile ? this.driveFileEntityService.getPublicUrl(bannerFile) : null,
|
||||||
pinnedNoteIds: channel.pinnedNoteIds,
|
pinnedNoteIds: channel.pinnedNoteIds,
|
||||||
color: channel.color,
|
color: channel.color,
|
||||||
isArchived: channel.isArchived,
|
isArchived: channel.isArchived,
|
||||||
|
@ -90,6 +122,7 @@ export class ChannelEntityService {
|
||||||
...(me ? {
|
...(me ? {
|
||||||
isFollowing,
|
isFollowing,
|
||||||
isFavorited,
|
isFavorited,
|
||||||
|
isMuting,
|
||||||
hasUnreadNote: false, // 後方互換性のため
|
hasUnreadNote: false, // 後方互換性のため
|
||||||
} : {}),
|
} : {}),
|
||||||
|
|
||||||
|
@ -98,5 +131,72 @@ export class ChannelEntityService {
|
||||||
} : {}),
|
} : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async packMany(
|
||||||
|
src: MiChannel['id'][] | MiChannel[],
|
||||||
|
me?: { id: MiUser['id'] } | null | undefined,
|
||||||
|
detailed?: boolean,
|
||||||
|
): Promise<Packed<'Channel'>[]> {
|
||||||
|
// IDのみの要素がある場合、DBからオブジェクトを取得して補う
|
||||||
|
const channels = src.filter(it => typeof it === 'object') as MiChannel[];
|
||||||
|
channels.push(
|
||||||
|
...(await this.channelsRepository.find({
|
||||||
|
where: {
|
||||||
|
id: In(src.filter(it => typeof it !== 'object') as MiChannel['id'][]),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
channels.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
|
|
||||||
|
const bannerFiles = await this.driveFilesRepository
|
||||||
|
.findBy({
|
||||||
|
id: In(channels.map(it => it.bannerId).filter(it => it != null)),
|
||||||
|
})
|
||||||
|
.then(it => new Map(it.map(it => [it.id, it])));
|
||||||
|
|
||||||
|
const followings = me
|
||||||
|
? await this.channelFollowingsRepository
|
||||||
|
.findBy({
|
||||||
|
followerId: me.id,
|
||||||
|
followeeId: In(channels.map(it => it.id)),
|
||||||
|
})
|
||||||
|
.then(it => new Set(it.map(it => it.followeeId)))
|
||||||
|
: new Set<MiChannel['id']>();
|
||||||
|
|
||||||
|
const favorites = me
|
||||||
|
? await this.channelFavoritesRepository
|
||||||
|
.findBy({
|
||||||
|
userId: me.id,
|
||||||
|
channelId: In(channels.map(it => it.id)),
|
||||||
|
})
|
||||||
|
.then(it => new Set(it.map(it => it.channelId)))
|
||||||
|
: new Set<MiChannel['id']>();
|
||||||
|
|
||||||
|
const muting = me
|
||||||
|
? await this.channelMutingRepository
|
||||||
|
.findBy({
|
||||||
|
userId: me.id,
|
||||||
|
channelId: In(channels.map(it => it.id)),
|
||||||
|
})
|
||||||
|
.then(it => new Set(it.map(it => it.channelId)))
|
||||||
|
: new Set<MiChannel['id']>();
|
||||||
|
|
||||||
|
const pinnedNotes = await this.notesRepository
|
||||||
|
.find({
|
||||||
|
where: {
|
||||||
|
id: In(channels.flatMap(it => it.pinnedNoteIds)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(it => new Map(it.map(it => [it.id, it])));
|
||||||
|
|
||||||
|
return Promise.all(channels.map(it => this.pack(it, me, detailed, {
|
||||||
|
bannerFiles,
|
||||||
|
followings,
|
||||||
|
favorites,
|
||||||
|
muting,
|
||||||
|
pinnedNotes,
|
||||||
|
})));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -71,6 +71,7 @@ export const DI = {
|
||||||
channelsRepository: Symbol('channelsRepository'),
|
channelsRepository: Symbol('channelsRepository'),
|
||||||
channelFollowingsRepository: Symbol('channelFollowingsRepository'),
|
channelFollowingsRepository: Symbol('channelFollowingsRepository'),
|
||||||
channelFavoritesRepository: Symbol('channelFavoritesRepository'),
|
channelFavoritesRepository: Symbol('channelFavoritesRepository'),
|
||||||
|
channelMutingRepository: Symbol('channelMutingRepository'),
|
||||||
registryItemsRepository: Symbol('registryItemsRepository'),
|
registryItemsRepository: Symbol('registryItemsRepository'),
|
||||||
webhooksRepository: Symbol('webhooksRepository'),
|
webhooksRepository: Symbol('webhooksRepository'),
|
||||||
systemWebhooksRepository: Symbol('systemWebhooksRepository'),
|
systemWebhooksRepository: Symbol('systemWebhooksRepository'),
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MiNote } from '@/models/Note.js';
|
||||||
|
import { Packed } from '@/misc/json-schema.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link note}が{@link channelIds}のチャンネルに関連するかどうかを判定し、関連する場合はtrueを返します。
|
||||||
|
* 関連するというのは、{@link channelIds}のチャンネルに向けての投稿であるか、またはそのチャンネルの投稿をリノート・引用リノートした投稿であるかを指します。
|
||||||
|
*
|
||||||
|
* @param note 確認対象のノート
|
||||||
|
* @param channelIds 確認対象のチャンネルID一覧
|
||||||
|
*/
|
||||||
|
export function isChannelRelated(note: MiNote | Packed<'Note'>, channelIds: Set<string>): boolean {
|
||||||
|
if (note.channelId && channelIds.has(note.channelId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note.renote != null && note.renote.channelId && channelIds.has(note.renote.channelId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: リプライはchannelIdのチェックだけでOKなはずなので見てない(チャンネルのノートにチャンネル外からのリプライまたはその逆はないはずなので)
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
|
@ -7,6 +7,8 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typ
|
||||||
import { id } from './util/id.js';
|
import { id } from './util/id.js';
|
||||||
import { MiUser } from './User.js';
|
import { MiUser } from './User.js';
|
||||||
|
|
||||||
|
export type AbuseReportResolveType = 'accept' | 'reject';
|
||||||
|
|
||||||
@Entity('abuse_user_report')
|
@Entity('abuse_user_report')
|
||||||
export class MiAbuseUserReport {
|
export class MiAbuseUserReport {
|
||||||
@PrimaryColumn(id())
|
@PrimaryColumn(id())
|
||||||
|
@ -76,7 +78,7 @@ export class MiAbuseUserReport {
|
||||||
@Column('varchar', {
|
@Column('varchar', {
|
||||||
length: 128, nullable: true,
|
length: 128, nullable: true,
|
||||||
})
|
})
|
||||||
public resolvedAs: 'accept' | 'reject' | null;
|
public resolvedAs: AbuseReportResolveType | null;
|
||||||
|
|
||||||
//#region Denormalized fields
|
//#region Denormalized fields
|
||||||
@Index()
|
@Index()
|
||||||
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm';
|
||||||
|
import { id } from './util/id.js';
|
||||||
|
import { MiUser } from './User.js';
|
||||||
|
import { MiChannel } from './Channel.js';
|
||||||
|
|
||||||
|
@Entity('channel_muting')
|
||||||
|
@Index(['userId', 'channelId'], {})
|
||||||
|
export class MiChannelMuting {
|
||||||
|
@PrimaryColumn(id())
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({
|
||||||
|
...id(),
|
||||||
|
})
|
||||||
|
public userId: MiUser['id'];
|
||||||
|
|
||||||
|
@ManyToOne(type => MiUser, {
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
})
|
||||||
|
@JoinColumn()
|
||||||
|
public user: MiUser | null;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({
|
||||||
|
...id(),
|
||||||
|
})
|
||||||
|
public channelId: MiChannel['id'];
|
||||||
|
|
||||||
|
@ManyToOne(type => MiChannel, {
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
})
|
||||||
|
@JoinColumn()
|
||||||
|
public channel: MiChannel | null;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column('timestamp with time zone', {
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
public expiresAt: Date | null;
|
||||||
|
}
|
|
@ -229,6 +229,13 @@ export class MiNote {
|
||||||
comment: '[Denormalized]',
|
comment: '[Denormalized]',
|
||||||
})
|
})
|
||||||
public renoteUserHost: string | null;
|
public renoteUserHost: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
...id(),
|
||||||
|
nullable: true,
|
||||||
|
comment: '[Denormalized]',
|
||||||
|
})
|
||||||
|
public renoteChannelId: MiChannel['id'] | null;
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
constructor(data: Partial<MiNote>) {
|
constructor(data: Partial<MiNote>) {
|
||||||
|
|
|
@ -22,6 +22,7 @@ import {
|
||||||
MiChannel,
|
MiChannel,
|
||||||
MiChannelFavorite,
|
MiChannelFavorite,
|
||||||
MiChannelFollowing,
|
MiChannelFollowing,
|
||||||
|
MiChannelMuting,
|
||||||
MiClip,
|
MiClip,
|
||||||
MiClipFavorite,
|
MiClipFavorite,
|
||||||
MiClipNote,
|
MiClipNote,
|
||||||
|
@ -417,6 +418,12 @@ const $channelFavoritesRepository: Provider = {
|
||||||
inject: [DI.db],
|
inject: [DI.db],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const $channelMutingRepository: Provider = {
|
||||||
|
provide: DI.channelMutingRepository,
|
||||||
|
useFactory: (db: DataSource) => db.getRepository(MiChannelMuting).extend(miRepository as MiRepository<MiChannelMuting>),
|
||||||
|
inject: [DI.db],
|
||||||
|
};
|
||||||
|
|
||||||
const $registryItemsRepository: Provider = {
|
const $registryItemsRepository: Provider = {
|
||||||
provide: DI.registryItemsRepository,
|
provide: DI.registryItemsRepository,
|
||||||
useFactory: (db: DataSource) => db.getRepository(MiRegistryItem).extend(miRepository as MiRepository<MiRegistryItem>),
|
useFactory: (db: DataSource) => db.getRepository(MiRegistryItem).extend(miRepository as MiRepository<MiRegistryItem>),
|
||||||
|
@ -554,6 +561,7 @@ const $reversiGamesRepository: Provider = {
|
||||||
$channelsRepository,
|
$channelsRepository,
|
||||||
$channelFollowingsRepository,
|
$channelFollowingsRepository,
|
||||||
$channelFavoritesRepository,
|
$channelFavoritesRepository,
|
||||||
|
$channelMutingRepository,
|
||||||
$registryItemsRepository,
|
$registryItemsRepository,
|
||||||
$webhooksRepository,
|
$webhooksRepository,
|
||||||
$systemWebhooksRepository,
|
$systemWebhooksRepository,
|
||||||
|
@ -625,6 +633,7 @@ const $reversiGamesRepository: Provider = {
|
||||||
$channelsRepository,
|
$channelsRepository,
|
||||||
$channelFollowingsRepository,
|
$channelFollowingsRepository,
|
||||||
$channelFavoritesRepository,
|
$channelFavoritesRepository,
|
||||||
|
$channelMutingRepository,
|
||||||
$registryItemsRepository,
|
$registryItemsRepository,
|
||||||
$webhooksRepository,
|
$webhooksRepository,
|
||||||
$systemWebhooksRepository,
|
$systemWebhooksRepository,
|
||||||
|
|
|
@ -3,13 +3,10 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { FindOneOptions, InsertQueryBuilder, ObjectLiteral, Repository, SelectQueryBuilder, TypeORMError } from 'typeorm';
|
import { FindOneOptions, InsertQueryBuilder, ObjectLiteral, Repository, SelectQueryBuilder } from 'typeorm';
|
||||||
import { DriverUtils } from 'typeorm/driver/DriverUtils.js';
|
|
||||||
import { RelationCountLoader } from 'typeorm/query-builder/relation-count/RelationCountLoader.js';
|
import { RelationCountLoader } from 'typeorm/query-builder/relation-count/RelationCountLoader.js';
|
||||||
import { RelationIdLoader } from 'typeorm/query-builder/relation-id/RelationIdLoader.js';
|
import { RelationIdLoader } from 'typeorm/query-builder/relation-id/RelationIdLoader.js';
|
||||||
import { RawSqlResultsToEntityTransformer } from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js';
|
import { RawSqlResultsToEntityTransformer } from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js';
|
||||||
import { ObjectUtils } from 'typeorm/util/ObjectUtils.js';
|
|
||||||
import { OrmUtils } from 'typeorm/util/OrmUtils.js';
|
|
||||||
import { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
|
import { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
|
||||||
import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js';
|
import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js';
|
||||||
import { MiAccessToken } from '@/models/AccessToken.js';
|
import { MiAccessToken } from '@/models/AccessToken.js';
|
||||||
|
@ -23,6 +20,7 @@ import { MiAuthSession } from '@/models/AuthSession.js';
|
||||||
import { MiBlocking } from '@/models/Blocking.js';
|
import { MiBlocking } from '@/models/Blocking.js';
|
||||||
import { MiChannelFollowing } from '@/models/ChannelFollowing.js';
|
import { MiChannelFollowing } from '@/models/ChannelFollowing.js';
|
||||||
import { MiChannelFavorite } from '@/models/ChannelFavorite.js';
|
import { MiChannelFavorite } from '@/models/ChannelFavorite.js';
|
||||||
|
import { MiChannelMuting } from "@/models/ChannelMuting.js";
|
||||||
import { MiClip } from '@/models/Clip.js';
|
import { MiClip } from '@/models/Clip.js';
|
||||||
import { MiClipNote } from '@/models/ClipNote.js';
|
import { MiClipNote } from '@/models/ClipNote.js';
|
||||||
import { MiClipFavorite } from '@/models/ClipFavorite.js';
|
import { MiClipFavorite } from '@/models/ClipFavorite.js';
|
||||||
|
@ -138,6 +136,7 @@ export {
|
||||||
MiBlocking,
|
MiBlocking,
|
||||||
MiChannelFollowing,
|
MiChannelFollowing,
|
||||||
MiChannelFavorite,
|
MiChannelFavorite,
|
||||||
|
MiChannelMuting,
|
||||||
MiClip,
|
MiClip,
|
||||||
MiClipNote,
|
MiClipNote,
|
||||||
MiClipFavorite,
|
MiClipFavorite,
|
||||||
|
@ -209,6 +208,7 @@ export type AuthSessionsRepository = Repository<MiAuthSession> & MiRepository<Mi
|
||||||
export type BlockingsRepository = Repository<MiBlocking> & MiRepository<MiBlocking>;
|
export type BlockingsRepository = Repository<MiBlocking> & MiRepository<MiBlocking>;
|
||||||
export type ChannelFollowingsRepository = Repository<MiChannelFollowing> & MiRepository<MiChannelFollowing>;
|
export type ChannelFollowingsRepository = Repository<MiChannelFollowing> & MiRepository<MiChannelFollowing>;
|
||||||
export type ChannelFavoritesRepository = Repository<MiChannelFavorite> & MiRepository<MiChannelFavorite>;
|
export type ChannelFavoritesRepository = Repository<MiChannelFavorite> & MiRepository<MiChannelFavorite>;
|
||||||
|
export type ChannelMutingRepository = Repository<MiChannelMuting> & MiRepository<MiChannelMuting>;
|
||||||
export type ClipsRepository = Repository<MiClip> & MiRepository<MiClip>;
|
export type ClipsRepository = Repository<MiClip> & MiRepository<MiClip>;
|
||||||
export type ClipNotesRepository = Repository<MiClipNote> & MiRepository<MiClipNote>;
|
export type ClipNotesRepository = Repository<MiClipNote> & MiRepository<MiClipNote>;
|
||||||
export type ClipFavoritesRepository = Repository<MiClipFavorite> & MiRepository<MiClipFavorite>;
|
export type ClipFavoritesRepository = Repository<MiClipFavorite> & MiRepository<MiClipFavorite>;
|
||||||
|
|
|
@ -80,6 +80,10 @@ export const packedChannelSchema = {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
optional: true, nullable: false,
|
optional: true, nullable: false,
|
||||||
},
|
},
|
||||||
|
isMuting: {
|
||||||
|
type: 'boolean',
|
||||||
|
optional: true, nullable: false,
|
||||||
|
},
|
||||||
pinnedNotes: {
|
pinnedNotes: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
optional: true, nullable: false,
|
optional: true, nullable: false,
|
||||||
|
|
|
@ -22,6 +22,7 @@ import { MiAuthSession } from '@/models/AuthSession.js';
|
||||||
import { MiBlocking } from '@/models/Blocking.js';
|
import { MiBlocking } from '@/models/Blocking.js';
|
||||||
import { MiChannelFollowing } from '@/models/ChannelFollowing.js';
|
import { MiChannelFollowing } from '@/models/ChannelFollowing.js';
|
||||||
import { MiChannelFavorite } from '@/models/ChannelFavorite.js';
|
import { MiChannelFavorite } from '@/models/ChannelFavorite.js';
|
||||||
|
import { MiChannelMuting } from "@/models/ChannelMuting.js";
|
||||||
import { MiClip } from '@/models/Clip.js';
|
import { MiClip } from '@/models/Clip.js';
|
||||||
import { MiClipNote } from '@/models/ClipNote.js';
|
import { MiClipNote } from '@/models/ClipNote.js';
|
||||||
import { MiClipFavorite } from '@/models/ClipFavorite.js';
|
import { MiClipFavorite } from '@/models/ClipFavorite.js';
|
||||||
|
@ -183,6 +184,7 @@ export const entities = [
|
||||||
MiChannel,
|
MiChannel,
|
||||||
MiChannelFollowing,
|
MiChannelFollowing,
|
||||||
MiChannelFavorite,
|
MiChannelFavorite,
|
||||||
|
MiChannelMuting,
|
||||||
MiRegistryItem,
|
MiRegistryItem,
|
||||||
MiAd,
|
MiAd,
|
||||||
MiPasswordResetRequest,
|
MiPasswordResetRequest,
|
||||||
|
|
|
@ -4,14 +4,13 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { In } from 'typeorm';
|
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { MutingsRepository } from '@/models/_.js';
|
import type { MutingsRepository } from '@/models/_.js';
|
||||||
import type Logger from '@/logger.js';
|
import type Logger from '@/logger.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { UserMutingService } from '@/core/UserMutingService.js';
|
import { UserMutingService } from '@/core/UserMutingService.js';
|
||||||
|
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||||
import type * as Bull from 'bullmq';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CheckExpiredMutingsProcessorService {
|
export class CheckExpiredMutingsProcessorService {
|
||||||
|
@ -22,6 +21,7 @@ export class CheckExpiredMutingsProcessorService {
|
||||||
private mutingsRepository: MutingsRepository,
|
private mutingsRepository: MutingsRepository,
|
||||||
|
|
||||||
private userMutingService: UserMutingService,
|
private userMutingService: UserMutingService,
|
||||||
|
private channelMutingService: ChannelMutingService,
|
||||||
private queueLoggerService: QueueLoggerService,
|
private queueLoggerService: QueueLoggerService,
|
||||||
) {
|
) {
|
||||||
this.logger = this.queueLoggerService.logger.createSubLogger('check-expired-mutings');
|
this.logger = this.queueLoggerService.logger.createSubLogger('check-expired-mutings');
|
||||||
|
@ -41,6 +41,8 @@ export class CheckExpiredMutingsProcessorService {
|
||||||
await this.userMutingService.unmute(expired);
|
await this.userMutingService.unmute(expired);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.channelMutingService.eraseExpiredMutings();
|
||||||
|
|
||||||
this.logger.succ('All expired mutings checked.');
|
this.logger.succ('All expired mutings checked.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -190,6 +190,8 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
||||||
if (signerHost !== activityIdHost) {
|
if (signerHost !== activityIdHost) {
|
||||||
throw new Bull.UnrecoverableError(`skip: signerHost(${signerHost}) !== activity.id host(${activityIdHost}`);
|
throw new Bull.UnrecoverableError(`skip: signerHost(${signerHost}) !== activity.id host(${activityIdHost}`);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
throw new Bull.UnrecoverableError('skip: activity id is not a string');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.apRequestChart.inbox();
|
this.apRequestChart.inbox();
|
||||||
|
|
|
@ -105,7 +105,7 @@ export class ActivityPubServerService {
|
||||||
let signature;
|
let signature;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
signature = httpSignature.parseRequest(request.raw, { 'headers': [] });
|
signature = httpSignature.parseRequest(request.raw, { 'headers': ['(request-target)', 'host', 'date'], authorizationHeaderName: 'signature' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reply.code(401);
|
reply.code(401);
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -127,6 +127,9 @@ import * as ep___channels_favorite from './endpoints/channels/favorite.js';
|
||||||
import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js';
|
import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js';
|
||||||
import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js';
|
import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js';
|
||||||
import * as ep___channels_search from './endpoints/channels/search.js';
|
import * as ep___channels_search from './endpoints/channels/search.js';
|
||||||
|
import * as ep___channels_mute_create from './endpoints/channels/mute/create.js';
|
||||||
|
import * as ep___channels_mute_delete from './endpoints/channels/mute/delete.js';
|
||||||
|
import * as ep___channels_mute_list from './endpoints/channels/mute/list.js';
|
||||||
import * as ep___charts_activeUsers from './endpoints/charts/active-users.js';
|
import * as ep___charts_activeUsers from './endpoints/charts/active-users.js';
|
||||||
import * as ep___charts_apRequest from './endpoints/charts/ap-request.js';
|
import * as ep___charts_apRequest from './endpoints/charts/ap-request.js';
|
||||||
import * as ep___charts_drive from './endpoints/charts/drive.js';
|
import * as ep___charts_drive from './endpoints/charts/drive.js';
|
||||||
|
@ -515,6 +518,9 @@ const $channels_favorite: Provider = { provide: 'ep:channels/favorite', useClass
|
||||||
const $channels_unfavorite: Provider = { provide: 'ep:channels/unfavorite', useClass: ep___channels_unfavorite.default };
|
const $channels_unfavorite: Provider = { provide: 'ep:channels/unfavorite', useClass: ep___channels_unfavorite.default };
|
||||||
const $channels_myFavorites: Provider = { provide: 'ep:channels/my-favorites', useClass: ep___channels_myFavorites.default };
|
const $channels_myFavorites: Provider = { provide: 'ep:channels/my-favorites', useClass: ep___channels_myFavorites.default };
|
||||||
const $channels_search: Provider = { provide: 'ep:channels/search', useClass: ep___channels_search.default };
|
const $channels_search: Provider = { provide: 'ep:channels/search', useClass: ep___channels_search.default };
|
||||||
|
const $channels_mute_create: Provider = { provide: 'ep:channels/mute/create', useClass: ep___channels_mute_create.default };
|
||||||
|
const $channels_mute_delete: Provider = { provide: 'ep:channels/mute/delete', useClass: ep___channels_mute_delete.default };
|
||||||
|
const $channels_mute_list: Provider = { provide: 'ep:channels/mute/list', useClass: ep___channels_mute_list.default };
|
||||||
const $charts_activeUsers: Provider = { provide: 'ep:charts/active-users', useClass: ep___charts_activeUsers.default };
|
const $charts_activeUsers: Provider = { provide: 'ep:charts/active-users', useClass: ep___charts_activeUsers.default };
|
||||||
const $charts_apRequest: Provider = { provide: 'ep:charts/ap-request', useClass: ep___charts_apRequest.default };
|
const $charts_apRequest: Provider = { provide: 'ep:charts/ap-request', useClass: ep___charts_apRequest.default };
|
||||||
const $charts_drive: Provider = { provide: 'ep:charts/drive', useClass: ep___charts_drive.default };
|
const $charts_drive: Provider = { provide: 'ep:charts/drive', useClass: ep___charts_drive.default };
|
||||||
|
@ -907,6 +913,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
||||||
$channels_unfavorite,
|
$channels_unfavorite,
|
||||||
$channels_myFavorites,
|
$channels_myFavorites,
|
||||||
$channels_search,
|
$channels_search,
|
||||||
|
$channels_mute_create,
|
||||||
|
$channels_mute_delete,
|
||||||
|
$channels_mute_list,
|
||||||
$charts_activeUsers,
|
$charts_activeUsers,
|
||||||
$charts_apRequest,
|
$charts_apRequest,
|
||||||
$charts_drive,
|
$charts_drive,
|
||||||
|
@ -1293,6 +1302,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
||||||
$channels_unfavorite,
|
$channels_unfavorite,
|
||||||
$channels_myFavorites,
|
$channels_myFavorites,
|
||||||
$channels_search,
|
$channels_search,
|
||||||
|
$channels_mute_create,
|
||||||
|
$channels_mute_delete,
|
||||||
|
$channels_mute_list,
|
||||||
$charts_activeUsers,
|
$charts_activeUsers,
|
||||||
$charts_apRequest,
|
$charts_apRequest,
|
||||||
$charts_drive,
|
$charts_drive,
|
||||||
|
|
|
@ -16,6 +16,7 @@ import { CacheService } from '@/core/CacheService.js';
|
||||||
import { MiLocalUser } from '@/models/User.js';
|
import { MiLocalUser } from '@/models/User.js';
|
||||||
import { UserService } from '@/core/UserService.js';
|
import { UserService } from '@/core/UserService.js';
|
||||||
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
|
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
|
||||||
|
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||||
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
|
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
|
||||||
import MainStreamConnection from './stream/Connection.js';
|
import MainStreamConnection from './stream/Connection.js';
|
||||||
import { ChannelsService } from './stream/ChannelsService.js';
|
import { ChannelsService } from './stream/ChannelsService.js';
|
||||||
|
@ -41,6 +42,7 @@ export class StreamingApiServerService {
|
||||||
private notificationService: NotificationService,
|
private notificationService: NotificationService,
|
||||||
private usersService: UserService,
|
private usersService: UserService,
|
||||||
private channelFollowingService: ChannelFollowingService,
|
private channelFollowingService: ChannelFollowingService,
|
||||||
|
private channelMutingService: ChannelMutingService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -100,6 +102,7 @@ export class StreamingApiServerService {
|
||||||
this.notificationService,
|
this.notificationService,
|
||||||
this.cacheService,
|
this.cacheService,
|
||||||
this.channelFollowingService,
|
this.channelFollowingService,
|
||||||
|
this.channelMutingService,
|
||||||
user, app,
|
user, app,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -133,6 +133,9 @@ import * as ep___channels_favorite from './endpoints/channels/favorite.js';
|
||||||
import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js';
|
import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js';
|
||||||
import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js';
|
import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js';
|
||||||
import * as ep___channels_search from './endpoints/channels/search.js';
|
import * as ep___channels_search from './endpoints/channels/search.js';
|
||||||
|
import * as ep___channels_mute_create from './endpoints/channels/mute/create.js';
|
||||||
|
import * as ep___channels_mute_delete from './endpoints/channels/mute/delete.js';
|
||||||
|
import * as ep___channels_mute_list from './endpoints/channels/mute/list.js';
|
||||||
import * as ep___charts_activeUsers from './endpoints/charts/active-users.js';
|
import * as ep___charts_activeUsers from './endpoints/charts/active-users.js';
|
||||||
import * as ep___charts_apRequest from './endpoints/charts/ap-request.js';
|
import * as ep___charts_apRequest from './endpoints/charts/ap-request.js';
|
||||||
import * as ep___charts_drive from './endpoints/charts/drive.js';
|
import * as ep___charts_drive from './endpoints/charts/drive.js';
|
||||||
|
@ -519,6 +522,9 @@ const eps = [
|
||||||
['channels/unfavorite', ep___channels_unfavorite],
|
['channels/unfavorite', ep___channels_unfavorite],
|
||||||
['channels/my-favorites', ep___channels_myFavorites],
|
['channels/my-favorites', ep___channels_myFavorites],
|
||||||
['channels/search', ep___channels_search],
|
['channels/search', ep___channels_search],
|
||||||
|
['channels/mute/create', ep___channels_mute_create],
|
||||||
|
['channels/mute/delete', ep___channels_mute_delete],
|
||||||
|
['channels/mute/list', ep___channels_mute_list],
|
||||||
['charts/active-users', ep___charts_activeUsers],
|
['charts/active-users', ep___charts_activeUsers],
|
||||||
['charts/ap-request', ep___charts_apRequest],
|
['charts/ap-request', ep___charts_apRequest],
|
||||||
['charts/drive', ep___charts_drive],
|
['charts/drive', ep___charts_drive],
|
||||||
|
|
|
@ -46,7 +46,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
throw new Error('cannot delete a root account');
|
throw new Error('cannot delete a root account');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.deleteAccoountService.deleteAccount(user);
|
await this.deleteAccoountService.deleteAccount(user, me);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -55,7 +55,7 @@ export const paramDef = {
|
||||||
properties: {
|
properties: {
|
||||||
title: { type: 'string', minLength: 1 },
|
title: { type: 'string', minLength: 1 },
|
||||||
text: { type: 'string', minLength: 1 },
|
text: { type: 'string', minLength: 1 },
|
||||||
imageUrl: { type: 'string', nullable: true, minLength: 1 },
|
imageUrl: { type: 'string', nullable: true, minLength: 0 },
|
||||||
icon: { type: 'string', enum: ['info', 'warning', 'error', 'success'], default: 'info' },
|
icon: { type: 'string', enum: ['info', 'warning', 'error', 'success'], default: 'info' },
|
||||||
display: { type: 'string', enum: ['normal', 'banner', 'dialog'], default: 'normal' },
|
display: { type: 'string', enum: ['normal', 'banner', 'dialog'], default: 'normal' },
|
||||||
forExistingUsers: { type: 'boolean', default: false },
|
forExistingUsers: { type: 'boolean', default: false },
|
||||||
|
@ -76,7 +76,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
title: ps.title,
|
title: ps.title,
|
||||||
text: ps.text,
|
text: ps.text,
|
||||||
imageUrl: ps.imageUrl,
|
/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- 空の文字列の場合、nullを渡すようにするため */
|
||||||
|
imageUrl: ps.imageUrl || null,
|
||||||
icon: ps.icon,
|
icon: ps.icon,
|
||||||
display: ps.display,
|
display: ps.display,
|
||||||
forExistingUsers: ps.forExistingUsers,
|
forExistingUsers: ps.forExistingUsers,
|
||||||
|
|
|
@ -33,13 +33,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
|
|
||||||
private deleteAccountService: DeleteAccountService,
|
private deleteAccountService: DeleteAccountService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const user = await this.usersRepository.findOneByOrFail({ id: ps.userId });
|
const user = await this.usersRepository.findOneByOrFail({ id: ps.userId });
|
||||||
if (user.isDeleted) {
|
if (user.isDeleted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.deleteAccountService.deleteAccount(user);
|
await this.deleteAccountService.deleteAccount(user, me);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import * as Redis from 'ioredis';
|
import * as Redis from 'ioredis';
|
||||||
|
import { Brackets } from 'typeorm';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import type { NotesRepository, AntennasRepository } from '@/models/_.js';
|
import type { NotesRepository, AntennasRepository } from '@/models/_.js';
|
||||||
import { QueryService } from '@/core/QueryService.js';
|
import { QueryService } from '@/core/QueryService.js';
|
||||||
|
@ -15,6 +16,7 @@ import { IdService } from '@/core/IdService.js';
|
||||||
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||||
|
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||||
import { ApiError } from '../../error.js';
|
import { ApiError } from '../../error.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
|
@ -74,6 +76,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
private noteReadService: NoteReadService,
|
private noteReadService: NoteReadService,
|
||||||
private fanoutTimelineService: FanoutTimelineService,
|
private fanoutTimelineService: FanoutTimelineService,
|
||||||
private globalEventService: GlobalEventService,
|
private globalEventService: GlobalEventService,
|
||||||
|
private channelMutingService: ChannelMutingService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||||
|
@ -113,6 +116,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||||
|
|
||||||
|
// -- ミュートされたチャンネル対策
|
||||||
|
const mutingChannelIds = await this.channelMutingService
|
||||||
|
.list({ requestUserId: me.id }, { idOnly: true })
|
||||||
|
.then(x => x.map(x => x.id));
|
||||||
|
if (mutingChannelIds.length > 0) {
|
||||||
|
query.andWhere(new Brackets(qb => {
|
||||||
|
qb.orWhere('note.channelId IS NULL');
|
||||||
|
qb.orWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
|
||||||
|
}));
|
||||||
|
query.andWhere(new Brackets(qb => {
|
||||||
|
qb.orWhere('note.renoteChannelId IS NULL');
|
||||||
|
qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
this.queryService.generateVisibilityQuery(query, me);
|
this.queryService.generateVisibilityQuery(query, me);
|
||||||
this.queryService.generateMutedUserQuery(query, me);
|
this.queryService.generateMutedUserQuery(query, me);
|
||||||
this.queryService.generateBlockedUserQuery(query, me);
|
this.queryService.generateBlockedUserQuery(query, me);
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { ApResolverService } from '@/core/activitypub/ApResolverService.js';
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['federation'],
|
tags: ['federation'],
|
||||||
|
|
||||||
|
requireAdmin: true,
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
kind: 'read:federation',
|
kind: 'read:federation',
|
||||||
|
|
||||||
|
|
|
@ -118,6 +118,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
]));
|
]));
|
||||||
if (local != null) return local;
|
if (local != null) return local;
|
||||||
|
|
||||||
|
const host = this.utilityService.extractDbHost(uri);
|
||||||
|
|
||||||
|
// local object, not found in db? fail
|
||||||
|
if (this.utilityService.isSelfHost(host)) return null;
|
||||||
|
|
||||||
// リモートから一旦オブジェクトフェッチ
|
// リモートから一旦オブジェクトフェッチ
|
||||||
const resolver = this.apResolverService.createResolver();
|
const resolver = this.apResolverService.createResolver();
|
||||||
const object = await resolver.resolve(uri) as any;
|
const object = await resolver.resolve(uri) as any;
|
||||||
|
@ -132,10 +137,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
if (local != null) return local;
|
if (local != null) return local;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 同一ユーザーの情報を再度処理するので、使用済みのresolverを再利用してはいけない
|
||||||
return await this.mergePack(
|
return await this.mergePack(
|
||||||
me,
|
me,
|
||||||
isActor(object) ? await this.apPersonService.createPerson(getApId(object)) : null,
|
isActor(object) ? await this.apPersonService.createPerson(getApId(object)) : null,
|
||||||
isPost(object) ? await this.apNoteService.createNote(getApId(object), undefined, true) : null,
|
isPost(object) ? await this.apNoteService.createNote(getApId(object), undefined, undefined, true) : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,90 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
|
import type { ChannelsRepository } from '@/models/_.js';
|
||||||
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { ApiError } from '@/server/api/error.js';
|
||||||
|
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||||
|
|
||||||
|
export const meta = {
|
||||||
|
tags: ['channels', 'mute'],
|
||||||
|
|
||||||
|
requireCredential: true,
|
||||||
|
prohibitMoved: true,
|
||||||
|
|
||||||
|
kind: 'write:channels',
|
||||||
|
|
||||||
|
errors: {
|
||||||
|
noSuchChannel: {
|
||||||
|
message: 'No such Channel.',
|
||||||
|
code: 'NO_SUCH_CHANNEL',
|
||||||
|
id: '7174361e-d58f-31d6-2e7c-6fb830786a3f',
|
||||||
|
},
|
||||||
|
|
||||||
|
alreadyMuting: {
|
||||||
|
message: 'You are already muting that user.',
|
||||||
|
code: 'ALREADY_MUTING_CHANNEL',
|
||||||
|
id: '5a251978-769a-da44-3e89-3931e43bb592',
|
||||||
|
},
|
||||||
|
|
||||||
|
expiresAtIsPast: {
|
||||||
|
message: 'Cannot set past date to "expiresAt".',
|
||||||
|
code: 'EXPIRES_AT_IS_PAST',
|
||||||
|
id: '42b32236-df2c-a45f-fdbf-def67268f749',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const paramDef = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
channelId: { type: 'string', format: 'misskey:id' },
|
||||||
|
expiresAt: {
|
||||||
|
type: 'integer',
|
||||||
|
nullable: true,
|
||||||
|
description: 'A Unix Epoch timestamp that must lie in the future. `null` means an indefinite mute.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['channelId'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||||
|
constructor(
|
||||||
|
@Inject(DI.channelsRepository)
|
||||||
|
private channelsRepository: ChannelsRepository,
|
||||||
|
private channelMutingService: ChannelMutingService,
|
||||||
|
) {
|
||||||
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
// Check if exists the channel
|
||||||
|
const targetChannel = await this.channelsRepository.findOneBy({ id: ps.channelId });
|
||||||
|
if (!targetChannel) {
|
||||||
|
throw new ApiError(meta.errors.noSuchChannel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already muting
|
||||||
|
const exist = await this.channelMutingService.isMuted({
|
||||||
|
requestUserId: me.id,
|
||||||
|
targetChannelId: targetChannel.id,
|
||||||
|
});
|
||||||
|
if (exist) {
|
||||||
|
throw new ApiError(meta.errors.alreadyMuting);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if expiresAt is past
|
||||||
|
if (ps.expiresAt && ps.expiresAt <= Date.now()) {
|
||||||
|
throw new ApiError(meta.errors.expiresAtIsPast);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.channelMutingService.mute({
|
||||||
|
requestUserId: me.id,
|
||||||
|
targetChannelId: targetChannel.id,
|
||||||
|
expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,73 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
|
import type { ChannelsRepository } from '@/models/_.js';
|
||||||
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||||
|
import { ApiError } from '@/server/api/error.js';
|
||||||
|
|
||||||
|
export const meta = {
|
||||||
|
tags: ['channels', 'mute'],
|
||||||
|
|
||||||
|
requireCredential: true,
|
||||||
|
prohibitMoved: true,
|
||||||
|
|
||||||
|
kind: 'write:channels',
|
||||||
|
|
||||||
|
errors: {
|
||||||
|
noSuchChannel: {
|
||||||
|
message: 'No such Channel.',
|
||||||
|
code: 'NO_SUCH_CHANNEL',
|
||||||
|
id: 'e7998769-6e94-d9c2-6b8f-94a527314aba',
|
||||||
|
},
|
||||||
|
|
||||||
|
notMuting: {
|
||||||
|
message: 'You are not muting that channel.',
|
||||||
|
code: 'NOT_MUTING_CHANNEL',
|
||||||
|
id: '14d55962-6ea8-d990-1333-d6bef78dc2ab',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const paramDef = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
channelId: { type: 'string', format: 'misskey:id' },
|
||||||
|
},
|
||||||
|
required: ['channelId'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||||
|
constructor(
|
||||||
|
@Inject(DI.channelsRepository)
|
||||||
|
private channelsRepository: ChannelsRepository,
|
||||||
|
private channelMutingService: ChannelMutingService,
|
||||||
|
) {
|
||||||
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
// Check if exists the channel
|
||||||
|
const targetChannel = await this.channelsRepository.findOneBy({ id: ps.channelId });
|
||||||
|
if (!targetChannel) {
|
||||||
|
throw new ApiError(meta.errors.noSuchChannel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check muting
|
||||||
|
const exist = await this.channelMutingService.isMuted({
|
||||||
|
requestUserId: me.id,
|
||||||
|
targetChannelId: targetChannel.id,
|
||||||
|
});
|
||||||
|
if (!exist) {
|
||||||
|
throw new ApiError(meta.errors.notMuting);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.channelMutingService.unmute({
|
||||||
|
requestUserId: me.id,
|
||||||
|
targetChannelId: targetChannel.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
|
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||||
|
import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js';
|
||||||
|
|
||||||
|
export const meta = {
|
||||||
|
tags: ['channels', 'mute'],
|
||||||
|
|
||||||
|
requireCredential: true,
|
||||||
|
prohibitMoved: true,
|
||||||
|
|
||||||
|
kind: 'read:channels',
|
||||||
|
|
||||||
|
res: {
|
||||||
|
type: 'array',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
ref: 'Channel',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const paramDef = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
required: [],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||||
|
constructor(
|
||||||
|
private channelMutingService: ChannelMutingService,
|
||||||
|
private channelEntityService: ChannelEntityService,
|
||||||
|
) {
|
||||||
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
const mutings = await this.channelMutingService.list({
|
||||||
|
requestUserId: me.id,
|
||||||
|
});
|
||||||
|
return await this.channelEntityService.packMany(mutings, me);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,6 +13,8 @@ import { DI } from '@/di-symbols.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||||
import { MiLocalUser } from '@/models/User.js';
|
import { MiLocalUser } from '@/models/User.js';
|
||||||
|
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||||
|
import { isChannelRelated } from '@/misc/is-channel-related.js';
|
||||||
import { ApiError } from '../../error.js';
|
import { ApiError } from '../../error.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
|
@ -70,6 +72,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
private queryService: QueryService,
|
private queryService: QueryService,
|
||||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||||
private activeUsersChart: ActiveUsersChart,
|
private activeUsersChart: ActiveUsersChart,
|
||||||
|
private channelMutingService: ChannelMutingService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||||
|
@ -89,6 +92,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id }, me), me);
|
return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id }, me), me);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mutingChannelIds = me
|
||||||
|
? await this.channelMutingService.mutingChannelsCache.get(me.id) ?? new Set<string>()
|
||||||
|
: new Set<string>();
|
||||||
return await this.fanoutTimelineEndpointService.timeline({
|
return await this.fanoutTimelineEndpointService.timeline({
|
||||||
untilId,
|
untilId,
|
||||||
sinceId,
|
sinceId,
|
||||||
|
@ -98,6 +104,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
useDbFallback: true,
|
useDbFallback: true,
|
||||||
redisTimelines: [`channelTimeline:${channel.id}`],
|
redisTimelines: [`channelTimeline:${channel.id}`],
|
||||||
excludePureRenotes: false,
|
excludePureRenotes: false,
|
||||||
|
includeMutedChannels: true,
|
||||||
|
noteFilter: note => {
|
||||||
|
// 共通機能を使うと見ているチャンネルそのものもミュートしてしまうので閲覧中のチャンネル以外を除く形にする
|
||||||
|
if (note.channelId === channel.id && (note.renoteChannelId === null || note.renoteChannelId === channel.id)) return true;
|
||||||
|
return !isChannelRelated(note, mutingChannelIds);
|
||||||
|
},
|
||||||
dbFallback: async (untilId, sinceId, limit) => {
|
dbFallback: async (untilId, sinceId, limit) => {
|
||||||
return await this.getFromDb({ untilId, sinceId, limit, channelId: channel.id }, me);
|
return await this.getFromDb({ untilId, sinceId, limit, channelId: channel.id }, me);
|
||||||
},
|
},
|
||||||
|
@ -122,6 +134,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
.leftJoinAndSelect('note.channel', 'channel');
|
.leftJoinAndSelect('note.channel', 'channel');
|
||||||
|
|
||||||
if (me) {
|
if (me) {
|
||||||
|
const mutingChannelIds = await this.channelMutingService
|
||||||
|
.list({ requestUserId: me.id }, { idOnly: true })
|
||||||
|
.then(x => x.map(x => x.id).filter(x => x !== ps.channelId));
|
||||||
|
if (mutingChannelIds.length > 0) {
|
||||||
|
query.andWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
|
||||||
|
query.andWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
|
||||||
|
}
|
||||||
|
|
||||||
this.queryService.generateMutedUserQuery(query, me);
|
this.queryService.generateMutedUserQuery(query, me);
|
||||||
this.queryService.generateBlockedUserQuery(query, me);
|
this.queryService.generateBlockedUserQuery(query, me);
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,8 @@ import { QueryService } from '@/core/QueryService.js';
|
||||||
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||||
import { MiLocalUser } from '@/models/User.js';
|
import { MiLocalUser } from '@/models/User.js';
|
||||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||||
|
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||||
|
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
|
||||||
import { ApiError } from '../../error.js';
|
import { ApiError } from '../../error.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
|
@ -46,7 +48,7 @@ export const meta = {
|
||||||
bothWithRepliesAndWithFiles: {
|
bothWithRepliesAndWithFiles: {
|
||||||
message: 'Specifying both withReplies and withFiles is not supported',
|
message: 'Specifying both withReplies and withFiles is not supported',
|
||||||
code: 'BOTH_WITH_REPLIES_AND_WITH_FILES',
|
code: 'BOTH_WITH_REPLIES_AND_WITH_FILES',
|
||||||
id: 'dfaa3eb7-8002-4cb7-bcc4-1095df46656f'
|
id: 'dfaa3eb7-8002-4cb7-bcc4-1095df46656f',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
@ -79,9 +81,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
@Inject(DI.notesRepository)
|
@Inject(DI.notesRepository)
|
||||||
private notesRepository: NotesRepository,
|
private notesRepository: NotesRepository,
|
||||||
|
|
||||||
@Inject(DI.channelFollowingsRepository)
|
|
||||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
|
||||||
|
|
||||||
private noteEntityService: NoteEntityService,
|
private noteEntityService: NoteEntityService,
|
||||||
private roleService: RoleService,
|
private roleService: RoleService,
|
||||||
private activeUsersChart: ActiveUsersChart,
|
private activeUsersChart: ActiveUsersChart,
|
||||||
|
@ -89,6 +88,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
private cacheService: CacheService,
|
private cacheService: CacheService,
|
||||||
private queryService: QueryService,
|
private queryService: QueryService,
|
||||||
private userFollowingService: UserFollowingService,
|
private userFollowingService: UserFollowingService,
|
||||||
|
private channelMutingService: ChannelMutingService,
|
||||||
|
private channelFollowingService: ChannelFollowingService,
|
||||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
@ -196,11 +197,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
withReplies: boolean,
|
withReplies: boolean,
|
||||||
}, me: MiLocalUser) {
|
}, me: MiLocalUser) {
|
||||||
const followees = await this.userFollowingService.getFollowees(me.id);
|
const followees = await this.userFollowingService.getFollowees(me.id);
|
||||||
const followingChannels = await this.channelFollowingsRepository.find({
|
|
||||||
where: {
|
const mutingChannelIds = await this.channelMutingService
|
||||||
followerId: me.id,
|
.list({ requestUserId: me.id }, { idOnly: true })
|
||||||
},
|
.then(x => x.map(x => x.id));
|
||||||
});
|
const followingChannelIds = await this.channelFollowingService
|
||||||
|
.list({ requestUserId: me.id }, { idOnly: true })
|
||||||
|
.then(x => x.map(x => x.id).filter(x => !mutingChannelIds.includes(x)));
|
||||||
|
|
||||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||||
.andWhere(new Brackets(qb => {
|
.andWhere(new Brackets(qb => {
|
||||||
|
@ -219,9 +222,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||||
|
|
||||||
if (followingChannels.length > 0) {
|
if (followingChannelIds.length > 0) {
|
||||||
const followingChannelIds = followingChannels.map(x => x.followeeId);
|
|
||||||
|
|
||||||
query.andWhere(new Brackets(qb => {
|
query.andWhere(new Brackets(qb => {
|
||||||
qb.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds });
|
qb.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds });
|
||||||
qb.orWhere('note.channelId IS NULL');
|
qb.orWhere('note.channelId IS NULL');
|
||||||
|
@ -230,6 +231,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
query.andWhere('note.channelId IS NULL');
|
query.andWhere('note.channelId IS NULL');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mutingChannelIds.length > 0) {
|
||||||
|
query.andWhere(new Brackets(qb => {
|
||||||
|
qb.orWhere('note.renoteChannelId IS NULL');
|
||||||
|
qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
if (!ps.withReplies) {
|
if (!ps.withReplies) {
|
||||||
query.andWhere(new Brackets(qb => {
|
query.andWhere(new Brackets(qb => {
|
||||||
qb
|
qb
|
||||||
|
|
|
@ -15,6 +15,7 @@ import { IdService } from '@/core/IdService.js';
|
||||||
import { QueryService } from '@/core/QueryService.js';
|
import { QueryService } from '@/core/QueryService.js';
|
||||||
import { MiLocalUser } from '@/models/User.js';
|
import { MiLocalUser } from '@/models/User.js';
|
||||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||||
|
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||||
import { ApiError } from '../../error.js';
|
import { ApiError } from '../../error.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
|
@ -76,6 +77,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||||
private queryService: QueryService,
|
private queryService: QueryService,
|
||||||
|
private channelMutingService: ChannelMutingService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||||
|
@ -156,9 +158,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||||
|
|
||||||
this.queryService.generateVisibilityQuery(query, me);
|
this.queryService.generateVisibilityQuery(query, me);
|
||||||
if (me) this.queryService.generateMutedUserQuery(query, me);
|
if (me) {
|
||||||
if (me) this.queryService.generateBlockedUserQuery(query, me);
|
this.queryService.generateMutedUserQuery(query, me);
|
||||||
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
this.queryService.generateBlockedUserQuery(query, me);
|
||||||
|
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||||
|
|
||||||
|
const mutedChannelIds = await this.channelMutingService
|
||||||
|
.list({ requestUserId: me.id }, { idOnly: true })
|
||||||
|
.then(x => x.map(x => x.id));
|
||||||
|
if (mutedChannelIds.length > 0) {
|
||||||
|
query.andWhere(new Brackets(qb => {
|
||||||
|
qb.orWhere('note.renoteChannelId IS NULL')
|
||||||
|
.orWhere('note.renoteChannelId NOT IN (:...mutedChannelIds)', { mutedChannelIds });
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (ps.withFiles) {
|
if (ps.withFiles) {
|
||||||
query.andWhere('note.fileIds != \'{}\'');
|
query.andWhere('note.fileIds != \'{}\'');
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
import { Brackets } from 'typeorm';
|
import { Brackets } from 'typeorm';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import type { NotesRepository, ChannelFollowingsRepository, MiMeta } from '@/models/_.js';
|
import type { NotesRepository, MiMeta } from '@/models/_.js';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import { QueryService } from '@/core/QueryService.js';
|
import { QueryService } from '@/core/QueryService.js';
|
||||||
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
||||||
|
@ -16,6 +16,8 @@ import { CacheService } from '@/core/CacheService.js';
|
||||||
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||||
import { MiLocalUser } from '@/models/User.js';
|
import { MiLocalUser } from '@/models/User.js';
|
||||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||||
|
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||||
|
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['notes'],
|
tags: ['notes'],
|
||||||
|
@ -61,15 +63,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
@Inject(DI.notesRepository)
|
@Inject(DI.notesRepository)
|
||||||
private notesRepository: NotesRepository,
|
private notesRepository: NotesRepository,
|
||||||
|
|
||||||
@Inject(DI.channelFollowingsRepository)
|
|
||||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
|
||||||
|
|
||||||
private noteEntityService: NoteEntityService,
|
private noteEntityService: NoteEntityService,
|
||||||
private activeUsersChart: ActiveUsersChart,
|
private activeUsersChart: ActiveUsersChart,
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
private cacheService: CacheService,
|
private cacheService: CacheService,
|
||||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||||
private userFollowingService: UserFollowingService,
|
private userFollowingService: UserFollowingService,
|
||||||
|
private channelMutingService: ChannelMutingService,
|
||||||
|
private channelFollowingService: ChannelFollowingService,
|
||||||
private queryService: QueryService,
|
private queryService: QueryService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
@ -140,11 +141,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
|
|
||||||
private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; includeMyRenotes: boolean; includeRenotedMyNotes: boolean; includeLocalRenotes: boolean; withFiles: boolean; withRenotes: boolean; }, me: MiLocalUser) {
|
private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; includeMyRenotes: boolean; includeRenotedMyNotes: boolean; includeLocalRenotes: boolean; withFiles: boolean; withRenotes: boolean; }, me: MiLocalUser) {
|
||||||
const followees = await this.userFollowingService.getFollowees(me.id);
|
const followees = await this.userFollowingService.getFollowees(me.id);
|
||||||
const followingChannels = await this.channelFollowingsRepository.find({
|
|
||||||
where: {
|
const mutingChannelIds = await this.channelMutingService
|
||||||
followerId: me.id,
|
.list({ requestUserId: me.id }, { idOnly: true })
|
||||||
},
|
.then(x => x.map(x => x.id));
|
||||||
});
|
const followingChannelIds = await this.channelFollowingService
|
||||||
|
.list({ requestUserId: me.id }, { idOnly: true })
|
||||||
|
.then(x => x.map(x => x.id).filter(x => !mutingChannelIds.includes(x)));
|
||||||
|
|
||||||
//#region Construct query
|
//#region Construct query
|
||||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||||
|
@ -154,15 +157,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||||
|
|
||||||
if (followees.length > 0 && followingChannels.length > 0) {
|
if (followees.length > 0 && followingChannelIds.length > 0) {
|
||||||
// ユーザー・チャンネルともにフォローあり
|
// ユーザー・チャンネルともにフォローあり
|
||||||
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
|
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
|
||||||
const followingChannelIds = followingChannels.map(x => x.followeeId);
|
|
||||||
query.andWhere(new Brackets(qb => {
|
query.andWhere(new Brackets(qb => {
|
||||||
qb
|
qb
|
||||||
.where(new Brackets(qb2 => {
|
.where(new Brackets(qb2 => {
|
||||||
qb2
|
qb2
|
||||||
.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds })
|
.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds })
|
||||||
.andWhere('note.channelId IS NULL');
|
.andWhere('note.channelId IS NULL');
|
||||||
}))
|
}))
|
||||||
.orWhere('note.channelId IN (:...followingChannelIds)', { followingChannelIds });
|
.orWhere('note.channelId IN (:...followingChannelIds)', { followingChannelIds });
|
||||||
|
@ -170,22 +172,32 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
} else if (followees.length > 0) {
|
} else if (followees.length > 0) {
|
||||||
// ユーザーフォローのみ(チャンネルフォローなし)
|
// ユーザーフォローのみ(チャンネルフォローなし)
|
||||||
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
|
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
|
||||||
query
|
|
||||||
.andWhere('note.channelId IS NULL')
|
|
||||||
.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
|
|
||||||
} else if (followingChannels.length > 0) {
|
|
||||||
// チャンネルフォローのみ(ユーザーフォローなし)
|
|
||||||
const followingChannelIds = followingChannels.map(x => x.followeeId);
|
|
||||||
query.andWhere(new Brackets(qb => {
|
query.andWhere(new Brackets(qb => {
|
||||||
qb
|
qb
|
||||||
|
.andWhere('note.channelId IS NULL')
|
||||||
|
.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
|
||||||
|
if (mutingChannelIds.length > 0) {
|
||||||
|
qb.andWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
} else if (followingChannelIds.length > 0) {
|
||||||
|
// チャンネルフォローのみ(ユーザーフォローなし)
|
||||||
|
query.andWhere(new Brackets(qb => {
|
||||||
|
qb
|
||||||
|
// renoteChannelIdは見る必要が無い
|
||||||
|
// ・HTLに流れてくるチャンネル=フォローしているチャンネル
|
||||||
|
// ・HTLにフォロー外のチャンネルが流れるのは、フォローしているユーザがそのチャンネル投稿をリノートした場合のみ
|
||||||
|
// つまり、ユーザフォローしてない前提のこのブロックでは見る必要が無い
|
||||||
.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds })
|
.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds })
|
||||||
.orWhere('note.userId = :meId', { meId: me.id });
|
.orWhere('note.userId = :meId', { meId: me.id });
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
// フォローなし
|
// フォローなし
|
||||||
query
|
query.andWhere(new Brackets(qb => {
|
||||||
.andWhere('note.channelId IS NULL')
|
qb
|
||||||
.andWhere('note.userId = :meId', { meId: me.id });
|
.andWhere('note.channelId IS NULL')
|
||||||
|
.andWhere('note.userId = :meId', { meId: me.id });
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
query.andWhere(new Brackets(qb => {
|
query.andWhere(new Brackets(qb => {
|
||||||
|
|
|
@ -14,6 +14,7 @@ import { IdService } from '@/core/IdService.js';
|
||||||
import { QueryService } from '@/core/QueryService.js';
|
import { QueryService } from '@/core/QueryService.js';
|
||||||
import { MiLocalUser } from '@/models/User.js';
|
import { MiLocalUser } from '@/models/User.js';
|
||||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||||
|
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||||
import { ApiError } from '../../error.js';
|
import { ApiError } from '../../error.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
|
@ -84,6 +85,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||||
private queryService: QueryService,
|
private queryService: QueryService,
|
||||||
|
private channelMutingService: ChannelMutingService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||||
|
@ -188,6 +190,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
this.queryService.generateBlockedUserQuery(query, me);
|
this.queryService.generateBlockedUserQuery(query, me);
|
||||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||||
|
|
||||||
|
// -- ミュートされたチャンネルのリノート対策
|
||||||
|
const mutedChannelIds = await this.channelMutingService
|
||||||
|
.list({ requestUserId: me.id }, { idOnly: true })
|
||||||
|
.then(x => x.map(x => x.id));
|
||||||
|
if (mutedChannelIds.length > 0) {
|
||||||
|
query.andWhere(new Brackets(qb => {
|
||||||
|
qb.orWhere('note.renoteChannelId IS NULL')
|
||||||
|
.orWhere('note.renoteChannelId NOT IN (:...mutedChannelIds)', { mutedChannelIds });
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
if (ps.includeMyRenotes === false) {
|
if (ps.includeMyRenotes === false) {
|
||||||
query.andWhere(new Brackets(qb => {
|
query.andWhere(new Brackets(qb => {
|
||||||
qb.orWhere('note.userId != :meId', { meId: me.id });
|
qb.orWhere('note.userId != :meId', { meId: me.id });
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import * as Redis from 'ioredis';
|
import * as Redis from 'ioredis';
|
||||||
|
import { Brackets } from 'typeorm';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import type { NotesRepository, RolesRepository } from '@/models/_.js';
|
import type { NotesRepository, RolesRepository } from '@/models/_.js';
|
||||||
import { QueryService } from '@/core/QueryService.js';
|
import { QueryService } from '@/core/QueryService.js';
|
||||||
|
@ -12,6 +13,7 @@ import { DI } from '@/di-symbols.js';
|
||||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
||||||
|
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||||
import { ApiError } from '../../error.js';
|
import { ApiError } from '../../error.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
|
@ -68,6 +70,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
private noteEntityService: NoteEntityService,
|
private noteEntityService: NoteEntityService,
|
||||||
private queryService: QueryService,
|
private queryService: QueryService,
|
||||||
private fanoutTimelineService: FanoutTimelineService,
|
private fanoutTimelineService: FanoutTimelineService,
|
||||||
|
private channelMutingService: ChannelMutingService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||||
|
@ -101,6 +104,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||||
|
|
||||||
|
// -- ミュートされたチャンネル対策
|
||||||
|
const mutingChannelIds = await this.channelMutingService
|
||||||
|
.list({ requestUserId: me.id }, { idOnly: true })
|
||||||
|
.then(x => x.map(x => x.id));
|
||||||
|
if (mutingChannelIds.length > 0) {
|
||||||
|
query.andWhere(new Brackets(qb => {
|
||||||
|
qb.orWhere('note.channelId IS NULL');
|
||||||
|
qb.orWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
|
||||||
|
}));
|
||||||
|
query.andWhere(new Brackets(qb => {
|
||||||
|
qb.orWhere('note.renoteChannelId IS NULL');
|
||||||
|
qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
this.queryService.generateVisibilityQuery(query, me);
|
this.queryService.generateVisibilityQuery(query, me);
|
||||||
this.queryService.generateMutedUserQuery(query, me);
|
this.queryService.generateMutedUserQuery(query, me);
|
||||||
this.queryService.generateBlockedUserQuery(query, me);
|
this.queryService.generateBlockedUserQuery(query, me);
|
||||||
|
|
|
@ -16,6 +16,7 @@ import { MiLocalUser } from '@/models/User.js';
|
||||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||||
import { FanoutTimelineName } from '@/core/FanoutTimelineService.js';
|
import { FanoutTimelineName } from '@/core/FanoutTimelineService.js';
|
||||||
import { ApiError } from '@/server/api/error.js';
|
import { ApiError } from '@/server/api/error.js';
|
||||||
|
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['users', 'notes'],
|
tags: ['users', 'notes'],
|
||||||
|
@ -77,12 +78,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
|
|
||||||
@Inject(DI.notesRepository)
|
@Inject(DI.notesRepository)
|
||||||
private notesRepository: NotesRepository,
|
private notesRepository: NotesRepository,
|
||||||
|
|
||||||
private noteEntityService: NoteEntityService,
|
private noteEntityService: NoteEntityService,
|
||||||
private queryService: QueryService,
|
private queryService: QueryService,
|
||||||
private cacheService: CacheService,
|
private cacheService: CacheService,
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||||
|
private channelMutingService: ChannelMutingService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||||
|
@ -163,6 +164,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
withFiles: boolean,
|
withFiles: boolean,
|
||||||
withRenotes: boolean,
|
withRenotes: boolean,
|
||||||
}, me: MiLocalUser | null) {
|
}, me: MiLocalUser | null) {
|
||||||
|
const mutingChannelIds = me
|
||||||
|
? await this.channelMutingService
|
||||||
|
.list({ requestUserId: me.id }, { idOnly: true })
|
||||||
|
.then(x => x.map(x => x.id))
|
||||||
|
: [];
|
||||||
const isSelf = me && (me.id === ps.userId);
|
const isSelf = me && (me.id === ps.userId);
|
||||||
|
|
||||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||||
|
@ -175,14 +181,30 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||||
|
|
||||||
if (ps.withChannelNotes) {
|
if (ps.withChannelNotes) {
|
||||||
if (!isSelf) query.andWhere(new Brackets(qb => {
|
query.andWhere(new Brackets(qb => {
|
||||||
qb.orWhere('note.channelId IS NULL');
|
if (mutingChannelIds.length > 0) {
|
||||||
qb.orWhere('channel.isSensitive = false');
|
qb.andWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds: mutingChannelIds });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSelf) {
|
||||||
|
qb.andWhere(new Brackets(qb2 => {
|
||||||
|
qb2.orWhere('note.channelId IS NULL');
|
||||||
|
qb2.orWhere('channel.isSensitive = false');
|
||||||
|
}));
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
query.andWhere('note.channelId IS NULL');
|
query.andWhere('note.channelId IS NULL');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- ミュートされたチャンネルのリノート対策
|
||||||
|
if (mutingChannelIds.length > 0) {
|
||||||
|
query.andWhere(new Brackets(qb => {
|
||||||
|
qb.orWhere('note.renoteChannelId IS NULL');
|
||||||
|
qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
this.queryService.generateVisibilityQuery(query, me);
|
this.queryService.generateVisibilityQuery(query, me);
|
||||||
if (me) {
|
if (me) {
|
||||||
this.queryService.generateMutedUserQuery(query, me, { id: ps.userId });
|
this.queryService.generateMutedUserQuery(query, me, { id: ps.userId });
|
||||||
|
|
|
@ -12,8 +12,9 @@ import type { NotificationService } from '@/core/NotificationService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { CacheService } from '@/core/CacheService.js';
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
import { MiFollowing, MiUserProfile } from '@/models/_.js';
|
import { MiFollowing, MiUserProfile } from '@/models/_.js';
|
||||||
import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService.js';
|
import type { GlobalEvents, StreamEventEmitter } from '@/core/GlobalEventService.js';
|
||||||
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
|
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
|
||||||
|
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||||
import { isJsonObject } from '@/misc/json-value.js';
|
import { isJsonObject } from '@/misc/json-value.js';
|
||||||
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
|
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
|
||||||
import type { ChannelsService } from './ChannelsService.js';
|
import type { ChannelsService } from './ChannelsService.js';
|
||||||
|
@ -37,6 +38,7 @@ export default class Connection {
|
||||||
public userProfile: MiUserProfile | null = null;
|
public userProfile: MiUserProfile | null = null;
|
||||||
public following: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {};
|
public following: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {};
|
||||||
public followingChannels: Set<string> = new Set();
|
public followingChannels: Set<string> = new Set();
|
||||||
|
public mutingChannels: Set<string> = new Set();
|
||||||
public userIdsWhoMeMuting: Set<string> = new Set();
|
public userIdsWhoMeMuting: Set<string> = new Set();
|
||||||
public userIdsWhoBlockingMe: Set<string> = new Set();
|
public userIdsWhoBlockingMe: Set<string> = new Set();
|
||||||
public userIdsWhoMeMutingRenotes: Set<string> = new Set();
|
public userIdsWhoMeMutingRenotes: Set<string> = new Set();
|
||||||
|
@ -49,7 +51,7 @@ export default class Connection {
|
||||||
private notificationService: NotificationService,
|
private notificationService: NotificationService,
|
||||||
private cacheService: CacheService,
|
private cacheService: CacheService,
|
||||||
private channelFollowingService: ChannelFollowingService,
|
private channelFollowingService: ChannelFollowingService,
|
||||||
|
private channelMutingService: ChannelMutingService,
|
||||||
user: MiUser | null | undefined,
|
user: MiUser | null | undefined,
|
||||||
token: MiAccessToken | null | undefined,
|
token: MiAccessToken | null | undefined,
|
||||||
) {
|
) {
|
||||||
|
@ -60,10 +62,19 @@ export default class Connection {
|
||||||
@bindThis
|
@bindThis
|
||||||
public async fetch() {
|
public async fetch() {
|
||||||
if (this.user == null) return;
|
if (this.user == null) return;
|
||||||
const [userProfile, following, followingChannels, userIdsWhoMeMuting, userIdsWhoBlockingMe, userIdsWhoMeMutingRenotes] = await Promise.all([
|
const [
|
||||||
|
userProfile,
|
||||||
|
following,
|
||||||
|
followingChannels,
|
||||||
|
mutingChannels,
|
||||||
|
userIdsWhoMeMuting,
|
||||||
|
userIdsWhoBlockingMe,
|
||||||
|
userIdsWhoMeMutingRenotes,
|
||||||
|
] = await Promise.all([
|
||||||
this.cacheService.userProfileCache.fetch(this.user.id),
|
this.cacheService.userProfileCache.fetch(this.user.id),
|
||||||
this.cacheService.userFollowingsCache.fetch(this.user.id),
|
this.cacheService.userFollowingsCache.fetch(this.user.id),
|
||||||
this.channelFollowingService.userFollowingChannelsCache.fetch(this.user.id),
|
this.channelFollowingService.userFollowingChannelsCache.fetch(this.user.id),
|
||||||
|
this.channelMutingService.mutingChannelsCache.fetch(this.user.id),
|
||||||
this.cacheService.userMutingsCache.fetch(this.user.id),
|
this.cacheService.userMutingsCache.fetch(this.user.id),
|
||||||
this.cacheService.userBlockedCache.fetch(this.user.id),
|
this.cacheService.userBlockedCache.fetch(this.user.id),
|
||||||
this.cacheService.renoteMutingsCache.fetch(this.user.id),
|
this.cacheService.renoteMutingsCache.fetch(this.user.id),
|
||||||
|
@ -71,6 +82,7 @@ export default class Connection {
|
||||||
this.userProfile = userProfile;
|
this.userProfile = userProfile;
|
||||||
this.following = following;
|
this.following = following;
|
||||||
this.followingChannels = followingChannels;
|
this.followingChannels = followingChannels;
|
||||||
|
this.mutingChannels = mutingChannels;
|
||||||
this.userIdsWhoMeMuting = userIdsWhoMeMuting;
|
this.userIdsWhoMeMuting = userIdsWhoMeMuting;
|
||||||
this.userIdsWhoBlockingMe = userIdsWhoBlockingMe;
|
this.userIdsWhoBlockingMe = userIdsWhoBlockingMe;
|
||||||
this.userIdsWhoMeMutingRenotes = userIdsWhoMeMutingRenotes;
|
this.userIdsWhoMeMutingRenotes = userIdsWhoMeMutingRenotes;
|
||||||
|
|
|
@ -6,7 +6,8 @@
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
|
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
|
||||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||||
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
|
import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js';
|
||||||
|
import { isChannelRelated } from '@/misc/is-channel-related.js';
|
||||||
import type { Packed } from '@/misc/json-schema.js';
|
import type { Packed } from '@/misc/json-schema.js';
|
||||||
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
|
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
|
||||||
import type Connection from './Connection.js';
|
import type Connection from './Connection.js';
|
||||||
|
@ -55,6 +56,10 @@ export default abstract class Channel {
|
||||||
return this.connection.followingChannels;
|
return this.connection.followingChannels;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected get mutingChannels() {
|
||||||
|
return this.connection.mutingChannels;
|
||||||
|
}
|
||||||
|
|
||||||
protected get subscriber() {
|
protected get subscriber() {
|
||||||
return this.connection.subscriber;
|
return this.connection.subscriber;
|
||||||
}
|
}
|
||||||
|
@ -74,6 +79,9 @@ export default abstract class Channel {
|
||||||
// 流れてきたNoteがリノートをミュートしてるユーザが行ったもの
|
// 流れてきたNoteがリノートをミュートしてるユーザが行ったもの
|
||||||
if (isRenotePacked(note) && !isQuotePacked(note) && this.userIdsWhoMeMutingRenotes.has(note.user.id)) return true;
|
if (isRenotePacked(note) && !isQuotePacked(note) && this.userIdsWhoMeMutingRenotes.has(note.user.id)) return true;
|
||||||
|
|
||||||
|
// 流れてきたNoteがミュートしているチャンネルと関わる
|
||||||
|
if (isChannelRelated(note, this.mutingChannels)) return true;
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,8 @@ import type { Packed } from '@/misc/json-schema.js';
|
||||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
|
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
|
||||||
|
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
|
||||||
|
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||||
import type { JsonObject } from '@/misc/json-value.js';
|
import type { JsonObject } from '@/misc/json-value.js';
|
||||||
import Channel, { type MiChannelService } from '../channel.js';
|
import Channel, { type MiChannelService } from '../channel.js';
|
||||||
|
|
||||||
|
@ -19,7 +21,6 @@ class ChannelChannel extends Channel {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private noteEntityService: NoteEntityService,
|
private noteEntityService: NoteEntityService,
|
||||||
|
|
||||||
id: string,
|
id: string,
|
||||||
connection: Channel['connection'],
|
connection: Channel['connection'],
|
||||||
) {
|
) {
|
||||||
|
@ -54,6 +55,35 @@ class ChannelChannel extends Channel {
|
||||||
this.send('note', note);
|
this.send('note', note);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ミュートとブロックされてるを処理する
|
||||||
|
*/
|
||||||
|
protected override isNoteMutedOrBlocked(note: Packed<'Note'>): boolean {
|
||||||
|
// 流れてきたNoteがインスタンスミュートしたインスタンスが関わる
|
||||||
|
if (isInstanceMuted(note, new Set<string>(this.userProfile?.mutedInstances ?? []))) return true;
|
||||||
|
|
||||||
|
// 流れてきたNoteがミュートしているユーザーが関わる
|
||||||
|
if (isUserRelated(note, this.userIdsWhoMeMuting)) return true;
|
||||||
|
// 流れてきたNoteがブロックされているユーザーが関わる
|
||||||
|
if (isUserRelated(note, this.userIdsWhoBlockingMe)) return true;
|
||||||
|
|
||||||
|
// 流れてきたNoteがリノートをミュートしてるユーザが行ったもの
|
||||||
|
if (isRenotePacked(note) && !isQuotePacked(note) && this.userIdsWhoMeMutingRenotes.has(note.user.id)) return true;
|
||||||
|
|
||||||
|
// このソケットで見ているチャンネルがミュートされていたとしても、チャンネルを直接見ている以上は流すようにしたい
|
||||||
|
// ただし、他のミュートしているチャンネルは流さないようにもしたい
|
||||||
|
// ノート自体のチャンネルIDはonNoteでチェックしているので、ここではリノートのチャンネルIDをチェックする
|
||||||
|
if (
|
||||||
|
(note.renote) &&
|
||||||
|
(note.renote.channelId !== this.channelId) &&
|
||||||
|
(note.renote.channelId && this.mutingChannels.has(note.renote.channelId))
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public dispose() {
|
public dispose() {
|
||||||
// Unsubscribe events
|
// Unsubscribe events
|
||||||
|
|
|
@ -44,7 +44,10 @@ class HomeTimelineChannel extends Channel {
|
||||||
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
|
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
|
||||||
|
|
||||||
if (note.channelId) {
|
if (note.channelId) {
|
||||||
if (!this.followingChannels.has(note.channelId)) return;
|
// そのチャンネルをフォローしていない
|
||||||
|
if (!this.followingChannels.has(note.channelId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// その投稿のユーザーをフォローしていなかったら弾く
|
// その投稿のユーザーをフォローしていなかったら弾く
|
||||||
if (!isMe && !Object.hasOwn(this.following, note.userId)) return;
|
if (!isMe && !Object.hasOwn(this.following, note.userId)) return;
|
||||||
|
|
|
@ -53,16 +53,25 @@ class HybridTimelineChannel extends Channel {
|
||||||
|
|
||||||
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
|
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
|
||||||
|
|
||||||
// チャンネルの投稿ではなく、自分自身の投稿 または
|
if (!note.channelId) {
|
||||||
// チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または
|
// 以下の条件に該当するノートのみ後続処理に通す(ので、以下のif文は該当しないノートをすべて弾くようにする)
|
||||||
// チャンネルの投稿ではなく、全体公開のローカルの投稿 または
|
// - 自分自身の投稿
|
||||||
// フォローしているチャンネルの投稿 の場合だけ
|
// - その投稿のユーザーをフォローしている
|
||||||
if (!(
|
// - 全体公開のローカルの投稿
|
||||||
(note.channelId == null && isMe) ||
|
if (!(
|
||||||
(note.channelId == null && Object.hasOwn(this.following, note.userId)) ||
|
isMe ||
|
||||||
(note.channelId == null && (note.user.host == null && note.visibility === 'public')) ||
|
Object.hasOwn(this.following, note.userId) ||
|
||||||
(note.channelId != null && this.followingChannels.has(note.channelId))
|
(note.user.host == null && note.visibility === 'public')
|
||||||
)) return;
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 以下の条件に該当するノートのみ後続処理に通す(ので、以下のif文は該当しないノートをすべて弾くようにする)
|
||||||
|
// - フォローしているチャンネルの投稿
|
||||||
|
if (!this.followingChannels.has(note.channelId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (note.visibility === 'followers') {
|
if (note.visibility === 'followers') {
|
||||||
if (!isMe && !Object.hasOwn(this.following, note.userId)) return;
|
if (!isMe && !Object.hasOwn(this.following, note.userId)) return;
|
||||||
|
|
|
@ -559,7 +559,7 @@ export class ClientServerService {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
//#region SSR (for crawlers)
|
//#region SSR
|
||||||
// User
|
// User
|
||||||
fastify.get<{ Params: { user: string; sub?: string; } }>('/@:user/:sub?', async (request, reply) => {
|
fastify.get<{ Params: { user: string; sub?: string; } }>('/@:user/:sub?', async (request, reply) => {
|
||||||
const { username, host } = Acct.parse(request.params.user);
|
const { username, host } = Acct.parse(request.params.user);
|
||||||
|
@ -584,11 +584,17 @@ export class ClientServerService {
|
||||||
reply.header('X-Robots-Tag', 'noimageai');
|
reply.header('X-Robots-Tag', 'noimageai');
|
||||||
reply.header('X-Robots-Tag', 'noai');
|
reply.header('X-Robots-Tag', 'noai');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _user = await this.userEntityService.pack(user);
|
||||||
|
|
||||||
return await reply.view('user', {
|
return await reply.view('user', {
|
||||||
user, profile, me,
|
user, profile, me,
|
||||||
avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user),
|
avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user),
|
||||||
sub: request.params.sub,
|
sub: request.params.sub,
|
||||||
...await this.generateCommonPugData(this.meta),
|
...await this.generateCommonPugData(this.meta),
|
||||||
|
clientCtx: htmlSafeJsonStringify({
|
||||||
|
user: _user,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// リモートユーザーなので
|
// リモートユーザーなので
|
||||||
|
@ -641,6 +647,9 @@ export class ClientServerService {
|
||||||
// TODO: Let locale changeable by instance setting
|
// TODO: Let locale changeable by instance setting
|
||||||
summary: getNoteSummary(_note),
|
summary: getNoteSummary(_note),
|
||||||
...await this.generateCommonPugData(this.meta),
|
...await this.generateCommonPugData(this.meta),
|
||||||
|
clientCtx: htmlSafeJsonStringify({
|
||||||
|
note: _note,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return await renderBase(reply);
|
return await renderBase(reply);
|
||||||
|
@ -729,6 +738,9 @@ export class ClientServerService {
|
||||||
profile,
|
profile,
|
||||||
avatarUrl: _clip.user.avatarUrl,
|
avatarUrl: _clip.user.avatarUrl,
|
||||||
...await this.generateCommonPugData(this.meta),
|
...await this.generateCommonPugData(this.meta),
|
||||||
|
clientCtx: htmlSafeJsonStringify({
|
||||||
|
clip: _clip,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return await renderBase(reply);
|
return await renderBase(reply);
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue