Merge branch Sharkey:develop into trackeropt

This commit is contained in:
Vavency 2025-07-17 15:04:33 +00:00
commit 9dbd2a6bb4
292 changed files with 5783 additions and 3323 deletions

View file

@ -341,6 +341,10 @@ id: 'aidx'
#maxAltTextLength: 20000
# Amount of characters that will be saved for remote media descriptions (alt text). Longer descriptions will be truncated to this length. (minimum: 1)
#maxRemoteAltTextLength: 100000
# Amount of characters that can be used when writing user bios. Longer descriptions will be rejected. (minimum: 1)
#maxBioLength: 1500
# Amount of characters that will be saved for remote user bios. Longer descriptions will be truncated to this length. (minimum: 1)
#maxRemoteBioLength: 15000
# Proxy for HTTP/HTTPS
#proxy: http://127.0.0.1:3128

View file

@ -344,6 +344,10 @@ id: 'aidx'
#maxAltTextLength: 20000
# Amount of characters that will be saved for remote media descriptions (alt text). Longer descriptions will be truncated to this length. (minimum: 1)
#maxRemoteAltTextLength: 100000
# Amount of characters that can be used when writing user bios. Longer descriptions will be rejected. (minimum: 1)
#maxBioLength: 1500
# Amount of characters that will be saved for remote user bios. Longer descriptions will be truncated to this length. (minimum: 1)
#maxRemoteBioLength: 15000
# Proxy for HTTP/HTTPS
#proxy: http://127.0.0.1:3128

View file

@ -5,7 +5,7 @@
"workspaceFolder": "/workspace",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "22.11.0"
"version": "22.15.0"
},
"ghcr.io/devcontainers-extra/features/pnpm:2": {
"version": "10.10.0"

View file

@ -1 +1 @@
22.11.0
22.15.0

View file

@ -1,3 +1,31 @@
## 2025.5.0
### Note
- DockerのNode.jsが22.15.0に更新されました
### Client
- Feat: マウスで中ボタンドラッグによりタイムラインを引っ張って更新できるように
- アクセシビリティ設定からオフにすることもできます
- Enhance: タイムラインのパフォーマンスを向上
- Enhance: バックアップされた設定のプロファイルを削除できるように
- Fix: 一部のブラウザでアコーディオンメニューのアニメーションが動作しない問題を修正
- Fix: ダイアログのお知らせが画面からはみ出ることがある問題を修正
- Fix: ユーザーポップアップでエラーが生じてもインジケーターが表示され続けてしまう問題を修正
### Server
- Enhance: 凍結されたユーザのノートが各種タイムラインで表示されないように `#15775`
- Enhance: 連合先のソフトウェア及びバージョン名により配信停止を行えるように `#15727`
- Enhance: 2025.4.1 で追加されたインデックスの再生成をノートの追加しながら行えるようになりました。 `#15915`
- `MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY` 環境変数を `1` にセットしていると、巨大なテーブルの既存のカラムに関するインデックス再生成が`CREATE INDEX CONCURRENTLY`を使用するようになりました。
- 複数のサーバープロセスをクラスタリングしているサーバーにおいて、一部のプロセスが起動している状態でこのオプションを有効にしてマイグレーションすることにより、ダウンタイムを削減することができます。
- ただし、このオプションを有効にする場合、インデックスの作成にかかる時間が倍~3倍以上になることがあります。
- また、大きなインスタンスである場合にはインデックスの作成に失敗し、複数回再試行する必要がある可能性があります。
- Fix: チャンネルのフォロー一覧の結果が一部正しくないのを修正 (#12175)
- Fix: ファイルをアップロードした際にファイル名が常に untitled になる問題を修正
- Fix: ファイルのアップロードに失敗することがある問題を修正
- 投稿フォーム上で画像のクロップを行うと、`Invalid Param.`エラーでノートが投稿出来なくなる問題も解決されます。
- この事象によって既にノートが投稿出来ない状態になっている場合は、投稿フォーム右上のメニューから、下書きデータの「リセット」を行ってください。
## 2025.4.1
### General

View file

@ -725,7 +725,12 @@ seems to do a decent job)
* Commit!
* double-check the new migration, that they won't conflict with our db changes: `git diff develop -- packages/backend/migration/`
* `pnpm clean; pnpm build`
* run tests `pnpm test; pnpm --filter backend test:e2e` (requires a test database, [see above](#testing)) and fix as much as you can.
* run lint `pnpm --filter=backend --filter=frontend-shared lint` + `pnpm --filter=frontend --filter=frontend-embed eslint` and fix as much as you can.
* run tests `pnpm test; pnpm --filter backend test:e2e` (requires a
test database, [see above](#testing)) and fix them all (the e2e
tests randomly fail with weird errors like `relation "users" does
not exist`, run them again if that happens)
* run lint `pnpm --filter=backend --filter=frontend-shared lint` +
`pnpm --filter=frontend --filter=frontend-embed eslint` and fix all
the problems
Then push and open a Merge Request.

View file

@ -1,6 +1,6 @@
# syntax = docker/dockerfile:1.4
ARG NODE_VERSION=22.11.0-alpine3.20
ARG NODE_VERSION=22.15.0-alpine3.20
FROM node:${NODE_VERSION} as build
@ -38,7 +38,7 @@ ARG UID="991"
ARG GID="991"
ENV COREPACK_DEFAULT_TO_LATEST=0
RUN apk add ffmpeg tini jemalloc pixman pango cairo libpng \
RUN apk add ffmpeg tini jemalloc pixman pango cairo libpng librsvg font-noto font-noto-cjk font-noto-thai \
&& corepack enable \
&& addgroup -g "${GID}" sharkey \
&& adduser -D -u "${UID}" -G sharkey -h /sharkey sharkey \

BIN
assets/ui-icons.afdesign Normal file

Binary file not shown.

View file

@ -220,6 +220,7 @@ silenceThisInstance: "Silencia aquesta instància "
mediaSilenceThisInstance: "Silenciar els arxius d'aquesta instància "
operations: "Accions"
software: "Programari"
softwareName: "Nom del programari"
version: "Versió"
metadata: "Metadades"
withNFiles: "{n} fitxer(s)"
@ -1347,6 +1348,7 @@ readonly: "Només lectura"
goToDeck: "Tornar al tauler"
federationJobs: "Treballs sindicats "
driveAboutTip: "Al Disc veure's una llista de tots els arxius que has anat pujant.<br>\nPots tornar-los a fer servir adjuntant-los a notes noves o pots adelantar-te i pujar arxius per publicar-los més tard!<br>\n<b>Tingués en compte que si esborres un arxiu també desapareixerà de tots els llocs on l'has fet servir (notes, pàgines, avatars, imatges de capçalera, etc.)</b><br>\nTambé pots crear carpetes per organitzar les."
scrollToClose: "Desplaçar per tancar"
_chat:
noMessagesYet: "Encara no tens missatges "
newMessage: "Missatge nou"
@ -1423,6 +1425,8 @@ _settings:
ifOn: "Quan s'activa"
ifOff: "Quan es desactiva"
enableSyncThemesBetweenDevices: "Sincronitzar els temes instal·lats entre dispositius"
enablePullToRefresh: "Lliscar i actualitzar "
enablePullToRefresh_description: "Amb el ratolí, llisca mentre prems la roda."
_chat:
showSenderName: "Mostrar el nom del remitent"
sendOnEnter: "Introdueix per enviar"
@ -1468,6 +1472,7 @@ _delivery:
manuallySuspended: "Suspendre manualment"
goneSuspended: "Servidor suspès perquè el servidor s'ha esborrat"
autoSuspendedForNotResponding: "Servidor suspès perquè el servidor no respon"
softwareSuspended: "Suspès perquè el programari ha deixat de desenvolupar-se "
_bubbleGame:
howToPlay: "Com es juga"
hold: "Mantenir"
@ -1599,6 +1604,8 @@ _serverSettings:
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."
deliverSuspendedSoftware: "Programari que ja no es distribueix"
deliverSuspendedSoftwareDescription: "Pots especificar un rang de noms i versions del programari del servidor per detenir l'entrega, per exemple, degut a vulnerabilitats. Aquesta informació la proporciona el servidor i la seva fiabilitat no es garantitzada. Es pot fer servir una especificació de rang sencer per especificar una versió, però es recomana especificar una versió anterior, com >= 2024.3.1-0, perquè especificar >= 2024.3.1 no incloure versions personalitzades com 2024.3.1-custom.0."
_accountMigration:
moveFrom: "Migrar un altre compte a aquest"
moveFromSub: "Crear un àlies per un altre compte"

View file

@ -220,6 +220,7 @@ silenceThisInstance: "Instanz stummschalten"
mediaSilenceThisInstance: "Medien dieses Servers stummschalten"
operations: "Aktionen"
software: "Software"
softwareName: "Software Name"
version: "Version"
metadata: "Metadaten"
withNFiles: "{n} Datei(en)"
@ -1347,6 +1348,7 @@ readonly: "Nur Lesezugriff"
goToDeck: "Zurück zum Deck"
federationJobs: "Föderation Jobs"
driveAboutTip: "In Drive sehen Sie eine Liste der Dateien, die Sie in der Vergangenheit hochgeladen haben. <br>\nSie können diese Dateien wiederverwenden um sie zu beispiel an Notizen anzuhängen, oder sie können Dateien vorab hochzuladen, um sie später zu versenden! <br>\n<b>Wenn Sie eine Datei löschen, verschwindet sie auch von allen Stellen, an denen Sie sie verwendet haben (Notizen, Seiten, Avatare, Banner usw.).</b><br>\nSie können auch Ordner erstellen, um sie zu organisieren."
scrollToClose: "Zum Schließen scrollen"
_chat:
noMessagesYet: "Noch keine Nachrichten"
newMessage: "Neue Nachricht"
@ -1423,6 +1425,8 @@ _settings:
ifOn: "Wenn eingeschaltet"
ifOff: "Wenn ausgeschaltet"
enableSyncThemesBetweenDevices: "Synchronisierung von installierten Themen auf verschiedenen Endgeräten"
enablePullToRefresh: "Ziehen zum Aktualisieren"
enablePullToRefresh_description: "Bei Benutzung einer Maus, mit gedrücktem Mausrad ziehen"
_chat:
showSenderName: "Name des Absenders anzeigen"
sendOnEnter: "Eingabetaste sendet Nachricht"
@ -1468,6 +1472,7 @@ _delivery:
manuallySuspended: "Manuell gesperrt"
goneSuspended: "Gesperrt wegen Löschung des Servers"
autoSuspendedForNotResponding: "Gesperrt, weil der Server nicht antwortet"
softwareSuspended: "Ausgesetzt, weil die Software nicht mehr beliefert wird"
_bubbleGame:
howToPlay: "Wie man spielt"
hold: "Halten"
@ -1599,6 +1604,8 @@ _serverSettings:
openRegistration: "Registrierung von Konten aktivieren"
openRegistrationWarning: "Das Aktivieren von Registrierungen ist riskant. Es wird empfohlen, sie nur dann zu aktivieren, wenn der Server ständig überwacht wird und im Falle eines Problems sofort reagiert werden kann."
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "Wenn über einen bestimmten Zeitraum keine Moderatorenaktivität festgestellt wird, wird diese Einstellung automatisch deaktiviert, um Spam zu verhindern."
deliverSuspendedSoftware: "Software, die nicht mehr beliefert wird"
deliverSuspendedSoftwareDescription: "Sie können eine Auswahl von Namen und Versionen verschiedener Serversoftware angeben, um die Zustellung zu stoppen, z. B. aufgrund von Sicherheitslücken. Diese Versionsinformationen werden vom Server bereitgestellt und ihre Zuverlässigkeit ist nicht garantiert. Es wird jedoch empfohlen, eine Vorabversion anzugeben, wie z. B. >= 2024.3.1-0, da die Angabe >= 2024.3.1 keine benutzerdefinierten Versionen wie 2024.3.1-custom.0 einschließt."
_accountMigration:
moveFrom: "Von einem anderen Konto zu diesem migrieren"
moveFromSub: "Alias für ein anderes Konto erstellen"

View file

@ -220,6 +220,7 @@ silenceThisInstance: "Silence this instance"
mediaSilenceThisInstance: "Media-silence this server"
operations: "Operations"
software: "Software"
softwareName: "Software"
version: "Version"
metadata: "Metadata"
withNFiles: "{n} file(s)"
@ -1347,6 +1348,7 @@ readonly: "Read only"
goToDeck: "Return to Deck"
federationJobs: "Federation Jobs"
driveAboutTip: "In Drive, a list of files you've uploaded in the past will be displayed. <br> \nYou can reuse these files when attaching them to notes, or you can upload files in advance to post later. <br> \n<b>Be careful when deleting a file, as it will not be available in all places where it was used (such as notes, pages, avatars, banners, etc.).</b> <br> \nYou can also create folders to organize your files."
scrollToClose: "Scroll to close"
_chat:
noMessagesYet: "No messages yet"
newMessage: "New message"
@ -1423,6 +1425,8 @@ _settings:
ifOn: "When turned on"
ifOff: "When turned off"
enableSyncThemesBetweenDevices: "Synchronize installed themes across devices"
enablePullToRefresh: "Pull to Refresh"
enablePullToRefresh_description: "When using a mouse, drag while pressing in the scroll wheel."
_chat:
showSenderName: "Show sender's name"
sendOnEnter: "Press Enter to send"
@ -1468,6 +1472,7 @@ _delivery:
manuallySuspended: "Manually suspended"
goneSuspended: "Server is suspended due to server deletion"
autoSuspendedForNotResponding: "Server is suspended due to no responding"
softwareSuspended: "Suspended as this software is no longer being distributed to"
_bubbleGame:
howToPlay: "How to play"
hold: "Hold"
@ -1599,6 +1604,8 @@ _serverSettings:
openRegistration: "Make the account creation open"
openRegistrationWarning: "Opening registration carries risks. It is recommended to only enable it if you have a system in place to continuously monitor the server and respond immediately in case of any issues."
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "If no moderator activity is detected for a while, this setting will be automatically turned off to prevent spam."
deliverSuspendedSoftware: "Suspended Software"
deliverSuspendedSoftwareDescription: "You can specify a range of names and versions of the server's software to stop delivery for vulnerability or other reasons. This version information is provided by the server and is not guaranteed to be reliable. A semver range specification can be used to specify the version, but specifying >= 2024.3.1 will not include custom versions such as 2024.3.1-custom.0, so it is recommended that a prerelease specification be used, such as >= 2024.3.1-0"
_accountMigration:
moveFrom: "Migrate another account to this one"
moveFromSub: "Create alias to another account"

94
locales/index.d.ts vendored
View file

@ -898,6 +898,10 @@ export interface Locale extends ILocale {
*
*/
"software": string;
/**
*
*/
"softwareName": string;
/**
*
*/
@ -5410,6 +5414,10 @@ export interface Locale extends ILocale {
*
*/
"driveAboutTip": string;
/**
*
*/
"scrollToClose": string;
"_chat": {
/**
*
@ -5706,6 +5714,14 @@ export interface Locale extends ILocale {
*
*/
"enableSyncThemesBetweenDevices": string;
/**
*
*/
"enablePullToRefresh": string;
/**
*
*/
"enablePullToRefresh_description": string;
"_chat": {
/**
*
@ -5730,6 +5746,10 @@ export interface Locale extends ILocale {
* : PC
*/
"profileNameDescription2": string;
/**
*
*/
"manageProfiles": string;
};
"_preferencesBackup": {
/**
@ -5872,6 +5892,10 @@ export interface Locale extends ILocale {
*
*/
"autoSuspendedForNotResponding": string;
/**
*
*/
"softwareSuspended": string;
};
};
"_bubbleGame": {
@ -6369,6 +6393,14 @@ export interface Locale extends ILocale {
*
*/
"thisSettingWillAutomaticallyOffWhenModeratorsInactive": string;
/**
*
*/
"deliverSuspendedSoftware": string;
/**
* semver 使>= 2024.3.1 2024.3.1-custom.0 >= 2024.3.1-0 prerelease
*/
"deliverSuspendedSoftwareDescription": string;
/**
* Logo URL
*/
@ -11988,6 +12020,22 @@ export interface Locale extends ILocale {
* Boosts muted
*/
"renoteMuted": string;
/**
* Mute note
*/
"muteNote": string;
/**
* Unmute note
*/
"unmuteNote": string;
/**
* {name} said something in a muted post
*/
"userSaysSomethingInMutedNote": ParameterizedString<"name">;
/**
* {name} said something in a muted thread
*/
"userSaysSomethingInMutedThread": ParameterizedString<"name">;
/**
* Mark all media from user as NSFW
*/
@ -12265,6 +12313,10 @@ export interface Locale extends ILocale {
* Collapse files
*/
"collapseFiles": string;
/**
* Clone
*/
"clone": string;
/**
* Uncollapse CWs on notes
*/
@ -12954,6 +13006,10 @@ export interface Locale extends ILocale {
* Unable to process quote. This post may be missing context.
*/
"quoteUnavailable": string;
/**
* One or more media attachments are unavailable and cannot be shown.
*/
"attachmentFailed": string;
};
/**
* Authorized Fetch
@ -13257,6 +13313,44 @@ export interface Locale extends ILocale {
* ActivityPub user data in its raw form. These fields are public and accessible to other instances.
*/
"rawApDescription": string;
/**
* Signup Reason
*/
"signupReason": string;
"clearCachedFilesOptions": {
/**
* Delete all cached remote files
*/
"title": string;
/**
* Only delete files older than:
*/
"olderThan": string;
/**
* now
*/
"now": string;
/**
* one week
*/
"oneWeek": string;
/**
* one month
*/
"oneMonth": string;
/**
* one year
*/
"oneYear": string;
/**
* Don't delete files used as avatars&c
*/
"keepFilesInUse": string;
/**
* this option requires more complicated database queries, you may need to increase the value of db.extra.statement_timeout in the configuration file
*/
"keepFilesInUseDescription": string;
};
}
declare const locales: {
[lang: string]: Locale;

View file

@ -220,6 +220,7 @@ silenceThisInstance: "サーバーをサイレンス"
mediaSilenceThisInstance: "サーバーをメディアサイレンス"
operations: "操作"
software: "ソフトウェア"
softwareName: "ソフトウェア名"
version: "バージョン"
metadata: "メタデータ"
withNFiles: "{n}つのファイル"
@ -1347,6 +1348,7 @@ readonly: "読み取り専用"
goToDeck: "デッキへ戻る"
federationJobs: "連合ジョブ"
driveAboutTip: "ドライブでは、過去にアップロードしたファイルの一覧が表示されます。<br>\nートに添付する際に再利用したり、あとで投稿するファイルを予めアップロードしておくこともできます。<br>\n<b>ファイルを削除すると、今までそのファイルを使用した全ての場所(ノート、ページ、アバター、バナー等)からも見えなくなるので注意してください。</b><br>\nフォルダを作って整理することもできます。"
scrollToClose: "スクロールして閉じる"
_chat:
noMessagesYet: "まだメッセージはありません"
@ -1426,6 +1428,8 @@ _settings:
ifOn: "オンのとき"
ifOff: "オフのとき"
enableSyncThemesBetweenDevices: "デバイス間でインストールしたテーマを同期"
enablePullToRefresh: "ひっぱって更新"
enablePullToRefresh_description: "マウスでは、ホイールを押し込みながらドラッグします。"
_chat:
showSenderName: "送信者の名前を表示"
@ -1435,6 +1439,7 @@ _preferencesProfile:
profileName: "プロファイル名"
profileNameDescription: "このデバイスを識別する名前を設定してください。"
profileNameDescription2: "例: 「メインPC」、「スマホ」など"
manageProfiles: "プロファイルの管理"
_preferencesBackup:
autoBackup: "自動バックアップ"
@ -1477,6 +1482,7 @@ _delivery:
manuallySuspended: "手動停止中"
goneSuspended: "サーバー削除のため停止中"
autoSuspendedForNotResponding: "サーバー応答なしのため停止中"
softwareSuspended: "配信停止中のソフトウェアであるため停止中"
_bubbleGame:
howToPlay: "遊び方"
@ -1615,6 +1621,8 @@ _serverSettings:
openRegistration: "アカウントの作成をオープンにする"
openRegistrationWarning: "登録を開放することはリスクが伴います。サーバーを常に監視し、トラブルが発生した際にすぐに対応できる体制がある場合のみオンにすることを推奨します。"
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "一定期間モデレーターのアクティビティが検出されなかった場合、スパム防止のためこの設定は自動でオフになります。"
deliverSuspendedSoftware: "配信停止中のソフトウェア"
deliverSuspendedSoftwareDescription: "脆弱性などの理由で、サーバーのソフトウェアの名前及びバージョンの範囲を指定して配信を停止できます。このバージョン情報はサーバーが提供したものであり、信頼性は保証されません。バージョン指定には semver の範囲指定が使用できますが、>= 2024.3.1 と指定すると 2024.3.1-custom.0 のようなカスタムバージョンが含まれないため、>= 2024.3.1-0 のように prerelease の指定を行うことを推奨します。"
_accountMigration:
moveFrom: "別のアカウントからこのアカウントに移行"

View file

@ -5,6 +5,7 @@ introMisskey: "Добро пожаловать! Misskey — это децент
poweredByMisskeyDescription: "{name} сервис на платформе с открытым исходным кодом <b>Misskey</b>, называемый экземпляром Misskey."
monthAndDay: "{day}.{month}"
search: "Поиск"
reset: "Сброс"
notifications: "Уведомления"
username: "Имя пользователя"
password: "Пароль"
@ -48,6 +49,7 @@ pin: "Закрепить в профиле"
unpin: "Открепить от профиля"
copyContent: "Скопировать содержимое"
copyLink: "Скопировать ссылку"
copyRemoteLink: "Скопировать ссылку на репост"
copyLinkRenote: "Скопировать ссылку на репост"
delete: "Удалить"
deleteAndEdit: "Удалить и отредактировать"
@ -215,8 +217,10 @@ perDay: "По дням"
stopActivityDelivery: "Остановить отправку обновлений активности"
blockThisInstance: "Блокировать этот инстанс"
silenceThisInstance: "Заглушить этот инстанс"
mediaSilenceThisInstance: "Заглушить сервер"
operations: "Операции"
software: "Программы"
softwareName: "Software Name"
version: "Версия"
metadata: "Метаданные"
withNFiles: "Файлы, {n} шт."
@ -235,7 +239,11 @@ clearCachedFilesConfirm: "Удалить все закэшированные ф
blockedInstances: "Заблокированные инстансы"
blockedInstancesDescription: "Введите список инстансов, которые хотите заблокировать. Они больше не смогут обмениваться с вашим инстансом."
silencedInstances: "Заглушённые инстансы"
silencedInstancesDescription: "Перечислите имена серверов, которые вы хотите отключить, разделив их новой строкой. Все учетные записи, принадлежащие к указанным в списке серверам, будут заблокированы и смогут отправлять запросы только на повторное использование и не смогут указывать локальные учетные записи, если они не будут отслеживаться. Это не повлияет на заблокированные серверы."
mediaSilencedInstances: "Заглушённые сервера"
mediaSilencedInstancesDescription: "Укажите названия серверов, для которых вы хотите отключить доступ к файлам, по одному серверу в строке. Все учетные записи, принадлежащие к перечисленным серверам, будут считаться конфиденциальными и не смогут использовать пользовательские эмодзи. Это никак не повлияет на заблокированные серверы."
federationAllowedHosts: "Серверы, поддерживающие федерацию"
federationAllowedHostsDescription: "Укажите имена серверов, для которых вы хотите разрешить объединение, разделив их разделителями строк."
muteAndBlock: "Скрытие и блокировка"
mutedUsers: "Скрытые пользователи"
blockedUsers: "Заблокированные пользователи"
@ -294,6 +302,7 @@ uploadFromUrlMayTakeTime: "Загрузка может занять некото
explore: "Обзор"
messageRead: "Прочитали"
noMoreHistory: "История закончилась"
startChat: "Начать чат"
nUsersRead: "Прочитали {n}"
agreeTo: "Я соглашаюсь с {0}"
agree: "Согласен"
@ -416,6 +425,7 @@ antennaExcludeBots: "Исключать ботов"
antennaKeywordsDescription: "Пишите слова через пробел в одной строке, чтобы ловить их появление вместе; на отдельных строках располагайте слова, или группы слов, чтобы ловить любые из них."
notifyAntenna: "Уведомлять о новых заметках"
withFileAntenna: "Только заметки с вложениями"
excludeNotesInSensitiveChannel: "Исключить заметки из конфиденциальных каналов"
enableServiceworker: "Включить ServiceWorker"
antennaUsersDescription: "Пишите каждое название аккаута на отдельной строке"
caseSensitive: "С учётом регистра"
@ -446,6 +456,8 @@ totpDescription: "Описание приложения-аутентификат
moderator: "Модератор"
moderation: "Модерация"
moderationNote: "Примечания модератора"
moderationNoteDescription: "Вы можете заполнять заметки, которые будут доступны только модераторам."
addModerationNote: ""
moderationLogs: "Журнал модерации"
nUsersMentioned: "Упомянуло пользователей: {n}"
securityKeyAndPasskey: "Ключ безопасности и парольная фраза"
@ -506,6 +518,8 @@ emojiStyle: "Стиль эмодзи"
native: "Системные"
menuStyle: "Стиль меню"
style: "Стиль"
drawer: "Панель"
popup: "Всплывающие окна"
showNoteActionsOnlyHover: "Показывать кнопки у заметок только при наведении"
showReactionsCount: "Видеть количество реакций на заметках"
noHistory: "История пока пуста"
@ -560,6 +574,7 @@ serverLogs: "Журнал сервера"
deleteAll: "Удалить всё"
showFixedPostForm: "Показывать поле для ввода новой заметки наверху ленты"
showFixedPostFormInChannel: "Показывать поле для ввода новой заметки наверху ленты (каналы)"
withRepliesByDefaultForNewlyFollowed: "По умолчанию включайте ответы новых пользователей, на которых вы подписались, во временную шкалу"
newNoteRecived: "Появилась новая заметка"
sounds: "Звуки"
sound: "Звуки"
@ -572,6 +587,7 @@ masterVolume: "Основная регулировка громкости"
notUseSound: "Выключить звук"
useSoundOnlyWhenActive: "Воспроизводить звук только когда Misskey активен."
details: "Подробнее"
renoteDetails: "Узнать больше"
chooseEmoji: "Выберите эмодзи"
unableToProcess: "Не удаётся завершить операцию"
recentUsed: "Последние использованные"
@ -587,6 +603,8 @@ ascendingOrder: "по возрастанию"
descendingOrder: "По убыванию"
scratchpad: "Когтеточка"
scratchpadDescription: "«Когтеточка» — это место для опытов с AiScript. Здесь можно писать программы, взаимодействующие с Misskey, запускать и смотреть что из этого получается."
uiInspector: "Средство проверки пользовательского интерфейса"
uiInspectorDescription: "Вы можете просмотреть список экземпляров компонентов пользовательского интерфейса, существующих в памяти. Элементы пользовательского интерфейса генерируются с помощью серии функций Ui:C:."
output: "Выходы"
script: "Скрипт"
disablePagesScript: "Отключить скрипты на «Страницах»"
@ -667,14 +685,19 @@ smtpSecure: "Использовать SSL/TLS для SMTP-соединений"
smtpSecureInfo: "Выключите при использовании STARTTLS."
testEmail: "Проверка доставки электронной почты"
wordMute: "Скрытие слов"
wordMuteDescription: "Сведите к минимуму записи, содержащие указанное утверждение. Нажмите на свернутую запись, чтобы отобразить ее."
hardWordMute: "Строгое скрытие слов"
showMutedWord: "Отображать слово без уведомления (звука)"
hardWordMuteDescription: "Скрыть заметки, содержащие указанное слово или фразу. В отличие от word mute, заметка будет полностью скрыта от просмотра."
regexpError: "Ошибка в регулярном выражении"
regexpErrorDescription: "В списке {tab} скрытых слов, в строке {line} обнаружена синтаксическая ошибка:"
instanceMute: "Глушение инстансов"
userSaysSomething: "{name} что-то сообщает"
userSaysSomethingAbout: "{name} что-то говорил о「{word}」"
makeActive: "Активировать"
display: "Отображение"
copy: "Копировать"
copiedToClipboard: "Скопированы в буфер обмена"
metrics: "Метрики"
overview: "Обзор"
logs: "Журналы"
@ -840,6 +863,7 @@ administration: "Управление"
accounts: "Учётные записи"
switch: "Переключение"
noMaintainerInformationWarning: "Не заполнены сведения об администраторах"
noInquiryUrlWarning: "URL-адрес контактной формы еще не задан."
noBotProtectionWarning: "Ботозащита не настроена"
configure: "Настроить"
postToGallery: "Опубликовать в галерею"
@ -904,6 +928,7 @@ followersVisibility: "Видимость подписчиков"
continueThread: "Показать следующие ответы"
deleteAccountConfirm: "Учётная запись будет безвозвратно удалена. Подтверждаете?"
incorrectPassword: "Пароль неверен."
incorrectTotp: "Введен неверный одноразовый пароль или срок его действия истек."
voteConfirm: "Отдать голос за «{choice}»?"
hide: "Спрятать"
useDrawerReactionPickerForMobile: "Выдвижная палитра на мобильном устройстве"
@ -928,6 +953,9 @@ oneHour: "1 час"
oneDay: "1 день"
oneWeek: "1 неделя"
oneMonth: "1 месяц"
threeMonths: "3 месяца"
oneYear: "1 год"
threeDays: "3 дня"
reflectMayTakeTime: "Изменения могут занять время для отображения"
failedToFetchAccountInformation: "Не удалось получить информацию об аккаунте"
rateLimitExceeded: "Ограничение скорости превышено"
@ -952,6 +980,7 @@ document: "Документ"
numberOfPageCache: "Количество сохранённых страниц в кэше"
numberOfPageCacheDescription: "Описание количества страниц в кэше"
logoutConfirm: "Вы хотите выйти из аккаунта?"
logoutWillClearClientData: "Когда вы выйдете из системы, информация о конфигурации клиента будет удалена из браузера.Чтобы иметь возможность восстановить информацию о вашей конфигурации при повторном входе в систему, пожалуйста, включите опцию автоматического резервного копирования в настройках."
lastActiveDate: "Последняя дата использования"
statusbar: "Статусбар"
pleaseSelect: "Пожалуйста, выберите"
@ -1001,6 +1030,7 @@ neverShow: "Больше не показывать"
remindMeLater: "Напомнить позже"
didYouLikeMisskey: "Вам нравится Misskey?"
pleaseDonate: "Сайт {host} работает на Misskey. Это бесплатное программное обеспечение, и ваши пожертвования очень бы помогли продолжать его разработку!"
correspondingSourceIsAvailable: "Соответствующий исходный код можно найти по адресу {anchor} "
roles: "Роли"
role: "Роль"
noRole: "Нет роли"
@ -1056,6 +1086,7 @@ prohibitedWords: "Запрещённые слова"
prohibitedWordsDescription: "Включает вывод ошибки при попытке опубликовать пост, содержащий указанное слово/набор слов.\nМножество слов может быть указано, разделяемые новой строкой."
prohibitedWordsDescription2: "Разделение пробелом создаёт спецификацию AND, а разделение косой чертой создаёт регулярное выражение."
hiddenTags: "Скрытые хештеги"
hiddenTagsDescription: "Установленные теги не будут отображаться в тренде, можно установить несколько тегов."
notesSearchNotAvailable: "Поиск заметок недоступен"
license: "Лицензия"
unfavoriteConfirm: "Удалить избранное?"
@ -1066,6 +1097,7 @@ retryAllQueuesConfirmTitle: "Хотите попробовать ещё раз?"
retryAllQueuesConfirmText: "Нагрузка на сервер может увеличиться"
enableChartsForRemoteUser: "Создание диаграмм для удалённых пользователей"
enableChartsForFederatedInstances: "Создание диаграмм для удалённых серверов"
enableStatsForFederatedInstances: "Получить информацию об удаленном сервере"
showClipButtonInNoteFooter: "Показать кнопку добавления в подборку в меню действий с заметкой"
reactionsDisplaySize: "Размер реакций"
limitWidthOfReaction: "Ограничить максимальную ширину реакций и отображать их в уменьшенном размере."
@ -1101,6 +1133,7 @@ preservedUsernames: "Зарезервированные имена пользо
preservedUsernamesDescription: "Перечислите зарезервированные имена пользователей, отделяя их строками. Они станут недоступны при создании учётной записи. Это ограничение не применяется при создании учётной записи администраторами. Также, уже существующие учётные записи останутся без изменений."
createNoteFromTheFile: "Создать заметку из этого файла"
archive: "Архив"
archived: "Архивировано"
unarchive: "Разархивировать"
channelArchiveConfirmTitle: "Переместить {name} в архив?"
channelArchiveConfirmDescription: "Архивированные каналы перестанут отображаться в списке каналов или результатах поиска. В них также нельзя будет добавлять новые записи."
@ -1121,6 +1154,7 @@ rolesThatCanBeUsedThisEmojiAsReaction: "Роли тех, кому можно и
rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "Если здесь ничего не указать, в качестве реакции эту эмодзи сможет использовать каждый."
rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "Эти роли должны быть общедоступными."
cancelReactionConfirm: "Вы действительно хотите удалить свою реакцию?"
changeReactionConfirm: "Вы действительно хотите удалить свою реакцию?"
later: "Позже"
goToMisskey: "К Misskey"
additionalEmojiDictionary: "Дополнительные словари эмодзи"
@ -1130,9 +1164,16 @@ enableServerMachineStats: "Опубликовать характеристики
enableIdenticonGeneration: "Включить генерацию иконки пользователя"
turnOffToImprovePerformance: "Отключение этого параметра может повысить производительность."
createInviteCode: "Создать код приглашения"
createWithOptions: "Используйте параметры для создания"
createCount: "Количество приглашений"
inviteCodeCreated: "Создан пригласительный код"
inviteLimitExceeded: "Достигнут предел количества пригласительных кодов, которые могут быть созданы."
createLimitRemaining: "Пригласительные коды, которые могут быть созданы: {limit} "
inviteLimitResetCycle: "За определенное {time} Вы можете создать неограниченное количество пригласительных кодов {limit} "
expirationDate: "Дата истечения"
noExpirationDate: "Бессрочно"
inviteCodeUsedAt: "Дата и время, когда был использован пригласительный код"
registeredUserUsingInviteCode: "Пользователи, которые использовали пригласительный код"
unused: "Неиспользованное"
used: "Использован"
expired: "Срок действия приглашения истёк"

View file

@ -220,6 +220,7 @@ silenceThisInstance: "静音此服务器"
mediaSilenceThisInstance: "隐藏此服务器的媒体文件"
operations: "操作"
software: "软件"
softwareName: "软件名"
version: "版本"
metadata: "元数据"
withNFiles: "{n} 个文件"
@ -1422,6 +1423,8 @@ _settings:
showNavbarSubButtons: "在导航栏中显示副按钮"
ifOn: "启用时"
ifOff: "关闭时"
enablePullToRefresh: "开启下拉刷新"
enablePullToRefresh_description: "使用鼠标时按下滚轮来拖动"
_chat:
showSenderName: "显示发送者的名字"
sendOnEnter: "回车键发送"
@ -1467,6 +1470,7 @@ _delivery:
manuallySuspended: "手动停止中"
goneSuspended: "因服务器被删除而停止"
autoSuspendedForNotResponding: "因服务器无应答而停止"
softwareSuspended: "因有不可用的软件而停止"
_bubbleGame:
howToPlay: "游戏说明"
hold: "抓住"
@ -1598,6 +1602,7 @@ _serverSettings:
openRegistration: "开放注册"
openRegistrationWarning: "开放注册有风险。建议仅当能够持续监控服务器并在出现问题时能够立即响应时才打开它。"
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "若在一段时间内没有检测到管理活动,为防止垃圾信息,此设定将自动关闭。"
deliverSuspendedSoftware: "不可用的软件"
_accountMigration:
moveFrom: "从别的账号迁移到此账户"
moveFromSub: "为另一个账户建立别名"

View file

@ -220,6 +220,7 @@ silenceThisInstance: "禁言此伺服器"
mediaSilenceThisInstance: "將這個伺服器的媒體設為禁言"
operations: "操作"
software: "軟體"
softwareName: "軟體名稱"
version: "版本"
metadata: "詮釋資料"
withNFiles: "{n} 個檔案"
@ -1347,6 +1348,7 @@ readonly: "唯讀"
goToDeck: "回去甲板"
federationJobs: "聯邦通訊作業"
driveAboutTip: "在「雲端硬碟」中,會顯示過去上傳的檔案列表。<br>\n可以在附加到貼文時重新利用或者事先上傳之後再用於發布。<br>\n<b>請注意,刪除檔案後,之前使用過該檔案的所有地方(貼文、頁面、大頭貼、橫幅等)也會一併無法顯示。</b><br>\n也可以建立資料夾來整理檔案。"
scrollToClose: "用滾輪關閉"
_chat:
noMessagesYet: "尚無訊息"
newMessage: "新訊息"
@ -1423,6 +1425,8 @@ _settings:
ifOn: "開啟時"
ifOff: "關閉時"
enableSyncThemesBetweenDevices: "在裝置之間同步已安裝的主題"
enablePullToRefresh: "下拉更新"
enablePullToRefresh_description: "使用滑鼠,按下並拖曳滾輪。"
_chat:
showSenderName: "顯示發送者的名稱"
sendOnEnter: "按下 Enter 發送訊息"
@ -1468,6 +1472,7 @@ _delivery:
manuallySuspended: "手動暫停中"
goneSuspended: "因為伺服器刪除所以暫停中"
autoSuspendedForNotResponding: "因為伺服器沒有回應所以暫停中"
softwareSuspended: "此軟體因已停止發佈,目前無法使用"
_bubbleGame:
howToPlay: "玩法說明"
hold: "保留"
@ -1599,6 +1604,8 @@ _serverSettings:
openRegistration: "允許建立帳戶"
openRegistrationWarning: "開放註冊伴隨著風險。 建議只有在伺服器受到持續監控,並準備好在出現問題時能立即處理的情況下才開放註冊。"
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "如果在一段期間內沒有偵測到任何審查員活動,此設定將自動關閉,以防止垃圾內容。"
deliverSuspendedSoftware: "已停止發佈的軟體"
deliverSuspendedSoftwareDescription: "由於脆弱性等原因,可以指定伺服器軟體的名稱與版本範圍來停止其發佈。這些版本資訊是由伺服器所提供,其可靠性無法保證。版本的指定可以使用 semver語意化版本控制 的範圍語法,但如果指定為 >= 2024.3.1,則像 2024.3.1-custom.0 這樣的自訂版本將不會被包含在內,因此建議使用 >= 2024.3.1-0 的方式來同時包含預發佈版本。"
_accountMigration:
moveFrom: "從其他帳戶遷移到這個帳戶"
moveFromSub: "為另一個帳戶建立別名"

View file

@ -1,6 +1,6 @@
{
"name": "sharkey",
"version": "2025.5.0-dev",
"version": "2025.5.2-dev",
"codename": "shonk",
"repository": {
"type": "git",
@ -54,17 +54,7 @@
"lodash": "4.17.21"
},
"dependencies": {
"cssnano": "7.0.6",
"esbuild": "0.25.3",
"execa": "9.5.2",
"fast-glob": "3.3.3",
"glob": "11.0.2",
"ignore-walk": "7.0.0",
"js-yaml": "4.1.0",
"postcss": "8.5.3",
"tar": "7.4.3",
"terser": "5.39.0",
"typescript": "5.8.3"
"js-yaml": "4.1.0"
},
"optionalDependencies": {
"cypress": "14.3.2"
@ -75,10 +65,20 @@
"@typescript-eslint/eslint-plugin": "8.31.0",
"@typescript-eslint/parser": "8.31.0",
"cross-env": "7.0.3",
"cssnano": "7.0.6",
"esbuild": "0.25.3",
"eslint": "9.25.1",
"globals": "16.0.0",
"execa": "9.5.2",
"fast-glob": "3.3.3",
"glob": "11.0.2",
"globals": "16.1.0",
"ncp": "2.0.0",
"pnpm": "10.10.0",
"start-server-and-test": "2.0.11"
"pnpm": "9.6.0",
"ignore-walk": "7.0.0",
"postcss": "8.5.3",
"start-server-and-test": "2.0.11",
"tar": "7.4.3",
"terser": "5.39.0",
"typescript": "5.8.3"
}
}

View file

@ -1,12 +1,19 @@
import tsParser from '@typescript-eslint/parser';
import sharedConfig from '../shared/eslint.config.js';
import globals from 'globals';
import sharedConfig from '../shared/eslint.config.js';
export default [
...sharedConfig,
{
ignores: ['**/node_modules', 'built', '@types/**/*', 'migration'],
},
{
languageOptions: {
globals: {
...globals.node,
},
},
},
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {

31
packages/backend/jest.js Normal file
View file

@ -0,0 +1,31 @@
#!/usr/bin/env node
import child_process from 'node:child_process';
import path from 'node:path';
import url from 'node:url';
import semver from 'semver';
const __filename = url.fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const args = [];
args.push(...[
...semver.satisfies(process.version, '^20.17.0 || ^22.0.0') ? ['--no-experimental-require-module'] : [],
'--experimental-vm-modules',
'--experimental-import-meta-resolve',
path.join(__dirname, 'node_modules/jest/bin/jest.js'),
...process.argv.slice(2),
]);
const child = child_process.spawn(process.execPath, args, { stdio: 'inherit' });
child.on('error', (err) => {
console.error('Failed to start Jest:', err);
process.exit(1);
});
child.on('exit', (code, signal) => {
if (code === null) {
process.exit(128 + signal);
} else {
process.exit(code);
}
});

View file

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

View file

@ -3,11 +3,25 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { isConcurrentIndexMigrationEnabled } from "./js/migration-config.js";
export class CompositeNoteIndex1745378064470 {
name = 'CompositeNoteIndex1745378064470';
transaction = isConcurrentIndexMigrationEnabled() ? false : undefined;
async up(queryRunner) {
await queryRunner.query(`CREATE INDEX "IDX_724b311e6f883751f261ebe378" ON "note" ("userId", "id" DESC)`);
const concurrently = isConcurrentIndexMigrationEnabled();
if (concurrently) {
const hasValidIndex = await queryRunner.query(`SELECT indisvalid FROM pg_index INNER JOIN pg_class ON pg_index.indexrelid = pg_class.oid WHERE pg_class.relname = 'IDX_724b311e6f883751f261ebe378'`);
if (hasValidIndex.length === 0 || hasValidIndex[0].indisvalid !== true) {
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_724b311e6f883751f261ebe378"`);
await queryRunner.query(`CREATE INDEX CONCURRENTLY "IDX_724b311e6f883751f261ebe378" ON "note" ("userId", "id" DESC)`);
}
} else {
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_724b311e6f883751f261ebe378" ON "note" ("userId", "id" DESC)`);
}
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_5b87d9d19127bd5d92026017a7"`);
// Flush all cached Linear Scan Plans and redo statistics for composite index
// this is important for Postgres to learn that even in highly complex queries, using this index first can reduce the result set significantly
@ -15,7 +29,8 @@ export class CompositeNoteIndex1745378064470 {
}
async down(queryRunner) {
const mayConcurrently = isConcurrentIndexMigrationEnabled() ? 'CONCURRENTLY' : '';
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_724b311e6f883751f261ebe378"`);
await queryRunner.query(`CREATE INDEX "IDX_5b87d9d19127bd5d92026017a7" ON "note" ("userId")`);
await queryRunner.query(`CREATE INDEX ${mayConcurrently} "IDX_5b87d9d19127bd5d92026017a7" ON "note" ("userId")`);
}
}

View file

@ -0,0 +1,22 @@
/**
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class AddNoteThreadMutingIsPostMute1749523586531 {
name = 'AddNoteThreadMutingIsPostMute1749523586531'
async up(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_ae7aab18a2641d3e5f25e0c4ea"`);
await queryRunner.query(`ALTER TABLE "note_thread_muting" ADD "isPostMute" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`COMMENT ON COLUMN "note_thread_muting"."isPostMute" IS 'If true, then this mute applies only to the referenced note. If false (default), then it applies to all replies as well.'`);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_01f7ab05099400012e9a7fd42b" ON "note_thread_muting" ("userId", "threadId", "isPostMute") `);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_01f7ab05099400012e9a7fd42b"`);
await queryRunner.query(`COMMENT ON COLUMN "note_thread_muting"."isPostMute" IS 'If true, then this mute applies only to the referenced note. If false (default), then it applies to all replies as well.'`);
await queryRunner.query(`ALTER TABLE "note_thread_muting" DROP COLUMN "isPostMute"`);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_ae7aab18a2641d3e5f25e0c4ea" ON "note_thread_muting" ("userId", "threadId") `);
}
}

View file

@ -0,0 +1,14 @@
/*
* SPDX-FileCopyrightText: Lillychan and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class UserDescriptionText1750541176036 {
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "description" TYPE TEXT`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "description" TYPE character varying(2048)`);
}
}

View file

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: dakkar and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class RegistryUniqueConstraints1750591589187 {
async up(queryRunner) {
await queryRunner.query(`DELETE FROM "registry_item" WHERE "id" IN (
SELECT t."id" FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY "userId","key","scope","domain" ORDER BY "updatedAt" DESC) rn
FROM "registry_item"
) t WHERE t.rn>1)`);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_d9c48d580287308f8c1f674946" ON "registry_item" ("userId", "key", "scope", "domain") NULLS NOT DISTINCT`);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_d9c48d580287308f8c1f674946"`);
}
}

View file

@ -0,0 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export function isConcurrentIndexMigrationEnabled() {
return process.env.MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY === '1';
}

View file

@ -1,6 +1,7 @@
import { DataSource } from 'typeorm';
import { loadConfig } from './built/config.js';
import { entities } from './built/postgres.js';
import { isConcurrentIndexMigrationEnabled } from "./migration/js/migration-config.js";
const config = loadConfig();
@ -14,8 +15,9 @@ export default new DataSource({
extra: {
...config.db.extra,
// migrations may be very slow, give them longer to run (that 10*1000 comes from postgres.ts)
statement_timeout: (config.db.extra?.statement_timeout ?? 1000 * 10) * 10,
statement_timeout: (config.db.extra?.statement_timeout ?? 1000 * 10) * 100,
},
entities: entities,
migrations: ['migration/*.js'],
migrationsTransactionMode: isConcurrentIndexMigrationEnabled() ? 'each' : 'all',
});

View file

@ -25,12 +25,12 @@
"typecheck": "pnpm --filter megalodon build && tsc --noEmit && tsc -p test --noEmit && tsc -p test-federation --noEmit",
"eslint": "eslint --quiet \"{src,test-federation,test,js,@types}/**/*.{js,jsx,ts,tsx,vue}\" --cache",
"lint": "pnpm typecheck && pnpm eslint",
"jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.unit.cjs",
"jest:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.e2e.cjs",
"jest:fed": "node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.fed.cjs",
"jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.unit.cjs",
"jest-and-coverage:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.e2e.cjs",
"jest-clear": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --clearCache",
"jest": "cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.unit.cjs",
"jest:e2e": "cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.e2e.cjs",
"jest:fed": "node ./jest.js --forceExit --config jest.config.fed.cjs",
"jest-and-coverage": "cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.unit.cjs",
"jest-and-coverage:e2e": "cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.e2e.cjs",
"jest-clear": "cross-env NODE_ENV=test node ./jest.js --clearCache",
"test": "pnpm jest",
"test:e2e": "pnpm build && pnpm build:test && pnpm jest:e2e",
"test:fed": "pnpm jest:fed",
@ -80,7 +80,7 @@
"@fastify/static": "8.1.1",
"@fastify/view": "10.0.2",
"@misskey-dev/sharp-read-bmp": "1.3.0",
"@misskey-dev/summaly": "5.2.1",
"@misskey-dev/summaly": "npm:@transfem-org/summaly@5.2.2",
"@nestjs/common": "11.1.0",
"@nestjs/core": "11.1.0",
"@nestjs/testing": "11.1.0",
@ -90,33 +90,30 @@
"@simplewebauthn/server": "12.0.0",
"@sinonjs/fake-timers": "11.3.1",
"@smithy/node-http-handler": "2.5.0",
"@swc/cli": "0.7.3",
"@swc/core": "1.11.24",
"@transfem-org/sfm-js": "0.24.6",
"mfm-js": "npm:@transfem-org/sfm-js@0.24.8",
"@twemoji/parser": "15.1.1",
"accepts": "1.3.8",
"ajv": "8.17.1",
"archiver": "7.0.1",
"argon2": "^0.40.1",
"argon2": "0.43.0",
"axios": "1.7.4",
"async-mutex": "0.5.0",
"bcryptjs": "2.4.3",
"blurhash": "2.0.5",
"body-parser": "1.20.3",
"bullmq": "5.51.1",
"cacheable-lookup": "7.0.0",
"canvas": "^3.1.0",
"canvas": "3.1.0",
"cbor": "9.0.2",
"chalk": "5.4.1",
"chalk-template": "1.1.0",
"cheerio": "1.0.0",
"chokidar": "3.6.0",
"cli-highlight": "2.1.11",
"cli-highlight": "npm:@transfem-org/cli-highlight@2.1.12",
"color-convert": "2.0.1",
"content-disposition": "0.5.4",
"date-fns": "2.30.0",
"deep-email-validator": "0.1.21",
"fast-xml-parser": "4.4.1",
"dom-serializer": "2.0.0",
"domhandler": "5.0.3",
"domutils": "3.2.2",
"fastify": "5.3.2",
"fastify-raw-body": "5.0.0",
"feed": "4.2.2",
@ -125,10 +122,9 @@
"form-data": "4.0.2",
"glob": "11.0.0",
"got": "14.4.7",
"happy-dom": "16.8.1",
"hpagent": "1.2.0",
"htmlescape": "1.1.1",
"http-link-header": "1.1.3",
"htmlparser2": "9.1.0",
"ioredis": "5.6.1",
"ip-cidr": "4.0.2",
"ipaddr.js": "2.2.0",
@ -136,49 +132,40 @@
"js-yaml": "4.1.0",
"json5": "2.2.3",
"jsonld": "8.3.3",
"jsrsasign": "11.1.0",
"juice": "11.0.1",
"megalodon": "workspace:*",
"meilisearch": "0.50.0",
"microformats-parser": "2.0.2",
"mime-types": "2.1.35",
"misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
"moment": "^2.30.1",
"moment": "2.30.1",
"ms": "3.0.0-canary.1",
"nanoid": "5.1.5",
"nested-property": "4.0.0",
"node-fetch": "3.3.2",
"nodemailer": "6.10.1",
"oauth": "0.10.2",
"oauth2orize": "1.12.0",
"oauth2orize-pkce": "0.1.2",
"os-utils": "0.0.14",
"otpauth": "9.4.0",
"parse5": "7.3.0",
"pg": "8.15.6",
"pkce-challenge": "4.1.0",
"probe-image-size": "7.2.3",
"promise-limit": "2.7.0",
"proxy-addr": "^2.0.7",
"psl": "^1.13.0",
"proxy-addr": "2.0.7",
"psl": "1.15.0",
"pug": "3.0.3",
"qrcode": "1.5.4",
"random-seed": "0.3.0",
"ratelimiter": "3.4.1",
"re2": "1.21.4",
"redis-info": "3.1.0",
"redis-lock": "0.1.4",
"reflect-metadata": "0.2.2",
"rename": "1.0.4",
"rss-parser": "3.13.0",
"rxjs": "7.8.2",
"sanitize-html": "2.16.0",
"secure-json-parse": "3.0.2",
"sharp": "0.34.1",
"semver": "7.7.1",
"slacc": "0.0.10",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"systeminformation": "5.25.11",
"tinycolor2": "1.6.0",
"tmp": "0.2.3",
@ -187,7 +174,7 @@
"typeorm": "0.3.22",
"typescript": "5.8.3",
"ulid": "2.4.0",
"uuid": "^9.0.1",
"uuid": "11.1.0",
"vary": "1.1.2",
"web-push": "3.6.7",
"ws": "8.18.1",
@ -198,16 +185,16 @@
"@nestjs/platform-express": "11.1.0",
"@sentry/vue": "9.14.0",
"@simplewebauthn/types": "12.0.0",
"@swc/cli": "0.7.3",
"@swc/core": "1.11.24",
"@swc/jest": "0.2.38",
"@types/accepts": "1.3.7",
"@types/archiver": "6.0.3",
"@types/bcryptjs": "2.4.6",
"@types/body-parser": "1.19.5",
"@types/color-convert": "2.0.4",
"@types/content-disposition": "0.5.8",
"@types/fluent-ffmpeg": "2.1.27",
"@types/htmlescape": "1.1.3",
"@types/http-link-header": "1.0.7",
"@types/jest": "29.5.14",
"@types/js-yaml": "4.0.9",
"@types/jsonld": "1.5.15",
@ -220,12 +207,11 @@
"@types/oauth2orize": "1.11.5",
"@types/oauth2orize-pkce": "0.1.2",
"@types/pg": "8.11.14",
"@types/proxy-addr": "^2.0.3",
"@types/psl": "^1.1.3",
"@types/proxy-addr": "2.0.3",
"@types/psl": "1.1.3",
"@types/pug": "2.0.10",
"@types/qrcode": "1.5.5",
"@types/random-seed": "0.3.5",
"@types/ratelimiter": "3.4.6",
"@types/redis-info": "3.0.3",
"@types/rename": "1.0.7",
"@types/sanitize-html": "2.15.0",
@ -235,7 +221,6 @@
"@types/supertest": "6.0.3",
"@types/tinycolor2": "1.4.6",
"@types/tmp": "0.2.6",
"@types/uuid": "^9.0.4",
"@types/vary": "1.1.3",
"@types/web-push": "3.6.4",
"@types/ws": "8.18.1",
@ -244,7 +229,7 @@
"aws-sdk-client-mock": "4.1.0",
"cross-env": "7.0.3",
"eslint-plugin-import": "2.31.0",
"execa": "8.0.1",
"execa": "9.5.2",
"fkill": "9.0.0",
"jest": "29.7.0",
"jest-mock": "29.7.0",

View file

@ -14,6 +14,7 @@ import { createPostgresDataSource } from './postgres.js';
import { RepositoryModule } from './models/RepositoryModule.js';
import { allSettled } from './misc/promise-tracker.js';
import { GlobalEvents } from './core/GlobalEventService.js';
import Logger from './logger.js';
import type { Provider, OnApplicationShutdown } from '@nestjs/common';
const $config: Provider = {
@ -24,8 +25,13 @@ const $config: Provider = {
const $db: Provider = {
provide: DI.db,
useFactory: async (config) => {
const db = createPostgresDataSource(config);
return await db.initialize();
try {
const db = createPostgresDataSource(config);
return await db.initialize();
} catch (e) {
console.error('failed to initialize database connection', e);
throw e;
}
},
inject: [DI.config],
};
@ -164,6 +170,8 @@ const $meta: Provider = {
exports: [$config, $db, $meta, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, $redisForReactions, $redisForRateLimit, RepositoryModule],
})
export class GlobalModule implements OnApplicationShutdown {
private readonly logger = new Logger('global');
constructor(
@Inject(DI.db) private db: DataSource,
@Inject(DI.redis) private redisClient: Redis.Redis,
@ -176,8 +184,10 @@ export class GlobalModule implements OnApplicationShutdown {
public async dispose(): Promise<void> {
// Wait for all potential DB queries
this.logger.info('Finalizing active promises...');
await allSettled();
// And then disconnect from DB
this.logger.info('Disconnected from data sources...');
await this.db.destroy();
this.redisClient.disconnect();
this.redisForPub.disconnect();
@ -185,6 +195,7 @@ export class GlobalModule implements OnApplicationShutdown {
this.redisForTimelines.disconnect();
this.redisForReactions.disconnect();
this.redisForRateLimit.disconnect();
this.logger.info('Global module disposed.');
}
async onApplicationShutdown(signal: string): Promise<void> {

View file

@ -19,6 +19,7 @@ export async function server() {
const app = await NestFactory.createApplicationContext(MainModule, {
logger: new NestLogger(),
});
app.enableShutdownHooks();
const serverService = app.get(ServerService);
await serverService.launch();
@ -39,6 +40,7 @@ export async function jobQueue() {
const jobQueue = await NestFactory.createApplicationContext(QueueProcessorModule, {
logger: new NestLogger(),
});
jobQueue.enableShutdownHooks();
jobQueue.get(QueueProcessorService).start();
jobQueue.get(ChartManagementService).start();

View file

@ -64,17 +64,35 @@ async function main() {
}
// Display detail of uncaught exception
process.on('uncaughtException', err => {
process.on('uncaughtExceptionMonitor', ((err, origin) => {
try {
logger.error('Uncaught exception:', err);
logger.error(`Uncaught exception (${origin}):`, err);
} catch {
console.error('Uncaught exception:', err);
console.error(`Uncaught exception (${origin}):`, err);
}
});
}));
// Dying away...
process.on('disconnect', () => {
try {
logger.warn('IPC channel disconnected! The process may soon die.');
} catch {
console.warn('IPC channel disconnected! The process may soon die.');
}
});
process.on('beforeExit', code => {
try {
logger.warn(`Event loop died! Process will exit with code ${code}.`);
} catch {
console.warn(`Event loop died! Process will exit with code ${code}.`);
}
});
process.on('exit', code => {
logger.info(`The process is going to exit with code ${code}`);
try {
logger.info(`The process is going to exit with code ${code}`);
} catch {
console.info(`The process is going to exit with code ${code}`);
}
});
//#endregion

View file

@ -96,6 +96,8 @@ type Source = {
maxRemoteNoteLength?: number;
maxAltTextLength?: number;
maxRemoteAltTextLength?: number;
maxBioLength?: number;
maxRemoteBioLength?: number;
clusterLimit?: number;
@ -261,6 +263,8 @@ export type Config = {
maxRemoteCwLength: number;
maxAltTextLength: number;
maxRemoteAltTextLength: number;
maxBioLength: number;
maxRemoteBioLength: number;
clusterLimit: number | undefined;
id: string;
outgoingAddress: string | undefined;
@ -461,6 +465,8 @@ export function loadConfig(): Config {
maxRemoteCwLength: config.maxRemoteCwLength ?? 5000,
maxAltTextLength: config.maxAltTextLength ?? 20000,
maxRemoteAltTextLength: config.maxRemoteAltTextLength ?? 100000,
maxBioLength: config.maxBioLength ?? 1500,
maxRemoteBioLength: config.maxRemoteBioLength ?? 15000,
clusterLimit: config.clusterLimit,
outgoingAddress: config.outgoingAddress,
outgoingAddressFamily: config.outgoingAddressFamily,
@ -658,7 +664,7 @@ function applyEnvOverrides(config: Source) {
_apply_top(['sentryForFrontend', 'browserTracingIntegration', 'routeLabel']);
_apply_top([['clusterLimit', 'deliverJobConcurrency', 'inboxJobConcurrency', 'relashionshipJobConcurrency', 'deliverJobPerSec', 'inboxJobPerSec', 'relashionshipJobPerSec', 'deliverJobMaxAttempts', 'inboxJobMaxAttempts']]);
_apply_top([['outgoingAddress', 'outgoingAddressFamily', 'proxy', 'proxySmtp', 'mediaDirectory', 'mediaProxy', 'proxyRemoteFiles', 'videoThumbnailGenerator']]);
_apply_top([['maxFileSize', 'maxNoteLength', 'maxRemoteNoteLength', 'maxAltTextLength', 'maxRemoteAltTextLength', 'pidFile', 'filePermissionBits']]);
_apply_top([['maxFileSize', 'maxNoteLength', 'maxRemoteNoteLength', 'maxAltTextLength', 'maxRemoteAltTextLength', 'maxBioLength', 'maxRemoteBioLength', 'pidFile', 'filePermissionBits']]);
_apply_top(['import', ['downloadTimeout', 'maxFileSize']]);
_apply_top([['signToActivityPubGet', 'checkActivityPubGetSignature', 'setupPassword', 'disallowExternalApRedirect']]);
_apply_top(['logging', 'sql', ['disableQueryTruncation', 'enableQueryParamLogging']]);

View file

@ -82,6 +82,28 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
}
}
/**
* Collects all email addresses that a abuse report should be sent to.
*/
@bindThis
public async getRecipientEMailAddresses(): Promise<string[]> {
const recipientEMailAddresses = await this.fetchEMailRecipients().then(it => it
.filter(it => it.isActive && it.userProfile?.emailVerified)
.map(it => it.userProfile?.email)
.filter(x => x != null),
);
if (this.meta.email) {
recipientEMailAddresses.push(this.meta.email);
}
if (this.meta.maintainerEmail) {
recipientEMailAddresses.push(this.meta.maintainerEmail);
}
return recipientEMailAddresses;
}
/**
* Mailを用いて{@link abuseReports}.
* .
@ -96,15 +118,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
return;
}
const recipientEMailAddresses = await this.fetchEMailRecipients().then(it => it
.filter(it => it.isActive && it.userProfile?.emailVerified)
.map(it => it.userProfile?.email)
.filter(x => x != null),
);
recipientEMailAddresses.push(
...(this.meta.email ? [this.meta.email] : []),
);
const recipientEMailAddresses = await this.getRecipientEMailAddresses();
if (recipientEMailAddresses.length <= 0) {
return;

View file

@ -9,87 +9,7 @@ import type { MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { NotificationService } from '@/core/NotificationService.js';
export const ACHIEVEMENT_TYPES = [
'notes1',
'notes10',
'notes100',
'notes500',
'notes1000',
'notes5000',
'notes10000',
'notes20000',
'notes30000',
'notes40000',
'notes50000',
'notes60000',
'notes70000',
'notes80000',
'notes90000',
'notes100000',
'login3',
'login7',
'login15',
'login30',
'login60',
'login100',
'login200',
'login300',
'login400',
'login500',
'login600',
'login700',
'login800',
'login900',
'login1000',
'passedSinceAccountCreated1',
'passedSinceAccountCreated2',
'passedSinceAccountCreated3',
'loggedInOnBirthday',
'loggedInOnNewYearsDay',
'noteClipped1',
'noteFavorited1',
'myNoteFavorited1',
'profileFilled',
'markedAsCat',
'following1',
'following10',
'following50',
'following100',
'following300',
'followers1',
'followers10',
'followers50',
'followers100',
'followers300',
'followers500',
'followers1000',
'collectAchievements30',
'viewAchievements3min',
'iLoveMisskey',
'foundTreasure',
'client30min',
'client60min',
'noteDeletedWithin1min',
'postedAtLateNight',
'postedAt0min0sec',
'selfQuote',
'htl20npm',
'viewInstanceChart',
'outputHelloWorldOnScratchpad',
'open3windows',
'driveFolderCircularReference',
'reactWithoutRead',
'clickedClickHere',
'justPlainLucky',
'setNameToSyuilo',
'cookieClicked',
'brainDiver',
'smashTestNotificationButton',
'tutorialCompleted',
'bubbleGameExplodingHead',
'bubbleGameDoubleExplodingHead',
] as const;
import { ACHIEVEMENT_TYPES } from '@/models/UserProfile.js';
@Injectable()
export class AchievementService {

View file

@ -6,7 +6,7 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import { In, IsNull } from 'typeorm';
import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiNote, MiFollowing } from '@/models/_.js';
import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiNote, MiFollowing, NoteThreadMutingsRepository } from '@/models/_.js';
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
import { QuantumKVCache } from '@/misc/QuantumKVCache.js';
import type { MiLocalUser, MiUser } from '@/models/User.js';
@ -46,6 +46,8 @@ export class CacheService implements OnApplicationShutdown {
public userBlockingCache: QuantumKVCache<Set<string>>;
public userBlockedCache: QuantumKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
public renoteMutingsCache: QuantumKVCache<Set<string>>;
public threadMutingsCache: QuantumKVCache<Set<string>>;
public noteMutingsCache: QuantumKVCache<Set<string>>;
public userFollowingsCache: QuantumKVCache<Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>;
public userFollowersCache: QuantumKVCache<Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>;
public hibernatedUserCache: QuantumKVCache<boolean>;
@ -77,6 +79,9 @@ export class CacheService implements OnApplicationShutdown {
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
@Inject(DI.noteThreadMutingsRepository)
private readonly noteThreadMutingsRepository: NoteThreadMutingsRepository,
private userEntityService: UserEntityService,
private readonly internalEventService: InternalEventService,
) {
@ -145,6 +150,36 @@ export class CacheService implements OnApplicationShutdown {
.then(ms => ms.map(m => [m.muterId, new Set(m.muteeIds)])),
});
this.threadMutingsCache = new QuantumKVCache<Set<string>>(this.internalEventService, 'threadMutings', {
lifetime: 1000 * 60 * 30, // 30m
fetcher: muterId => this.noteThreadMutingsRepository
.find({ where: { userId: muterId, isPostMute: false }, select: { threadId: true } })
.then(ms => new Set(ms.map(m => m.threadId))),
bulkFetcher: muterIds => this.noteThreadMutingsRepository
.createQueryBuilder('muting')
.select('"muting"."userId"', 'userId')
.addSelect('array_agg("muting"."threadId")', 'threadIds')
.groupBy('"muting"."userId"')
.where({ userId: In(muterIds), isPostMute: false })
.getRawMany<{ userId: string, threadIds: string[] }>()
.then(ms => ms.map(m => [m.userId, new Set(m.threadIds)])),
});
this.noteMutingsCache = new QuantumKVCache<Set<string>>(this.internalEventService, 'noteMutings', {
lifetime: 1000 * 60 * 30, // 30m
fetcher: muterId => this.noteThreadMutingsRepository
.find({ where: { userId: muterId, isPostMute: true }, select: { threadId: true } })
.then(ms => new Set(ms.map(m => m.threadId))),
bulkFetcher: muterIds => this.noteThreadMutingsRepository
.createQueryBuilder('muting')
.select('"muting"."userId"', 'userId')
.addSelect('array_agg("muting"."threadId")', 'threadIds')
.groupBy('"muting"."userId"')
.where({ userId: In(muterIds), isPostMute: true })
.getRawMany<{ userId: string, threadIds: string[] }>()
.then(ms => ms.map(m => [m.userId, new Set(m.threadIds)])),
});
this.userFollowingsCache = new QuantumKVCache<Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>(this.internalEventService, 'userFollowings', {
lifetime: 1000 * 60 * 30, // 30m
fetcher: (key) => this.followingsRepository.findBy({ followerId: key }).then(xs => new Map(xs.map(f => [f.followeeId, f]))),
@ -272,6 +307,8 @@ export class CacheService implements OnApplicationShutdown {
this.userFollowingsCache.delete(body.id),
this.userFollowersCache.delete(body.id),
this.hibernatedUserCache.delete(body.id),
this.threadMutingsCache.delete(body.id),
this.noteMutingsCache.delete(body.id),
]);
}
} else {
@ -542,7 +579,11 @@ export class CacheService implements OnApplicationShutdown {
this.userBlockingCache.dispose();
this.userBlockedCache.dispose();
this.renoteMutingsCache.dispose();
this.threadMutingsCache.dispose();
this.noteMutingsCache.dispose();
this.userFollowingsCache.dispose();
this.userFollowersCache.dispose();
this.hibernatedUserCache.dispose();
}
@bindThis

View file

@ -164,7 +164,7 @@ export class DriveService {
try {
await this.videoProcessingService.webOptimizeVideo(path, type);
} catch (err) {
this.registerLogger.warn(`Video optimization failed: ${err instanceof Error ? err.message : String(err)}`, { error: err });
this.registerLogger.warn(`Video optimization failed: ${renderInlineError(err)}`);
}
}
@ -367,7 +367,7 @@ export class DriveService {
this.registerLogger.debug('web image not created (not an required image)');
}
} catch (err) {
this.registerLogger.warn('web image not created (an error occurred)', err as Error);
this.registerLogger.warn(`web image not created: ${renderInlineError(err)}`);
}
} else {
if (satisfyWebpublic) this.registerLogger.debug('web image not created (original satisfies webpublic)');
@ -386,7 +386,7 @@ export class DriveService {
thumbnail = await this.imageProcessingService.convertSharpToWebp(img, 498, 422);
}
} catch (err) {
this.registerLogger.warn('thumbnail not created (an error occurred)', err as Error);
this.registerLogger.warn(`Error creating thumbnail: ${renderInlineError(err)}`);
}
// #endregion thumbnail
@ -420,27 +420,21 @@ export class DriveService {
);
if (this.meta.objectStorageSetPublicRead) params.ACL = 'public-read';
if (this.bunnyService.usingBunnyCDN(this.meta)) {
await this.bunnyService.upload(this.meta, key, stream).catch(
err => {
this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}`, err);
},
);
} else {
await this.s3Service.upload(this.meta, params)
.then(
result => {
if ('Bucket' in result) { // CompleteMultipartUploadCommandOutput
this.registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`);
} else { // AbortMultipartUploadCommandOutput
this.registerLogger.error(`Upload Result Aborted: key = ${key}, filename = ${filename}`);
}
})
.catch(
err => {
this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}`, err);
},
);
try {
if (this.bunnyService.usingBunnyCDN(this.meta)) {
await this.bunnyService.upload(this.meta, key, stream);
} else {
const result = await this.s3Service.upload(this.meta, params);
if ('Bucket' in result) { // CompleteMultipartUploadCommandOutput
this.registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`);
} else { // AbortMultipartUploadCommandOutput
this.registerLogger.error(`Upload Result Aborted: key = ${key}, filename = ${filename}`);
throw new Error('S3 upload aborted');
}
}
} catch (err) {
this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}: ${renderInlineError(err)}`);
throw err;
}
}
@ -857,7 +851,7 @@ export class DriveService {
}
} catch (err: any) {
if (err.name === 'NoSuchKey') {
this.deleteLogger.warn(`The object storage had no such key to delete: ${key}. Skipping this.`, err as Error);
this.deleteLogger.warn(`The object storage had no such key to delete: ${key}. Skipping this.`);
return;
} else {
throw new Error(`Failed to delete the file from the object storage with the given key: ${key}`, {

View file

@ -37,6 +37,7 @@ type TimelineOptions = {
excludeReplies?: boolean;
excludeBots?: boolean;
excludePureRenotes: boolean;
ignoreAuthorFromUserSuspension?: boolean;
dbFallback: (untilId: string | null, sinceId: string | null, limit: number) => Promise<MiNote[]>,
};
@ -145,6 +146,23 @@ export class FanoutTimelineEndpointService {
};
}
{
const parentFilter = filter;
filter = (note) => {
const noteJoined = note as MiNote & {
renoteUser: MiUser | null;
replyUser: MiUser | null;
};
if (!ps.ignoreAuthorFromUserSuspension) {
if (note.user!.isSuspended) return false;
}
if (note.userId !== note.renoteUserId && noteJoined.renoteUser?.isSuspended) return false;
if (note.userId !== note.replyUserId && noteJoined.replyUser?.isSuspended) return false;
return parentFilter(note);
};
}
const redisTimeline: MiNote[] = [];
let readFromRedis = 0;
let lastSuccessfulRate = 1; // rateをキャッシュする

View file

@ -7,7 +7,7 @@ import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import tinycolor from 'tinycolor2';
import * as Redis from 'ioredis';
import { load as cheerio } from 'cheerio';
import { load as cheerio } from 'cheerio/slim';
import type { MiInstance } from '@/models/Instance.js';
import type Logger from '@/logger.js';
import { DI } from '@/di-symbols.js';
@ -16,7 +16,7 @@ import { HttpRequestService } from '@/core/HttpRequestService.js';
import { bindThis } from '@/decorators.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { renderInlineError } from '@/misc/render-inline-error.js';
import type { CheerioAPI } from 'cheerio';
import type { CheerioAPI } from 'cheerio/slim';
type NodeInfo = {
openRegistrations?: unknown;

View file

@ -5,25 +5,22 @@
import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import * as parse5 from 'parse5';
import { type Document, type HTMLParagraphElement, Window } from 'happy-dom';
import { isText, isTag, Text } from 'domhandler';
import * as htmlparser2 from 'htmlparser2';
import { Node, Document, ChildNode, Element, ParentNode } from 'domhandler';
import * as domserializer from 'dom-serializer';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { intersperse } from '@/misc/prelude/array.js';
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import type { IMentionedRemoteUsers } from '@/models/Note.js';
import { bindThis } from '@/decorators.js';
import type { DefaultTreeAdapterMap } from 'parse5';
import type * as mfm from '@transfem-org/sfm-js';
const treeAdapter = parse5.defaultTreeAdapter;
type Node = DefaultTreeAdapterMap['node'];
type ChildNode = DefaultTreeAdapterMap['childNode'];
import type * as mfm from 'mfm-js';
const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/;
const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/;
export type Appender = (document: Document, body: HTMLParagraphElement) => void;
export type Appender = (document: Document, body: Element) => void;
@Injectable()
export class MfmService {
@ -40,7 +37,7 @@ export class MfmService {
const normalizedHashtagNames = hashtagNames == null ? undefined : new Set<string>(hashtagNames.map(x => normalizeForSearch(x)));
const dom = parse5.parseFragment(html);
const dom = htmlparser2.parseDocument(html);
let text = '';
@ -51,57 +48,50 @@ export class MfmService {
return text.trim();
function getText(node: Node): string {
if (treeAdapter.isTextNode(node)) return node.value;
if (!treeAdapter.isElementNode(node)) return '';
if (node.nodeName === 'br') return '\n';
if (isText(node)) return node.data;
if (!isTag(node)) return '';
if (node.tagName === 'br') return '\n';
if (node.childNodes) {
return node.childNodes.map(n => getText(n)).join('');
}
return '';
return node.childNodes.map(n => getText(n)).join('');
}
function appendChildren(childNodes: ChildNode[]): void {
if (childNodes) {
for (const n of childNodes) {
analyze(n);
}
for (const n of childNodes) {
analyze(n);
}
}
function analyze(node: Node) {
if (treeAdapter.isTextNode(node)) {
text += node.value;
if (isText(node)) {
text += node.data;
return;
}
// Skip comment or document type node
if (!treeAdapter.isElementNode(node)) {
if (!isTag(node)) {
return;
}
switch (node.nodeName) {
switch (node.tagName) {
case 'br': {
text += '\n';
break;
return;
}
case 'a': {
const txt = getText(node);
const rel = node.attrs.find(x => x.name === 'rel');
const href = node.attrs.find(x => x.name === 'href');
const rel = node.attribs.rel;
const href = node.attribs.href;
// ハッシュタグ
if (normalizedHashtagNames && href && normalizedHashtagNames.has(normalizeForSearch(txt))) {
text += txt;
// メンション
} else if (txt.startsWith('@') && !(rel && rel.value.startsWith('me '))) {
} else if (txt.startsWith('@') && !(rel && rel.startsWith('me '))) {
const part = txt.split('@');
if (part.length === 2 && href) {
//#region ホスト名部分が省略されているので復元する
const acct = `${txt}@${(new URL(href.value)).hostname}`;
const acct = `${txt}@${(new URL(href)).hostname}`;
text += acct;
//#endregion
} else if (part.length === 3) {
@ -116,25 +106,32 @@ export class MfmService {
if (!href) {
return txt;
}
if (!txt || txt === href.value) { // #6383: Missing text node
if (href.value.match(urlRegexFull)) {
return href.value;
if (!txt || txt === href) { // #6383: Missing text node
if (href.match(urlRegexFull)) {
return href;
} else {
return `<${href.value}>`;
return `<${href}>`;
}
}
if (href.value.match(urlRegex) && !href.value.match(urlRegexFull)) {
return `[${txt}](<${href.value}>)`; // #6846
if (href.match(urlRegex) && !href.match(urlRegexFull)) {
return `[${txt}](<${href}>)`; // #6846
} else {
return `[${txt}](${href.value})`;
return `[${txt}](${href})`;
}
};
text += generateLink();
}
break;
return;
}
}
// Don't produce invalid empty MFM
if (node.childNodes.length < 1) {
return;
}
switch (node.tagName) {
case 'h1': {
text += '**【';
appendChildren(node.childNodes);
@ -185,14 +182,17 @@ export class MfmService {
case 'ruby--': {
let ruby: [string, string][] = [];
for (const child of node.childNodes) {
if (child.nodeName === 'rp') {
if (isText(child) && !/\s|\[|\]/.test(child.data)) {
ruby.push([child.data, '']);
continue;
}
if (treeAdapter.isTextNode(child) && !/\s|\[|\]/.test(child.value)) {
ruby.push([child.value, '']);
if (!isTag(child)) {
continue;
}
if (child.nodeName === 'rt' && ruby.length > 0) {
if (child.tagName === 'rp') {
continue;
}
if (child.tagName === 'rt' && ruby.length > 0) {
const rt = getText(child);
if (/\s|\[|\]/.test(rt)) {
// If any space is included in rt, it is treated as a normal text
@ -217,7 +217,7 @@ export class MfmService {
// block code (<pre><code>)
case 'pre': {
if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') {
if (node.childNodes.length === 1 && isTag(node.childNodes[0]) && node.childNodes[0].tagName === 'code') {
text += '\n```\n';
text += getText(node.childNodes[0]);
text += '\n```\n';
@ -302,17 +302,17 @@ export class MfmService {
let nonRtNodes = [];
// scan children, ignore `rp`, split on `rt`
for (const child of node.childNodes) {
if (treeAdapter.isTextNode(child)) {
if (isText(child)) {
nonRtNodes.push(child);
continue;
}
if (!treeAdapter.isElementNode(child)) {
if (!isTag(child)) {
continue;
}
if (child.nodeName === 'rp') {
if (child.tagName === 'rp') {
continue;
}
if (child.nodeName === 'rt') {
if (child.tagName === 'rt') {
// the only case in which we don't need a `$[group ]`
// is when both sides of the ruby are simple words
const needsGroup = nonRtNodes.length > 1 ||
@ -335,6 +335,38 @@ export class MfmService {
break;
}
// Replace iframe with link so we can generate previews.
// We shouldn't normally see this, but federated blogging platforms (WordPress, MicroBlog.Pub) can send it.
case 'iframe': {
const txt: string | undefined = node.attribs.title || node.attribs.alt;
const href: string | undefined = node.attribs.src;
if (href) {
if (href.match(/[\s>]/)) {
if (txt) {
// href is invalid + has a label => render a pseudo-link
text += `${text} (${href})`;
} else {
// href is invalid + no label => render plain text
text += href;
}
} else {
if (txt) {
// href is valid + has a label => render a link
const label = txt
.replaceAll('[', '(')
.replaceAll(']', ')')
.replaceAll(/\r?\n/, ' ')
.replaceAll('`', '\'');
text += `[${label}](<${href}>)`;
} else {
// href is valid + no label => render a plain URL
text += `<${href}>`;
}
}
}
break;
}
default: // includes inline elements
{
appendChildren(node.childNodes);
@ -350,45 +382,44 @@ export class MfmService {
return null;
}
const { happyDOM, window } = new Window();
const doc = new Document([]);
const doc = window.document;
const body = new Element('p', {});
doc.childNodes.push(body);
const body = doc.createElement('p');
function appendChildren(children: mfm.MfmNode[], targetElement: any): void {
if (children) {
for (const child of children.map(x => (handlers as any)[x.type](x))) targetElement.appendChild(child);
function appendChildren(children: mfm.MfmNode[], targetElement: ParentNode): void {
for (const child of children.map(x => handle(x))) {
targetElement.childNodes.push(child);
}
}
function fnDefault(node: mfm.MfmFn) {
const el = doc.createElement('i');
const el = new Element('i', {});
appendChildren(node.children, el);
return el;
}
const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => any } = {
const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => ChildNode } = {
bold: (node) => {
const el = doc.createElement('b');
const el = new Element('b', {});
appendChildren(node.children, el);
return el;
},
small: (node) => {
const el = doc.createElement('small');
const el = new Element('small', {});
appendChildren(node.children, el);
return el;
},
strike: (node) => {
const el = doc.createElement('del');
const el = new Element('del', {});
appendChildren(node.children, el);
return el;
},
italic: (node) => {
const el = doc.createElement('i');
const el = new Element('i', {});
appendChildren(node.children, el);
return el;
},
@ -399,11 +430,12 @@ export class MfmService {
const text = node.children[0].type === 'text' ? node.children[0].props.text : '';
try {
const date = new Date(parseInt(text, 10) * 1000);
const el = doc.createElement('time');
el.setAttribute('datetime', date.toISOString());
el.textContent = date.toISOString();
const el = new Element('time', {
datetime: date.toISOString(),
});
el.childNodes.push(new Text(date.toISOString()));
return el;
} catch (err) {
} catch {
return fnDefault(node);
}
}
@ -412,20 +444,20 @@ export class MfmService {
if (node.children.length === 1) {
const child = node.children[0];
const text = child.type === 'text' ? child.props.text : '';
const rubyEl = doc.createElement('ruby');
const rtEl = doc.createElement('rt');
const rubyEl = new Element('ruby', {});
const rtEl = new Element('rt', {});
// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備りゅうび」となるようにする
const rpStartEl = doc.createElement('rp');
rpStartEl.appendChild(doc.createTextNode('('));
const rpEndEl = doc.createElement('rp');
rpEndEl.appendChild(doc.createTextNode(')'));
const rpStartEl = new Element('rp', {});
rpStartEl.childNodes.push(new Text('('));
const rpEndEl = new Element('rp', {});
rpEndEl.childNodes.push(new Text(')'));
rubyEl.appendChild(doc.createTextNode(text.split(' ')[0]));
rtEl.appendChild(doc.createTextNode(text.split(' ')[1]));
rubyEl.appendChild(rpStartEl);
rubyEl.appendChild(rtEl);
rubyEl.appendChild(rpEndEl);
rubyEl.childNodes.push(new Text(text.split(' ')[0]));
rtEl.childNodes.push(new Text(text.split(' ')[1]));
rubyEl.childNodes.push(rpStartEl);
rubyEl.childNodes.push(rtEl);
rubyEl.childNodes.push(rpEndEl);
return rubyEl;
} else {
const rt = node.children.at(-1);
@ -435,20 +467,20 @@ export class MfmService {
}
const text = rt.type === 'text' ? rt.props.text : '';
const rubyEl = doc.createElement('ruby');
const rtEl = doc.createElement('rt');
const rubyEl = new Element('ruby', {});
const rtEl = new Element('rt', {});
// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備りゅうび」となるようにする
const rpStartEl = doc.createElement('rp');
rpStartEl.appendChild(doc.createTextNode('('));
const rpEndEl = doc.createElement('rp');
rpEndEl.appendChild(doc.createTextNode(')'));
const rpStartEl = new Element('rp', {});
rpStartEl.childNodes.push(new Text('('));
const rpEndEl = new Element('rp', {});
rpEndEl.childNodes.push(new Text(')'));
appendChildren(node.children.slice(0, node.children.length - 1), rubyEl);
rtEl.appendChild(doc.createTextNode(text.trim()));
rubyEl.appendChild(rpStartEl);
rubyEl.appendChild(rtEl);
rubyEl.appendChild(rpEndEl);
rtEl.childNodes.push(new Text(text.trim()));
rubyEl.childNodes.push(rpStartEl);
rubyEl.childNodes.push(rtEl);
rubyEl.childNodes.push(rpEndEl);
return rubyEl;
}
}
@ -456,7 +488,7 @@ export class MfmService {
// hack for ruby, should never be needed because we should
// never send this out to other instances
case 'group': {
const el = doc.createElement('span');
const el = new Element('span', {});
appendChildren(node.children, el);
return el;
}
@ -468,125 +500,135 @@ export class MfmService {
},
blockCode: (node) => {
const pre = doc.createElement('pre');
const inner = doc.createElement('code');
inner.textContent = node.props.code;
pre.appendChild(inner);
const pre = new Element('pre', {});
const inner = new Element('code', {});
inner.childNodes.push(new Text(node.props.code));
pre.childNodes.push(inner);
return pre;
},
center: (node) => {
const el = doc.createElement('div');
const el = new Element('div', {});
appendChildren(node.children, el);
return el;
},
emojiCode: (node) => {
return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
return new Text(`\u200B:${node.props.name}:\u200B`);
},
unicodeEmoji: (node) => {
return doc.createTextNode(node.props.emoji);
return new Text(node.props.emoji);
},
hashtag: (node) => {
const a = doc.createElement('a');
a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`);
a.textContent = `#${node.props.hashtag}`;
a.setAttribute('rel', 'tag');
const a = new Element('a', {
href: `${this.config.url}/tags/${node.props.hashtag}`,
rel: 'tag',
});
a.childNodes.push(new Text(`#${node.props.hashtag}`));
return a;
},
inlineCode: (node) => {
const el = doc.createElement('code');
el.textContent = node.props.code;
const el = new Element('code', {});
el.childNodes.push(new Text(node.props.code));
return el;
},
mathInline: (node) => {
const el = doc.createElement('code');
el.textContent = node.props.formula;
const el = new Element('code', {});
el.childNodes.push(new Text(node.props.formula));
return el;
},
mathBlock: (node) => {
const el = doc.createElement('code');
el.textContent = node.props.formula;
const el = new Element('code', {});
el.childNodes.push(new Text(node.props.formula));
return el;
},
link: (node) => {
const a = doc.createElement('a');
a.setAttribute('href', node.props.url);
const a = new Element('a', {
href: node.props.url,
});
appendChildren(node.children, a);
return a;
},
mention: (node) => {
const a = doc.createElement('a');
const { username, host, acct } = node.props;
const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username.toLowerCase() === username.toLowerCase() && remoteUser.host?.toLowerCase() === host?.toLowerCase());
a.setAttribute('href', remoteUserInfo
? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri)
: `${this.config.url}/${acct.endsWith(`@${this.config.url}`) ? acct.substring(0, acct.length - this.config.url.length - 1) : acct}`);
a.className = 'u-url mention';
a.textContent = acct;
const a = new Element('a', {
href: remoteUserInfo
? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri)
: `${this.config.url}/${acct.endsWith(`@${this.config.url}`) ? acct.substring(0, acct.length - this.config.url.length - 1) : acct}`,
class: 'u-url mention',
});
a.childNodes.push(new Text(acct));
return a;
},
quote: (node) => {
const el = doc.createElement('blockquote');
const el = new Element('blockquote', {});
appendChildren(node.children, el);
return el;
},
text: (node) => {
if (!node.props.text.match(/[\r\n]/)) {
return doc.createTextNode(node.props.text);
return new Text(node.props.text);
}
const el = doc.createElement('span');
const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => doc.createTextNode(x));
const el = new Element('span', {});
const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => new Text(x));
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
el.appendChild(x === 'br' ? doc.createElement('br') : x);
el.childNodes.push(x === 'br' ? new Element('br', {}) : x);
}
return el;
},
url: (node) => {
const a = doc.createElement('a');
a.setAttribute('href', node.props.url);
a.textContent = node.props.url;
const a = new Element('a', {
href: node.props.url,
});
a.childNodes.push(new Text(node.props.url));
return a;
},
search: (node) => {
const a = doc.createElement('a');
a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`);
a.textContent = node.props.content;
const a = new Element('a', {
href: `https://www.google.com/search?q=${node.props.query}`,
});
a.childNodes.push(new Text(node.props.content));
return a;
},
plain: (node) => {
const el = doc.createElement('span');
const el = new Element('span', {});
appendChildren(node.children, el);
return el;
},
};
// Utility function to make TypeScript behave
function handle<T extends mfm.MfmNode>(node: T): ChildNode {
const handler = handlers[node.type] as (node: T) => ChildNode;
return handler(node);
}
appendChildren(nodes, body);
for (const additionalAppender of additionalAppenders) {
additionalAppender(doc, body);
}
const serialized = body.outerHTML;
happyDOM.close().catch(err => {});
return serialized;
return domserializer.render(body, {
encodeEntities: 'utf8'
});
}
// the toMastoApiHtml function was taken from Iceshrimp and written by zotan and modified by marie to work with the current MK version
@ -598,55 +640,55 @@ export class MfmService {
return null;
}
const { happyDOM, window } = new Window();
const doc = new Document([]);
const doc = window.document;
const body = new Element('p', {});
doc.childNodes.push(body);
const body = doc.createElement('p');
function appendChildren(children: mfm.MfmNode[], targetElement: any): void {
if (children) {
for (const child of children.map((x) => (handlers as any)[x.type](x))) targetElement.appendChild(child);
function appendChildren(children: mfm.MfmNode[], targetElement: ParentNode): void {
for (const child of children) {
const result = handle(child);
targetElement.childNodes.push(result);
}
}
const handlers: {
[K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => any;
[K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => ChildNode;
} = {
bold(node) {
const el = doc.createElement('span');
el.textContent = '**';
const el = new Element('span', {});
el.childNodes.push(new Text('**'));
appendChildren(node.children, el);
el.textContent += '**';
el.childNodes.push(new Text('**'));
return el;
},
small(node) {
const el = doc.createElement('small');
const el = new Element('small', {});
appendChildren(node.children, el);
return el;
},
strike(node) {
const el = doc.createElement('span');
el.textContent = '~~';
const el = new Element('span', {});
el.childNodes.push(new Text('~~'));
appendChildren(node.children, el);
el.textContent += '~~';
el.childNodes.push(new Text('~~'));
return el;
},
italic(node) {
const el = doc.createElement('span');
el.textContent = '*';
const el = new Element('span', {});
el.childNodes.push(new Text('*'));
appendChildren(node.children, el);
el.textContent += '*';
el.childNodes.push(new Text('*'));
return el;
},
fn(node) {
switch (node.props.name) {
case 'group': { // hack for ruby
const el = doc.createElement('span');
const el = new Element('span', {});
appendChildren(node.children, el);
return el;
}
@ -654,119 +696,121 @@ export class MfmService {
if (node.children.length === 1) {
const child = node.children[0];
const text = child.type === 'text' ? child.props.text : '';
const rubyEl = doc.createElement('ruby');
const rtEl = doc.createElement('rt');
const rubyEl = new Element('ruby', {});
const rtEl = new Element('rt', {});
const rpStartEl = doc.createElement('rp');
rpStartEl.appendChild(doc.createTextNode('('));
const rpEndEl = doc.createElement('rp');
rpEndEl.appendChild(doc.createTextNode(')'));
const rpStartEl = new Element('rp', {});
rpStartEl.childNodes.push(new Text('('));
const rpEndEl = new Element('rp', {});
rpEndEl.childNodes.push(new Text(')'));
rubyEl.appendChild(doc.createTextNode(text.split(' ')[0]));
rtEl.appendChild(doc.createTextNode(text.split(' ')[1]));
rubyEl.appendChild(rpStartEl);
rubyEl.appendChild(rtEl);
rubyEl.appendChild(rpEndEl);
rubyEl.childNodes.push(new Text(text.split(' ')[0]));
rtEl.childNodes.push(new Text(text.split(' ')[1]));
rubyEl.childNodes.push(rpStartEl);
rubyEl.childNodes.push(rtEl);
rubyEl.childNodes.push(rpEndEl);
return rubyEl;
} else {
const rt = node.children.at(-1);
if (!rt) {
const el = doc.createElement('span');
const el = new Element('span', {});
appendChildren(node.children, el);
return el;
}
const text = rt.type === 'text' ? rt.props.text : '';
const rubyEl = doc.createElement('ruby');
const rtEl = doc.createElement('rt');
const rubyEl = new Element('ruby', {});
const rtEl = new Element('rt', {});
const rpStartEl = doc.createElement('rp');
rpStartEl.appendChild(doc.createTextNode('('));
const rpEndEl = doc.createElement('rp');
rpEndEl.appendChild(doc.createTextNode(')'));
const rpStartEl = new Element('rp', {});
rpStartEl.childNodes.push(new Text('('));
const rpEndEl = new Element('rp', {});
rpEndEl.childNodes.push(new Text(')'));
appendChildren(node.children.slice(0, node.children.length - 1), rubyEl);
rtEl.appendChild(doc.createTextNode(text.trim()));
rubyEl.appendChild(rpStartEl);
rubyEl.appendChild(rtEl);
rubyEl.appendChild(rpEndEl);
rtEl.childNodes.push(new Text(text.trim()));
rubyEl.childNodes.push(rpStartEl);
rubyEl.childNodes.push(rtEl);
rubyEl.childNodes.push(rpEndEl);
return rubyEl;
}
}
default: {
const el = doc.createElement('span');
el.textContent = '*';
const el = new Element('span', {});
el.childNodes.push(new Text('*'));
appendChildren(node.children, el);
el.textContent += '*';
el.childNodes.push(new Text('*'));
return el;
}
}
},
blockCode(node) {
const pre = doc.createElement('pre');
const inner = doc.createElement('code');
const pre = new Element('pre', {});
const inner = new Element('code', {});
const nodes = node.props.code
.split(/\r\n|\r|\n/)
.map((x) => doc.createTextNode(x));
.map((x) => new Text(x));
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
inner.appendChild(x === 'br' ? doc.createElement('br') : x);
inner.childNodes.push(x === 'br' ? new Element('br', {}) : x);
}
pre.appendChild(inner);
pre.childNodes.push(inner);
return pre;
},
center(node) {
const el = doc.createElement('div');
const el = new Element('div', {});
appendChildren(node.children, el);
return el;
},
emojiCode(node) {
return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
return new Text(`\u200B:${node.props.name}:\u200B`);
},
unicodeEmoji(node) {
return doc.createTextNode(node.props.emoji);
return new Text(node.props.emoji);
},
hashtag: (node) => {
const a = doc.createElement('a');
a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`);
a.textContent = `#${node.props.hashtag}`;
a.setAttribute('rel', 'tag');
a.setAttribute('class', 'hashtag');
const a = new Element('a', {
href: `${this.config.url}/tags/${node.props.hashtag}`,
rel: 'tag',
class: 'hashtag',
});
a.childNodes.push(new Text(`#${node.props.hashtag}`));
return a;
},
inlineCode(node) {
const el = doc.createElement('code');
el.textContent = node.props.code;
const el = new Element('code', {});
el.childNodes.push(new Text(node.props.code));
return el;
},
mathInline(node) {
const el = doc.createElement('code');
el.textContent = node.props.formula;
const el = new Element('code', {});
el.childNodes.push(new Text(node.props.formula));
return el;
},
mathBlock(node) {
const el = doc.createElement('code');
el.textContent = node.props.formula;
const el = new Element('code', {});
el.childNodes.push(new Text(node.props.formula));
return el;
},
link(node) {
const a = doc.createElement('a');
a.setAttribute('rel', 'nofollow noopener noreferrer');
a.setAttribute('target', '_blank');
a.setAttribute('href', node.props.url);
const a = new Element('a', {
rel: 'nofollow noopener noreferrer',
target: '_blank',
href: node.props.url,
});
appendChildren(node.children, a);
return a;
},
@ -775,92 +819,107 @@ export class MfmService {
const { username, host, acct } = node.props;
const resolved = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
const el = doc.createElement('span');
const el = new Element('span', {});
if (!resolved) {
el.textContent = acct;
el.childNodes.push(new Text(acct));
} else {
el.setAttribute('class', 'h-card');
el.setAttribute('translate', 'no');
const a = doc.createElement('a');
a.setAttribute('href', resolved.url ? resolved.url : resolved.uri);
a.className = 'u-url mention';
const span = doc.createElement('span');
span.textContent = resolved.username || username;
a.textContent = '@';
a.appendChild(span);
el.appendChild(a);
el.attribs.class = 'h-card';
el.attribs.translate = 'no';
const a = new Element('a', {
href: resolved.url ? resolved.url : resolved.uri,
class: 'u-url mention',
});
const span = new Element('span', {});
span.childNodes.push(new Text(resolved.username || username));
a.childNodes.push(new Text('@'));
a.childNodes.push(span);
el.childNodes.push(a);
}
return el;
},
quote(node) {
const el = doc.createElement('blockquote');
const el = new Element('blockquote', {});
appendChildren(node.children, el);
return el;
},
text(node) {
const el = doc.createElement('span');
if (!node.props.text.match(/[\r\n]/)) {
return new Text(node.props.text);
}
const el = new Element('span', {});
const nodes = node.props.text
.split(/\r\n|\r|\n/)
.map((x) => doc.createTextNode(x));
.map((x) => new Text(x));
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
el.appendChild(x === 'br' ? doc.createElement('br') : x);
el.childNodes.push(x === 'br' ? new Element('br', {}) : x);
}
return el;
},
url(node) {
const a = doc.createElement('a');
a.setAttribute('rel', 'nofollow noopener noreferrer');
a.setAttribute('target', '_blank');
a.setAttribute('href', node.props.url);
a.textContent = node.props.url.replace(/^https?:\/\//, '');
const a = new Element('a', {
rel: 'nofollow noopener noreferrer',
target: '_blank',
href: node.props.url,
});
a.childNodes.push(new Text(node.props.url.replace(/^https?:\/\//, '')));
return a;
},
search: (node) => {
const a = doc.createElement('a');
a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`);
a.textContent = node.props.content;
const a = new Element('a', {
href: `https://www.google.com/search?q=${node.props.query}`,
});
a.childNodes.push(new Text(node.props.content));
return a;
},
plain(node) {
const el = doc.createElement('span');
const el = new Element('span', {});
appendChildren(node.children, el);
return el;
},
};
// Utility function to make TypeScript behave
function handle<T extends mfm.MfmNode>(node: T): ChildNode {
const handler = handlers[node.type] as (node: T) => ChildNode;
return handler(node);
}
appendChildren(nodes, body);
if (quoteUri !== null) {
const a = doc.createElement('a');
a.setAttribute('href', quoteUri);
a.textContent = quoteUri.replace(/^https?:\/\//, '');
const a = new Element('a', {
href: quoteUri,
});
a.childNodes.push(new Text(quoteUri.replace(/^https?:\/\//, '')));
const quote = doc.createElement('span');
quote.setAttribute('class', 'quote-inline');
quote.appendChild(doc.createElement('br'));
quote.appendChild(doc.createElement('br'));
quote.innerHTML += 'RE: ';
quote.appendChild(a);
const quote = new Element('span', {
class: 'quote-inline',
});
quote.childNodes.push(new Element('br', {}));
quote.childNodes.push(new Element('br', {}));
quote.childNodes.push(new Text('RE: '));
quote.childNodes.push(a);
body.appendChild(quote);
body.childNodes.push(quote);
}
let result = body.outerHTML;
let result = domserializer.render(body, {
encodeEntities: 'utf8'
});
if (inline) {
result = result.replace(/^<p>/, '').replace(/<\/p>$/, '');
}
happyDOM.close().catch(() => {});
return result;
}
}

View file

@ -4,7 +4,7 @@
*/
import { setImmediate } from 'node:timers/promises';
import * as mfm from '@transfem-org/sfm-js';
import * as mfm from 'mfm-js';
import { In, DataSource, IsNull, LessThan } from 'typeorm';
import * as Redis from 'ioredis';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
@ -676,18 +676,15 @@ export class NoteCreateService implements OnApplicationShutdown {
});
// 通知
if (data.reply.userHost === null) {
const isThreadMuted = await this.noteThreadMutingsRepository.exists({
where: {
userId: data.reply.userId,
threadId: data.reply.threadId ?? data.reply.id,
},
});
const threadId = data.reply.threadId ?? data.reply.id;
const [
isThreadMuted,
userIdsWhoMeMuting,
] = data.reply.userId ? await Promise.all([
] = await Promise.all([
this.cacheService.threadMutingsCache.fetch(data.reply.userId).then(ms => ms.has(threadId)),
this.cacheService.userMutingsCache.fetch(data.reply.userId),
]) : [new Set<string>()];
]);
const muted = isUserRelated(note, userIdsWhoMeMuting);
@ -705,14 +702,17 @@ export class NoteCreateService implements OnApplicationShutdown {
// Notify
if (data.renote.userHost === null) {
const isThreadMuted = await this.noteThreadMutingsRepository.exists({
where: {
userId: data.renote.userId,
threadId: data.renote.threadId ?? data.renote.id,
},
});
const threadId = data.renote.threadId ?? data.renote.id;
const muted = data.renote.userId && isUserRelated(note, await this.cacheService.userMutingsCache.fetch(data.renote.userId));
const [
isThreadMuted,
userIdsWhoMeMuting,
] = await Promise.all([
this.cacheService.threadMutingsCache.fetch(data.renote.userId).then(ms => ms.has(threadId)),
this.cacheService.userMutingsCache.fetch(data.renote.userId),
]);
const muted = data.renote.userId && isUserRelated(note, userIdsWhoMeMuting);
if (!isThreadMuted && !muted) {
nm.push(data.renote.userId, type);
@ -731,7 +731,7 @@ export class NoteCreateService implements OnApplicationShutdown {
//#region AP deliver
if (!data.localOnly && this.userEntityService.isLocalUser(user)) {
trackTask(async () => {
const noteActivity = await this.renderNoteOrRenoteActivity(data, note, user);
const noteActivity = await this.apRendererService.renderNoteOrRenoteActivity(note, user, { renote: data.renote });
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
// メンションされたリモートユーザーに配送
@ -842,18 +842,23 @@ export class NoteCreateService implements OnApplicationShutdown {
@bindThis
private async createMentionedEvents(mentionedUsers: MinimumUser[], note: MiNote, nm: NotificationManager) {
const [
threadMutings,
userMutings,
] = await Promise.all([
this.cacheService.threadMutingsCache.fetchMany(mentionedUsers.map(u => u.id)).then(ms => new Map(ms)),
this.cacheService.userMutingsCache.fetchMany(mentionedUsers.map(u => u.id)).then(ms => new Map(ms)),
]);
// Only create mention events for local users, and users for whom the note is visible
for (const u of mentionedUsers.filter(u => (note.visibility !== 'specified' || note.visibleUserIds.some(x => x === u.id)) && this.userEntityService.isLocalUser(u))) {
const isThreadMuted = await this.noteThreadMutingsRepository.exists({
where: {
userId: u.id,
threadId: note.threadId ?? note.id,
},
});
const threadId = note.threadId ?? note.id;
const isThreadMuted = threadMutings.get(u.id)?.has(threadId);
const muted = u.id && isUserRelated(note, await this.cacheService.userMutingsCache.fetch(u.id));
const mutings = userMutings.get(u.id);
const isUserMuted = mutings != null && isUserRelated(note, mutings);
if (isThreadMuted || muted) {
if (isThreadMuted || isUserMuted) {
continue;
}
@ -874,17 +879,6 @@ export class NoteCreateService implements OnApplicationShutdown {
this.notesRepository.increment({ id: reply.id }, 'repliesCount', 1);
}
@bindThis
private async renderNoteOrRenoteActivity(data: Option, note: MiNote, user: MiUser) {
if (data.localOnly) return null;
const content = this.isRenote(data) && !this.isQuote(data)
? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note)
: this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, user, false), note);
return this.apRendererService.addContext(content);
}
@bindThis
private index(note: MiNote) {
if (note.text == null && note.cw == null) return;
@ -964,6 +958,7 @@ export class NoteCreateService implements OnApplicationShutdown {
// TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
for (const following of followings) {
if (following.followerHost !== null) continue;
// 基本的にvisibleUserIdsには自身のidが含まれている前提であること
if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue;

View file

@ -4,7 +4,7 @@
*/
import { setImmediate } from 'node:timers/promises';
import * as mfm from '@transfem-org/sfm-js';
import * as mfm from 'mfm-js';
import { DataSource, In, IsNull, LessThan } from 'typeorm';
import * as Redis from 'ioredis';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
@ -647,18 +647,15 @@ export class NoteEditService implements OnApplicationShutdown {
if (data.reply) {
// 通知
if (data.reply.userHost === null) {
const isThreadMuted = await this.noteThreadMutingsRepository.exists({
where: {
userId: data.reply.userId,
threadId: data.reply.threadId ?? data.reply.id,
},
});
const threadId = data.reply.threadId ?? data.reply.id;
const [
isThreadMuted,
userIdsWhoMeMuting,
] = data.reply.userId ? await Promise.all([
] = await Promise.all([
this.cacheService.threadMutingsCache.fetch(data.reply.userId).then(ms => ms.has(threadId)),
this.cacheService.userMutingsCache.fetch(data.reply.userId),
]) : [new Set<string>()];
]);
const muted = isUserRelated(note, userIdsWhoMeMuting);
@ -675,7 +672,7 @@ export class NoteEditService implements OnApplicationShutdown {
//#region AP deliver
if (!data.localOnly && this.userEntityService.isLocalUser(user)) {
trackTask(async () => {
const noteActivity = await this.renderNoteOrRenoteActivity(data, note, user);
const noteActivity = await this.apRendererService.renderNoteOrRenoteActivity(note, user, { renote: data.renote });
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
// メンションされたリモートユーザーに配送
@ -770,17 +767,6 @@ export class NoteEditService implements OnApplicationShutdown {
(note.files != null && note.files.length > 0);
}
@bindThis
private async renderNoteOrRenoteActivity(data: Option, note: MiNote, user: MiUser) {
if (data.localOnly) return null;
const content = this.isRenote(data) && !this.isQuote(data)
? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note)
: this.apRendererService.renderUpdate(await this.apRendererService.renderUpNote(note, user, false), user);
return this.apRendererService.addContext(content);
}
@bindThis
private index(note: MiNote) {
if (note.text == null && note.cw == null) return;
@ -849,6 +835,7 @@ export class NoteEditService implements OnApplicationShutdown {
// TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
for (const following of followings) {
if (following.followerHost !== null) continue;
// 基本的にvisibleUserIdsには自身のidが含まれている前提であること
if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue;

View file

@ -47,29 +47,36 @@ export class QueryService {
) {
}
public makePaginationQuery<T extends ObjectLiteral>(q: SelectQueryBuilder<T>, sinceId?: string | null, untilId?: string | null, sinceDate?: number | null, untilDate?: number | null): SelectQueryBuilder<T> {
public makePaginationQuery<T extends ObjectLiteral>(
q: SelectQueryBuilder<T>,
sinceId?: string | null,
untilId?: string | null,
sinceDate?: number | null,
untilDate?: number | null,
targetColumn = 'id',
): SelectQueryBuilder<T> {
if (sinceId && untilId) {
q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId });
q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId });
q.orderBy(`${q.alias}.id`, 'DESC');
q.andWhere(`${q.alias}.${targetColumn} > :sinceId`, { sinceId: sinceId });
q.andWhere(`${q.alias}.${targetColumn} < :untilId`, { untilId: untilId });
q.orderBy(`${q.alias}.${targetColumn}`, 'DESC');
} else if (sinceId) {
q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId });
q.orderBy(`${q.alias}.id`, 'ASC');
q.andWhere(`${q.alias}.${targetColumn} > :sinceId`, { sinceId: sinceId });
q.orderBy(`${q.alias}.${targetColumn}`, 'ASC');
} else if (untilId) {
q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId });
q.orderBy(`${q.alias}.id`, 'DESC');
q.andWhere(`${q.alias}.${targetColumn} < :untilId`, { untilId: untilId });
q.orderBy(`${q.alias}.${targetColumn}`, 'DESC');
} else if (sinceDate && untilDate) {
q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: this.idService.gen(sinceDate) });
q.andWhere(`${q.alias}.id < :untilId`, { untilId: this.idService.gen(untilDate) });
q.orderBy(`${q.alias}.id`, 'DESC');
q.andWhere(`${q.alias}.${targetColumn} > :sinceId`, { sinceId: this.idService.gen(sinceDate) });
q.andWhere(`${q.alias}.${targetColumn} < :untilId`, { untilId: this.idService.gen(untilDate) });
q.orderBy(`${q.alias}.${targetColumn}`, 'DESC');
} else if (sinceDate) {
q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: this.idService.gen(sinceDate) });
q.orderBy(`${q.alias}.id`, 'ASC');
q.andWhere(`${q.alias}.${targetColumn} > :sinceId`, { sinceId: this.idService.gen(sinceDate) });
q.orderBy(`${q.alias}.${targetColumn}`, 'ASC');
} else if (untilDate) {
q.andWhere(`${q.alias}.id < :untilId`, { untilId: this.idService.gen(untilDate) });
q.orderBy(`${q.alias}.id`, 'DESC');
q.andWhere(`${q.alias}.${targetColumn} < :untilId`, { untilId: this.idService.gen(untilDate) });
q.orderBy(`${q.alias}.${targetColumn}`, 'DESC');
} else {
q.orderBy(`${q.alias}.id`, 'DESC');
q.orderBy(`${q.alias}.${targetColumn}`, 'DESC');
}
return q;
}
@ -557,4 +564,26 @@ export class QueryService {
return q[join](`NOT EXISTS (${threadMutedQuery.getQuery()})`, threadMutedQuery.getParameters());
}
// Requirements: user replyUser renoteUser must be joined
@bindThis
public generateSuspendedUserQueryForNote(q: SelectQueryBuilder<any>, excludeAuthor?: boolean): void {
if (excludeAuthor) {
const brakets = (user: string) => new Brackets(qb => qb
.where(`note.${user}Id IS NULL`)
.orWhere(`user.id = ${user}.id`)
.orWhere(`${user}.isSuspended = FALSE`));
q
.andWhere(brakets('replyUser'))
.andWhere(brakets('renoteUser'));
} else {
const brakets = (user: string) => new Brackets(qb => qb
.where(`note.${user}Id IS NULL`)
.orWhere(`${user}.isSuspended = FALSE`));
q
.andWhere('user.isSuspended = FALSE')
.andWhere(brakets('replyUser'))
.andWhere(brakets('renoteUser'));
}
}
}

View file

@ -9,6 +9,7 @@ import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { baseQueueOptions, QUEUE } from '@/queue/const.js';
import { allSettled } from '@/misc/promise-tracker.js';
import Logger from '@/logger.js';
import {
DeliverJobData,
EndedPollNotificationJobData,
@ -120,6 +121,8 @@ const $scheduleNotePost: Provider = {
],
})
export class QueueModule implements OnApplicationShutdown {
private readonly logger = new Logger('queue');
constructor(
@Inject('queue:system') public systemQueue: SystemQueue,
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
@ -135,8 +138,10 @@ export class QueueModule implements OnApplicationShutdown {
public async dispose(): Promise<void> {
// Wait for all potential queue jobs
this.logger.info('Finalizing active promises...');
await allSettled();
// And then close all queues
this.logger.info('Closing BullMQ queues...');
await Promise.all([
this.systemQueue.close(),
this.endedPollNotificationQueue.close(),
@ -149,6 +154,7 @@ export class QueueModule implements OnApplicationShutdown {
this.systemWebhookDeliverQueue.close(),
this.scheduleNotePostQueue.close(),
]);
this.logger.info('Queue module disposed.');
}
async onApplicationShutdown(signal: string): Promise<void> {

View file

@ -684,8 +684,11 @@ export class QueueService {
}
@bindThis
public createCleanRemoteFilesJob() {
return this.objectStorageQueue.add('cleanRemoteFiles', {}, {
public createCleanRemoteFilesJob(olderThanSeconds: number = 0, keepFilesInUse: boolean = false) {
return this.objectStorageQueue.add('cleanRemoteFiles', {
keepFilesInUse,
olderThanSeconds,
}, {
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,

View file

@ -275,12 +275,8 @@ export class ReactionService {
// リアクションされたユーザーがローカルユーザーなら通知を作成
if (note.userHost === null) {
const isThreadMuted = await this.noteThreadMutingsRepository.exists({
where: {
userId: note.userId,
threadId: note.threadId ?? note.id,
},
});
const threadId = note.threadId ?? note.id;
const isThreadMuted = await this.cacheService.threadMutingsCache.fetch(note.userId).then(ms => ms.has(threadId));
if (!isThreadMuted) {
this.notificationService.createNotification(note.userId, 'reaction', {

View file

@ -27,25 +27,9 @@ export class RegistryApiService {
public async set(userId: MiUser['id'], domain: string | null, scope: string[], key: string, value: any) {
// TODO: 作成できるキーの数を制限する
const query = this.registryItemsRepository.createQueryBuilder('item');
if (domain) {
query.where('item.domain = :domain', { domain: domain });
} else {
query.where('item.domain IS NULL');
}
query.andWhere('item.userId = :userId', { userId: userId });
query.andWhere('item.key = :key', { key: key });
query.andWhere('item.scope = :scope', { scope: scope });
const existingItem = await query.getOne();
if (existingItem) {
await this.registryItemsRepository.update(existingItem.id, {
updatedAt: new Date(),
value: value,
});
} else {
await this.registryItemsRepository.insert({
await this.registryItemsRepository.createQueryBuilder('item')
.insert()
.values({
id: this.idService.gen(),
updatedAt: new Date(),
userId: userId,
@ -53,8 +37,13 @@ export class RegistryApiService {
scope: scope,
key: key,
value: value,
});
}
})
.orUpdate(
['updatedAt', 'value'],
['userId', 'key', 'scope', 'domain'],
{ upsertType: 'on-conflict-do-update' }
)
.execute();
if (domain == null) {
// TODO: サードパーティアプリが傍受出来てしまうのでどうにかする

View file

@ -737,6 +737,17 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
}
}
@bindThis
public async clone(role: MiRole, moderator?: MiUser): Promise<MiRole> {
const suffix = ' (cloned)';
const newName = role.name.slice(0, 256 - suffix.length) + suffix;
return this.create({
...role,
name: newName,
}, moderator);
}
@bindThis
public async delete(role: MiRole, moderator?: MiUser): Promise<void> {
await this.rolesRepository.delete({ id: role.id });

View file

@ -301,6 +301,7 @@ export class SearchService {
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
@ -368,11 +369,17 @@ export class SearchService {
])
: [new Set<string>(), new Set<string>()];
const query = this.notesRepository.createQueryBuilder('note');
const query = this.notesRepository.createQueryBuilder('note')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
query.where('note.id IN (:...noteIds)', { noteIds: res.hits.map(x => x.id) });
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
const notes = (await query.getMany()).filter(note => {
if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;

View file

@ -6,7 +6,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { Not, IsNull } from 'typeorm';
import type { FollowingsRepository, FollowRequestsRepository, UsersRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js';
import { MiUser } from '@/models/User.js';
import { QueueService } from '@/core/QueueService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
@ -17,9 +17,15 @@ import { RelationshipJobData } from '@/queue/types.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { isSystemAccount } from '@/misc/is-system-account.js';
import { CacheService } from '@/core/CacheService.js';
import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js';
import { renderInlineError } from '@/misc/render-inline-error.js';
import { trackPromise } from '@/misc/promise-tracker.js';
@Injectable()
export class UserSuspendService {
private readonly logger: Logger;
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@ -36,7 +42,10 @@ export class UserSuspendService {
private apRendererService: ApRendererService,
private moderationLogService: ModerationLogService,
private readonly cacheService: CacheService,
loggerService: LoggerService,
) {
this.logger = loggerService.getLogger('user-suspend');
}
@bindThis
@ -47,16 +56,16 @@ export class UserSuspendService {
isSuspended: true,
});
this.moderationLogService.log(moderator, 'suspend', {
await this.moderationLogService.log(moderator, 'suspend', {
userId: user.id,
userUsername: user.username,
userHost: user.host,
});
(async () => {
await this.postSuspend(user).catch(e => {});
await this.unFollowAll(user).catch(e => {});
})();
trackPromise((async () => {
await this.postSuspend(user);
await this.freezeAll(user);
})().catch(e => this.logger.error(`Error suspending user ${user.id}: ${renderInlineError(e)}`)));
}
@bindThis
@ -65,33 +74,36 @@ export class UserSuspendService {
isSuspended: false,
});
this.moderationLogService.log(moderator, 'unsuspend', {
await this.moderationLogService.log(moderator, 'unsuspend', {
userId: user.id,
userUsername: user.username,
userHost: user.host,
});
(async () => {
await this.postUnsuspend(user).catch(e => {});
})();
trackPromise((async () => {
await this.postUnsuspend(user);
await this.unFreezeAll(user);
})().catch(e => this.logger.error(`Error un-suspending for user ${user.id}: ${renderInlineError(e)}`)));
}
@bindThis
private async postSuspend(user: { id: MiUser['id']; host: MiUser['host'] }): Promise<void> {
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
/*
this.followRequestsRepository.delete({
followeeId: user.id,
});
this.followRequestsRepository.delete({
followerId: user.id,
});
*/
if (this.userEntityService.isLocalUser(user)) {
// 知り得る全SharedInboxにDelete配信
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
const queue: string[] = [];
const queue = new Map<string, boolean>();
const followings = await this.followingsRepository.find({
where: [
@ -104,12 +116,12 @@ export class UserSuspendService {
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
for (const inbox of inboxes) {
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
if (inbox != null) {
queue.set(inbox, true);
}
}
for (const inbox of queue) {
this.queueService.deliver(user, content, inbox, true);
}
await this.queueService.deliverMany(user, content, queue);
}
}
@ -121,7 +133,7 @@ export class UserSuspendService {
// 知り得る全SharedInboxにUndo Delete配信
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user));
const queue: string[] = [];
const queue = new Map<string, boolean>();
const followings = await this.followingsRepository.find({
where: [
@ -134,12 +146,12 @@ export class UserSuspendService {
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
for (const inbox of inboxes) {
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
if (inbox != null) {
queue.set(inbox, true);
}
}
for (const inbox of queue) {
this.queueService.deliver(user as any, content, inbox, true);
}
await this.queueService.deliverMany(user, content, queue);
}
}
@ -160,4 +172,36 @@ export class UserSuspendService {
}
this.queueService.createUnfollowJob(jobs);
}
@bindThis
private async freezeAll(user: MiUser): Promise<void> {
// Freeze follow relations with all remote users
await this.followingsRepository
.createQueryBuilder('following')
.orWhere({
followeeId: user.id,
followerHost: Not(IsNull()),
})
.update({
isFollowerHibernated: true,
})
.execute();
}
@bindThis
private async unFreezeAll(user: MiUser): Promise<void> {
// Restore follow relations with all remote users
await this.followingsRepository
.createQueryBuilder('following')
.innerJoin(MiUser, 'follower', 'user.id = following.followerId')
.andWhere('follower.isHibernated = false') // Don't unfreeze if the follower is *actually* frozen
.andWhere({
followeeId: user.id,
followerHost: Not(IsNull()),
})
.update({
isFollowerHibernated: false,
})
.execute();
}
}

View file

@ -7,10 +7,12 @@ import { URL, domainToASCII } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import RE2 from 're2';
import psl from 'psl';
import semver from 'semver';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { bindThis } from '@/decorators.js';
import { MiMeta } from '@/models/Meta.js';
import { MiMeta, SoftwareSuspension } from '@/models/Meta.js';
import { MiInstance } from '@/models/Instance.js';
@Injectable()
export class UtilityService {
@ -213,4 +215,20 @@ export class UtilityService {
return '';
}
}
@bindThis
public isDeliverSuspendedSoftware(software: Pick<MiInstance, 'softwareName' | 'softwareVersion'>): SoftwareSuspension | undefined {
if (software.softwareName == null) return undefined;
if (software.softwareVersion == null) {
// software version is null; suspend iff versionRange is *
return this.meta.deliverSuspendedSoftware.find(x =>
x.software === software.softwareName
&& x.versionRange.trim() === '*');
} else {
const softwareVersion = software.softwareVersion;
return this.meta.deliverSuspendedSoftware.find(x =>
x.software === software.softwareName
&& semver.satisfies(softwareVersion, x.versionRange, { includePrerelease: true }));
}
}
}

View file

@ -5,7 +5,7 @@
import { URL } from 'node:url';
import { Injectable } from '@nestjs/common';
import { XMLParser } from 'fast-xml-parser';
import { load as cheerio } from 'cheerio/slim';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { bindThis } from '@/decorators.js';
import type Logger from '@/logger.js';
@ -101,14 +101,12 @@ export class WebfingerService {
private async fetchWebFingerTemplateFromHostMeta(url: string): Promise<string | null> {
try {
const res = await this.httpRequestService.getHtml(url, 'application/xrd+xml');
const options = {
ignoreAttributes: false,
isArray: (_name: string, jpath: string) => jpath === 'XRD.Link',
};
const parser = new XMLParser(options);
const hostMeta = parser.parse(res);
const template = (hostMeta['XRD']['Link'] as Array<any>).filter(p => p['@_rel'] === 'lrdd')[0]['@_template'];
return template.indexOf('{uri}') < 0 ? null : template;
const hostMeta = cheerio(res, {
xml: true,
});
const template = hostMeta('XRD > Link[rel="lrdd"][template*="{uri}"]').attr('template');
return template ?? null;
} catch (err) {
this.logger.error(`error while request host-meta for ${url}: ${renderInlineError(err)}`);
return null;

View file

@ -13,6 +13,7 @@ import { type WebhookEventTypes } from '@/models/Webhook.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { type UserWebhookPayload, UserWebhookService } from '@/core/UserWebhookService.js';
import { QueueService } from '@/core/QueueService.js';
import { IdService } from '@/core/IdService.js';
import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
const oneDayMillis = 24 * 60 * 60 * 1000;
@ -166,6 +167,7 @@ export class WebhookTestService {
private userWebhookService: UserWebhookService,
private systemWebhookService: SystemWebhookService,
private queueService: QueueService,
private readonly idService: IdService,
) {
}
@ -392,6 +394,7 @@ export class WebhookTestService {
private async toPackedNote(note: MiNote, detail = true, override?: Packed<'Note'>): Promise<Packed<'Note'>> {
return {
id: note.id,
threadId: note.threadId ?? note.id,
createdAt: new Date().toISOString(),
deletedAt: null,
text: note.text,
@ -401,6 +404,10 @@ export class WebhookTestService {
replyId: note.replyId,
renoteId: note.renoteId,
isHidden: false,
isMutingThread: false,
isMutingNote: false,
isFavorited: false,
isRenoted: false,
visibility: note.visibility,
mentions: note.mentions,
visibleUserIds: note.visibleUserIds,
@ -435,10 +442,12 @@ export class WebhookTestService {
private async toPackedUserLite(user: MiUser, override?: Packed<'UserLite'>): Promise<Packed<'UserLite'>> {
return {
...user,
createdAt: this.idService.parse(user.id).date.toISOString(),
id: user.id,
name: user.name,
username: user.username,
host: user.host,
description: 'dummy user',
avatarUrl: user.avatarId == null ? null : user.avatarUrl,
avatarBlurhash: user.avatarId == null ? null : user.avatarBlurhash,
avatarDecorations: user.avatarDecorations.map(it => ({

View file

@ -4,7 +4,7 @@
*/
import { Injectable } from '@nestjs/common';
import * as mfm from '@transfem-org/sfm-js';
import * as mfm from 'mfm-js';
import { MfmService, Appender } from '@/core/MfmService.js';
import type { MiNote } from '@/models/Note.js';
import { bindThis } from '@/decorators.js';

View file

@ -6,8 +6,9 @@
import { createPublicKey, randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import * as mfm from '@transfem-org/sfm-js';
import * as mfm from 'mfm-js';
import { UnrecoverableError } from 'bullmq';
import { Element, Text } from 'domhandler';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import type { MiPartialLocalUser, MiLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js';
@ -31,6 +32,8 @@ import { IdService } from '@/core/IdService.js';
import { appendContentWarning } from '@/misc/append-content-warning.js';
import { QueryService } from '@/core/QueryService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { CacheService } from '@/core/CacheService.js';
import { isPureRenote, isQuote, isRenote } from '@/misc/is-renote.js';
import { JsonLdService } from './JsonLdService.js';
import { ApMfmService } from './ApMfmService.js';
import { CONTEXT } from './misc/contexts.js';
@ -74,6 +77,7 @@ export class ApRendererService {
private idService: IdService,
private readonly queryService: QueryService,
private utilityService: UtilityService,
private readonly cacheService: CacheService,
) {
}
@ -231,7 +235,7 @@ export class ApRendererService {
*/
@bindThis
public async renderFollowUser(id: MiUser['id']): Promise<string> {
const user = await this.usersRepository.findOneByOrFail({ id: id }) as MiPartialLocalUser | MiPartialRemoteUser;
const user = await this.cacheService.findUserById(id) as MiPartialLocalUser | MiPartialRemoteUser;
return this.userEntityService.getUserUri(user);
}
@ -401,7 +405,7 @@ export class ApRendererService {
inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId });
if (inReplyToNote != null) {
const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId });
const inReplyToUser = await this.cacheService.findUserById(inReplyToNote.userId);
if (inReplyToUser) {
if (inReplyToNote.uri) {
@ -421,7 +425,7 @@ export class ApRendererService {
let quote: string | undefined = undefined;
if (note.renoteId) {
if (isRenote(note) && isQuote(note)) {
const renote = await this.notesRepository.findOneBy({ id: note.renoteId });
if (renote) {
@ -475,16 +479,18 @@ export class ApRendererService {
// the claas name `quote-inline` is used in non-misskey clients for styling quote notes.
// For compatibility, the span part should be kept as possible.
apAppend.push((doc, body) => {
body.appendChild(doc.createElement('br'));
body.appendChild(doc.createElement('br'));
const span = doc.createElement('span');
span.className = 'quote-inline';
span.appendChild(doc.createTextNode('RE: '));
const link = doc.createElement('a');
link.setAttribute('href', quote);
link.textContent = quote;
span.appendChild(link);
body.appendChild(span);
body.childNodes.push(new Element('br', {}));
body.childNodes.push(new Element('br', {}));
const span = new Element('span', {
class: 'quote-inline',
});
span.childNodes.push(new Text('RE: '));
const link = new Element('a', {
href: quote,
});
link.childNodes.push(new Text(quote));
span.childNodes.push(link);
body.childNodes.push(span);
});
}
@ -539,6 +545,7 @@ export class ApRendererService {
attributedTo,
summary: summary ?? undefined,
content: content ?? undefined,
updated: note.updatedAt?.toISOString() ?? undefined,
_misskey_content: text,
source: {
content: text,
@ -548,7 +555,8 @@ export class ApRendererService {
quoteUrl: quote,
quoteUri: quote,
// https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md
quote: quote,
// Disabled since Mastodon hides the fallback link when this is set
// quote: quote,
published: this.idService.parse(note.id).date.toISOString(),
to,
cc,
@ -753,174 +761,6 @@ export class ApRendererService {
};
}
@bindThis
public async renderUpNote(note: MiNote, author: MiUser, dive = true): Promise<IPost> {
const getPromisedFiles = async (ids: string[]): Promise<MiDriveFile[]> => {
if (ids.length === 0) return [];
const items = await this.driveFilesRepository.findBy({ id: In(ids) });
return ids.map(id => items.find(item => item.id === id)).filter((item): item is MiDriveFile => item != null);
};
let inReplyTo;
let inReplyToNote: MiNote | null;
if (note.replyId) {
inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId });
if (inReplyToNote != null) {
const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId });
if (inReplyToUser) {
if (inReplyToNote.uri) {
inReplyTo = inReplyToNote.uri;
} else {
if (dive) {
inReplyTo = await this.renderUpNote(inReplyToNote, inReplyToUser, false);
} else {
inReplyTo = `${this.config.url}/notes/${inReplyToNote.id}`;
}
}
}
}
} else {
inReplyTo = null;
}
let quote: string | undefined = undefined;
if (note.renoteId) {
const renote = await this.notesRepository.findOneBy({ id: note.renoteId });
if (renote) {
quote = renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`;
}
}
const attributedTo = this.userEntityService.genLocalUserUri(note.userId);
const mentions = note.mentionedRemoteUsers ? (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri) : [];
let to: string[] = [];
let cc: string[] = [];
if (note.visibility === 'public') {
to = ['https://www.w3.org/ns/activitystreams#Public'];
cc = [`${attributedTo}/followers`].concat(mentions);
} else if (note.visibility === 'home') {
to = [`${attributedTo}/followers`];
cc = ['https://www.w3.org/ns/activitystreams#Public'].concat(mentions);
} else if (note.visibility === 'followers') {
to = [`${attributedTo}/followers`];
cc = mentions;
} else {
to = mentions;
}
const mentionedUsers = note.mentions && note.mentions.length > 0 ? await this.usersRepository.findBy({
id: In(note.mentions),
}) : [];
const hashtagTags = note.tags.map(tag => this.renderHashtag(tag));
const mentionTags = mentionedUsers.map(u => this.renderMention(u as MiLocalUser | MiRemoteUser));
const files = await getPromisedFiles(note.fileIds);
const text = note.text ?? '';
let poll: MiPoll | null = null;
if (note.hasPoll) {
poll = await this.pollsRepository.findOneBy({ noteId: note.id });
}
const apAppend: Appender[] = [];
if (quote) {
// Append quote link as `<br><br><span class="quote-inline">RE: <a href="...">...</a></span>`
// the claas name `quote-inline` is used in non-misskey clients for styling quote notes.
// For compatibility, the span part should be kept as possible.
apAppend.push((doc, body) => {
body.appendChild(doc.createElement('br'));
body.appendChild(doc.createElement('br'));
const span = doc.createElement('span');
span.className = 'quote-inline';
span.appendChild(doc.createTextNode('RE: '));
const link = doc.createElement('a');
link.setAttribute('href', quote);
link.textContent = quote;
span.appendChild(link);
body.appendChild(span);
});
}
let summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw;
// Apply mandatory CW, if applicable
if (author.mandatoryCW) {
summary = appendContentWarning(summary, author.mandatoryCW);
}
const { content } = this.apMfmService.getNoteHtml(note, apAppend);
const emojis = await this.getEmojis(note.emojis);
const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji));
const tag: IObject[] = [
...hashtagTags,
...mentionTags,
...apemojis,
];
// https://codeberg.org/fediverse/fep/src/branch/main/fep/e232/fep-e232.md
if (quote) {
tag.push({
type: 'Link',
mediaType: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
rel: 'https://misskey-hub.net/ns#_misskey_quote',
href: quote,
} satisfies ILink);
}
const asPoll = poll ? {
type: 'Question',
[poll.expiresAt && poll.expiresAt < new Date() ? 'closed' : 'endTime']: poll.expiresAt,
[poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({
type: 'Note',
name: text,
replies: {
type: 'Collection',
totalItems: poll!.votes[i],
},
})),
} as const : {};
return {
id: `${this.config.url}/notes/${note.id}`,
type: 'Note',
attributedTo,
summary: summary ?? undefined,
content: content ?? undefined,
updated: note.updatedAt?.toISOString(),
_misskey_content: text,
source: {
content: text,
mediaType: 'text/x.misskeymarkdown',
},
_misskey_quote: quote,
quoteUrl: quote,
quoteUri: quote,
// https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md
quote: quote,
published: this.idService.parse(note.id).date.toISOString(),
to,
cc,
inReplyTo,
attachment: files.map(x => this.renderDocument(x)),
sensitive: note.cw != null || files.some(file => file.isSensitive),
tag,
...asPoll,
};
}
@bindThis
public renderVote(user: { id: MiUser['id'] }, vote: MiPollVote, note: MiNote, poll: MiPoll, pollOwner: MiRemoteUser): ICreate {
return {
@ -1074,6 +914,27 @@ export class ApRendererService {
};
}
@bindThis
public async renderNoteOrRenoteActivity(note: MiNote, user: MiUser, hint?: { renote?: MiNote | null }) {
if (note.localOnly) return null;
if (isPureRenote(note)) {
const renote = hint?.renote ?? note.renote ?? await this.notesRepository.findOneByOrFail({ id: note.renoteId });
const apAnnounce = this.renderAnnounce(renote.uri ?? `${this.config.url}/notes/${renote.id}`, note);
return this.addContext(apAnnounce);
}
const apNote = await this.renderNote(note, user, false);
if (note.updatedAt != null) {
const apUpdate = this.renderUpdate(apNote, user);
return this.addContext(apUpdate);
} else {
const apCreate = this.renderCreate(apNote, note);
return this.addContext(apCreate);
}
}
@bindThis
private async getEmojis(names: string[]): Promise<MiEmoji[]> {
if (names.length === 0) return [];

View file

@ -6,7 +6,7 @@
import * as crypto from 'node:crypto';
import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import { Window } from 'happy-dom';
import { load as cheerio } from 'cheerio/slim';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import type { MiUser } from '@/models/User.js';
@ -18,6 +18,8 @@ import { bindThis } from '@/decorators.js';
import type Logger from '@/logger.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
import type { IObject, IObjectWithId } from './type.js';
import type { Cheerio, CheerioAPI } from 'cheerio/slim';
import type { AnyNode } from 'domhandler';
type Request = {
url: string;
@ -219,53 +221,33 @@ export class ApRequestService {
(contentType ?? '').split(';')[0].trimEnd().toLowerCase() === 'text/html' &&
_followAlternate === true
) {
const html = await res.text();
const { window, happyDOM } = new Window({
settings: {
disableJavaScriptEvaluation: true,
disableJavaScriptFileLoading: true,
disableCSSFileLoading: true,
disableComputedStyleRendering: true,
handleDisabledFileLoadingAsSuccess: true,
navigation: {
disableMainFrameNavigation: true,
disableChildFrameNavigation: true,
disableChildPageNavigation: true,
disableFallbackToSetURL: true,
},
timer: {
maxTimeout: 0,
maxIntervalTime: 0,
maxIntervalIterations: 0,
},
},
});
const document = window.document;
let alternate: Cheerio<AnyNode> | null;
try {
document.documentElement.innerHTML = html;
const html = await res.text();
const document = cheerio(html);
// Search for any matching value in priority order:
// 1. Type=AP > Type=none > Type=anything
// 2. Alternate > Canonical
// 3. Page order (fallback)
const alternate =
document.querySelector('head > link[href][rel="alternate"][type="application/activity+json"]') ??
document.querySelector('head > link[href][rel="canonical"][type="application/activity+json"]') ??
document.querySelector('head > link[href][rel="alternate"]:not([type])') ??
document.querySelector('head > link[href][rel="canonical"]:not([type])') ??
document.querySelector('head > link[href][rel="alternate"]') ??
document.querySelector('head > link[href][rel="canonical"]');
if (alternate) {
const href = alternate.getAttribute('href');
if (href && this.apUtilityService.haveSameAuthority(url, href)) {
return await this.signedGet(href, user, allowAnonymous, false);
}
}
alternate = selectFirst(document, [
'head > link[href][rel="alternate"][type="application/activity+json"]',
'head > link[href][rel="canonical"][type="application/activity+json"]',
'head > link[href][rel="alternate"]:not([type])',
'head > link[href][rel="canonical"]:not([type])',
'head > link[href][rel="alternate"]',
'head > link[href][rel="canonical"]',
]);
} catch {
// something went wrong parsing the HTML, ignore the whole thing
} finally {
happyDOM.close().catch(err => {});
alternate = null;
}
if (alternate) {
const href = alternate.attr('href');
if (href && this.apUtilityService.haveSameAuthority(url, href)) {
return await this.signedGet(href, user, allowAnonymous, false);
}
}
}
//#endregion
@ -285,3 +267,14 @@ export class ApRequestService {
return activity as IObjectWithId;
}
}
function selectFirst($: CheerioAPI, selectors: string[]): Cheerio<AnyNode> | null {
for (const selector of selectors) {
const selection = $(selector);
if (selection.length > 0) {
return selection;
}
}
return null;
}

View file

@ -21,6 +21,8 @@ import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
import { SystemAccountService } from '@/core/SystemAccountService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { toArray } from '@/misc/prelude/array.js';
import { isPureRenote } from '@/misc/is-renote.js';
import { CacheService } from '@/core/CacheService.js';
import { AnyCollection, getApId, getNullableApId, IObjectWithId, isCollection, isCollectionOrOrderedCollection, isCollectionPage, isOrderedCollection, isOrderedCollectionPage } from './type.js';
import { ApDbResolverService } from './ApDbResolverService.js';
import { ApRendererService } from './ApRendererService.js';
@ -49,6 +51,7 @@ export class Resolver {
private loggerService: LoggerService,
private readonly apLogService: ApLogService,
private readonly apUtilityService: ApUtilityService,
private readonly cacheService: CacheService,
private recursionLimit = 256,
) {
this.history = new Set();
@ -355,18 +358,20 @@ export class Resolver {
switch (parsed.type) {
case 'notes':
return this.notesRepository.findOneByOrFail({ id: parsed.id, userHost: IsNull() })
return this.notesRepository.findOneOrFail({ where: { id: parsed.id, userHost: IsNull() }, relations: { user: true, renote: true } })
.then(async note => {
const author = await this.usersRepository.findOneByOrFail({ id: note.userId });
const author = note.user ?? await this.cacheService.findUserById(note.userId);
if (parsed.rest === 'activity') {
// this refers to the create activity and not the note itself
return this.apRendererService.addContext(this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, author), note));
return await this.apRendererService.renderNoteOrRenoteActivity(note, author);
} else if (!isPureRenote(note)) {
const apNote = await this.apRendererService.renderNote(note, author);
return this.apRendererService.addContext(apNote);
} else {
return this.apRendererService.renderNote(note, author);
throw new IdentifiableError('732c2633-3395-4d51-a9b7-c7084774e3e7', `Failed to resolve local ${url}: cannot resolve a boost as note`);
}
}) as Promise<IObjectWithId>;
case 'users':
return this.usersRepository.findOneByOrFail({ id: parsed.id, host: IsNull() })
return this.cacheService.findLocalUserById(parsed.id)
.then(user => this.apRendererService.renderPerson(user as MiLocalUser));
case 'questions':
// Polls are indexed by the note they are attached to.
@ -387,14 +392,8 @@ export class Resolver {
.then(async followRequest => {
if (followRequest == null) throw new IdentifiableError('a9d946e5-d276-47f8-95fb-f04230289bb0', `failed to resolve local ${url}: invalid follow request ID`);
const [follower, followee] = await Promise.all([
this.usersRepository.findOneBy({
id: followRequest.followerId,
host: IsNull(),
}),
this.usersRepository.findOneBy({
id: followRequest.followeeId,
host: Not(IsNull()),
}),
this.cacheService.findLocalUserById(followRequest.followerId),
this.cacheService.findLocalUserById(followRequest.followeeId),
]);
if (follower == null || followee == null) {
throw new IdentifiableError('06ae3170-1796-4d93-a697-2611ea6d83b6', `failed to resolve local ${url}: follower or followee does not exist`);
@ -440,6 +439,7 @@ export class ApResolverService {
private loggerService: LoggerService,
private readonly apLogService: ApLogService,
private readonly apUtilityService: ApUtilityService,
private readonly cacheService: CacheService,
) {
}
@ -465,6 +465,7 @@ export class ApResolverService {
this.loggerService,
this.apLogService,
this.apUtilityService,
this.cacheService,
opts?.recursionLimit,
);
}

View file

@ -0,0 +1,83 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { load as cheerio } from 'cheerio/slim';
import type { IApDocument } from '@/core/activitypub/type.js';
import type { CheerioAPI } from 'cheerio/slim';
/**
* Finds HTML elements representing inline media and returns them as simulated AP documents.
* Returns an empty array if the input cannot be parsed, or no media was found.
* @param html Input HTML to analyze.
*/
export function extractMediaFromHtml(html: string): IApDocument[] {
const $ = parseHtml(html);
if (!$) return [];
const attachments = new Map<string, IApDocument>();
// <img> tags, including <picture> and <object> fallback elements
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img
$('img[src]')
.toArray()
.forEach(img => attachments.set(img.attribs.src, {
type: 'Image',
url: img.attribs.src,
name: img.attribs.alt || img.attribs.title || null,
}));
// <object> tags
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/object
$('object[data]')
.toArray()
.forEach(object => attachments.set(object.attribs.data, {
type: 'Document',
url: object.attribs.data,
name: object.attribs.alt || object.attribs.title || null,
}));
// <embed> tags
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/embed
$('embed[src]')
.toArray()
.forEach(embed => attachments.set(embed.attribs.src, {
type: 'Document',
url: embed.attribs.src,
name: embed.attribs.alt || embed.attribs.title || null,
}));
// <audio> tags
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/audio
$('audio[src]')
.toArray()
.forEach(audio => attachments.set(audio.attribs.src, {
type: 'Audio',
url: audio.attribs.src,
name: audio.attribs.alt || audio.attribs.title || null,
}));
// <video> tags
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/video
$('video[src]')
.toArray()
.forEach(audio => attachments.set(audio.attribs.src, {
type: 'Video',
url: audio.attribs.src,
name: audio.attribs.alt || audio.attribs.title || null,
}));
// TODO support <svg>? We would need to extract it directly from the HTML and save to a temp file.
return Array.from(attachments.values());
}
function parseHtml(html: string): CheerioAPI | null {
try {
return cheerio(html);
} catch {
// Don't worry about invalid HTML
return null;
}
}

View file

@ -0,0 +1,61 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { parse, inspect, extract } from 'mfm-js';
import type { IApDocument } from '@/core/activitypub/type.js';
import type { MfmNode, MfmText } from 'mfm-js';
/**
* Finds MFM notes representing inline media and returns them as simulated AP documents.
* Returns an empty array if the input cannot be parsed, or no media was found.
* @param mfm Input MFM to analyze.
*/
export function extractMediaFromMfm(mfm: string): IApDocument[] {
const nodes = parseMfm(mfm);
if (nodes == null) return [];
const attachments = new Map<string, IApDocument>();
inspect(nodes, node => {
if (node.type === 'link' && node.props.image) {
const alt: string[] = [];
inspect(node.children, node => {
switch (node.type) {
case 'text':
alt.push(node.props.text);
break;
case 'unicodeEmoji':
alt.push(node.props.emoji);
break;
case 'emojiCode':
alt.push(':');
alt.push(node.props.name);
alt.push(':');
break;
}
});
attachments.set(node.props.url, {
type: 'Image',
url: node.props.url,
name: alt.length > 0
? alt.join('')
: null,
});
}
});
return Array.from(attachments.values());
}
function parseMfm(mfm: string): MfmNode[] | null {
try {
return parse(mfm);
} catch {
// Don't worry about invalid MFM
return null;
}
}

View file

@ -0,0 +1,74 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { IPost } from '@/core/activitypub/type.js';
import { toArray } from '@/misc/prelude/array.js';
/**
* Gets content of a specified media type from a provided object.
*
* Optionally supports a "permissive" mode which enables the following changes:
* 1. MIME types are checked in a case-insensitive manner.
* 2. MIME types are matched based on inclusion, not strict equality.
* 3. A candidate content is considered to match if it has no specified MIME type.
*
* Note: this method is written defensively to protect against malform remote objects.
* When extending or modifying it, please be sure to work with "unknown" type and validate everything.
*
* Note: the logic in this method is carefully ordered to match the selection priority of existing code in ApNoteService.
* Please do not re-arrange it without testing!
* New checks can be added to the end of the method to safely extend the existing logic.
*
* @param object AP object to extract content from.
* @param mimeType MIME type to look for.
* @param permissive Enables permissive mode, as described above. Defaults to false (disabled).
*/
export function getContentByType(object: IPost | Record<string, unknown>, mimeType: string, permissive = false): string | null {
// Case 1: Extended "source" property
if (object.source && typeof(object.source) === 'object') {
// "source" is permitted to be an array, though no implementations are known to do this yet.
const sources = toArray(object.source) as Record<string, unknown>[];
for (const source of sources) {
if (typeof (source.content) === 'string' && checkMediaType(source.mediaType)) {
return source.content;
}
}
}
// Case 2: Special case for MFM
if (typeof(object._misskey_content) === 'string' && mimeType === 'text/x.misskeymarkdown') {
return object._misskey_content;
}
// Case 3: AP native "content" property
if (typeof(object.content) === 'string' && checkMediaType(object.mediaType)) {
return object.content;
}
return null;
// Checks if the provided media type matches the input parameters.
function checkMediaType(mediaType: unknown): boolean {
if (typeof(mediaType) === 'string') {
// Strict match
if (mediaType === mimeType) {
return true;
}
// Permissive match
if (permissive && mediaType.toLowerCase().includes(mimeType.toLowerCase())) {
return true;
}
}
// Permissive fallback match
if (permissive && mediaType == null) {
return true;
}
// No match
return false;
}
}

View file

@ -86,7 +86,7 @@ export class ApImageService {
uri: image.url,
sensitive: !!(image.sensitive),
isLink: !shouldBeCached,
comment: truncate(image.name ?? undefined, this.config.maxRemoteAltTextLength),
comment: truncate(image.summary || image.name || undefined, this.config.maxRemoteAltTextLength),
});
if (!file.isLink || file.url === image.url) return file;

View file

@ -6,6 +6,7 @@
import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import { UnrecoverableError } from 'bullmq';
import promiseLimit from 'promise-limit';
import { DI } from '@/di-symbols.js';
import type { UsersRepository, PollsRepository, EmojisRepository, NotesRepository, MiMeta } from '@/models/_.js';
import type { Config } from '@/config.js';
@ -27,6 +28,9 @@ import { checkHttps } from '@/misc/check-https.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { isRetryableError } from '@/misc/is-retryable-error.js';
import { renderInlineError } from '@/misc/render-inline-error.js';
import { extractMediaFromHtml } from '@/core/activitypub/misc/extract-media-from-html.js';
import { extractMediaFromMfm } from '@/core/activitypub/misc/extract-media-from-mfm.js';
import { getContentByType } from '@/core/activitypub/misc/get-content-by-type.js';
import { getOneApId, getApId, validPost, isEmoji, getApType, isApObject, isDocument, IApDocument, isLink } from '../type.js';
import { ApLoggerService } from '../ApLoggerService.js';
import { ApMfmService } from '../ApMfmService.js';
@ -206,12 +210,10 @@ export class ApNoteService {
const cw = note.summary === '' ? null : note.summary;
// テキストのパース
let text: string | null = null;
if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') {
text = note.source.content;
} else if (typeof note._misskey_content !== 'undefined') {
text = note._misskey_content;
} else if (typeof note.content === 'string') {
let text =
getContentByType(note, 'text/x.misskeymarkdown') ??
getContentByType(note, 'text/markdown');
if (text == null && typeof note.content === 'string') {
text = this.apMfmService.htmlToMfm(note.content, note.tag);
}
@ -248,21 +250,14 @@ export class ApNoteService {
}
}
const processErrors: string[] = [];
// 添付ファイル
const files: MiDriveFile[] = [];
for (const attach of toArray(note.attachment)) {
attach.sensitive ??= note.sensitive;
const file = await this.apImageService.resolveImage(actor, attach);
if (file) files.push(file);
}
// Some software (Peertube) attaches a thumbnail under "icon" instead of "attachment"
const icon = getBestIcon(note);
if (icon) {
icon.sensitive ??= note.sensitive;
const file = await this.apImageService.resolveImage(actor, icon);
if (file) files.push(file);
// Note: implementation moved to getAttachment function to avoid duplication.
// Please copy any upstream changes to that method! (It's in the bottom of this class)
const { files, hasFileError } = await this.getAttachments(note, actor);
if (hasFileError) {
processErrors.push('attachmentFailed');
}
// リプライ
@ -284,7 +279,9 @@ export class ApNoteService {
// 引用
const quote = await this.getQuote(note, entryUri, resolver);
const processErrors = quote === null ? ['quoteUnavailable'] : null;
if (quote === null) {
processErrors.push('quoteUnavailable');
}
if (reply && reply.userHost == null && reply.localOnly) {
throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot reply to local-only note');
@ -328,7 +325,7 @@ export class ApNoteService {
files,
reply,
renote: quote ?? null,
processErrors,
processErrors: processErrors.length > 0 ? processErrors : null,
name: note.name,
cw,
text,
@ -412,12 +409,10 @@ export class ApNoteService {
const cw = note.summary === '' ? null : note.summary;
// テキストのパース
let text: string | null = null;
if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') {
text = note.source.content;
} else if (typeof note._misskey_content !== 'undefined') {
text = note._misskey_content;
} else if (typeof note.content === 'string') {
let text =
getContentByType(note, 'text/x.misskeymarkdown') ??
getContentByType(note, 'text/markdown');
if (text == null && typeof note.content === 'string') {
text = this.apMfmService.htmlToMfm(note.content, note.tag);
}
@ -446,21 +441,12 @@ export class ApNoteService {
}
}
const processErrors: string[] = [];
// 添付ファイル
const files: MiDriveFile[] = [];
for (const attach of toArray(note.attachment)) {
attach.sensitive ??= note.sensitive;
const file = await this.apImageService.resolveImage(actor, attach);
if (file) files.push(file);
}
// Some software (Peertube) attaches a thumbnail under "icon" instead of "attachment"
const icon = getBestIcon(note);
if (icon) {
icon.sensitive ??= note.sensitive;
const file = await this.apImageService.resolveImage(actor, icon);
if (file) files.push(file);
const { files, hasFileError } = await this.getAttachments(note, actor);
if (hasFileError) {
processErrors.push('attachmentFailed');
}
// リプライ
@ -482,7 +468,9 @@ export class ApNoteService {
// 引用
const quote = await this.getQuote(note, entryUri, resolver);
const processErrors = quote === null ? ['quoteUnavailable'] : null;
if (quote === null) {
processErrors.push('quoteUnavailable');
}
if (quote && quote.userHost == null && quote.localOnly) {
throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot quote a local-only note');
@ -523,7 +511,7 @@ export class ApNoteService {
files,
reply,
renote: quote ?? null,
processErrors,
processErrors: processErrors.length > 0 ? processErrors : null,
name: note.name,
cw,
text,
@ -722,10 +710,95 @@ export class ApNoteService {
// Permanent error - return null
return null;
}
/**
* Extracts and saves all media attachments from the provided note.
* Returns an array of all the created files.
*/
private async getAttachments(note: IPost, actor: MiRemoteUser): Promise<{ files: MiDriveFile[], hasFileError: boolean }> {
const attachments = new Map<string, IApDocument & { url: string }>();
// Extract inline media from HTML content.
// Don't use source.content, _misskey_content, or anything else because those aren't HTML.
const htmlContent = getContentByType(note, 'text/html', true);
if (htmlContent) {
for (const attach of extractMediaFromHtml(htmlContent)) {
if (hasUrl(attach)) {
attachments.set(attach.url, attach);
}
}
}
// Extract inline media from MFM / markdown content.
const mfmContent =
getContentByType(note, 'text/x.misskeymarkdown') ??
getContentByType(note, 'text/markdown');
if (mfmContent) {
for (const attach of extractMediaFromMfm(mfmContent)) {
if (hasUrl(attach)) {
attachments.set(attach.url, attach);
}
}
}
// Some software (Peertube) attaches a thumbnail under "icon" instead of "attachment"
const icon = getBestIcon(note);
if (icon) {
if (hasUrl(icon)) {
attachments.set(icon.url, icon);
}
}
// Populate AP attachments last, to overwrite any "fallback" elements that may have been inlined in HTML.
// AP attachments should be considered canonical.
for (const attach of toArray(note.attachment)) {
if (hasUrl(attach)) {
attachments.set(attach.url, attach);
}
}
// Resolve all files w/ concurrency 2.
// This prevents one big file from blocking the others.
const limiter = promiseLimit<MiDriveFile | null>(2);
const results = await Promise
.all(Array
.from(attachments.values())
.map(attach => limiter(async () => {
attach.sensitive ??= note.sensitive;
return await this.resolveImage(actor, attach);
})));
// Process results
let hasFileError = false;
const files: MiDriveFile[] = [];
for (const result of results) {
if (result != null) {
files.push(result);
} else {
hasFileError = true;
}
}
return { files, hasFileError };
}
private async resolveImage(actor: MiRemoteUser, attachment: IApDocument & { url: string }): Promise<MiDriveFile | null> {
try {
return await this.apImageService.resolveImage(actor, attachment);
} catch (err) {
if (isRetryableError(err)) {
this.logger.warn(`Temporary failure to resolve attachment at ${attachment.url}: ${renderInlineError(err)}`);
throw err;
} else {
this.logger.warn(`Permanent failure to resolve attachment at ${attachment.url}: ${renderInlineError(err)}`);
return null;
}
}
}
}
function getBestIcon(note: IObject): IObject | null {
const icons: IObject[] = toArray(note.icon);
function getBestIcon(note: IObject): IApDocument | null {
const icons: IApDocument[] = toArray(note.icon);
if (icons.length < 2) {
return icons[0] ?? null;
}
@ -741,3 +814,8 @@ function getBestIcon(note: IObject): IObject | null {
return best;
}, null as IApDocument | null) ?? null;
}
// Need this to make TypeScript happy...
function hasUrl<T extends IObject>(object: T): object is T & { url: string } {
return typeof(object.url) === 'string';
}

View file

@ -31,7 +31,6 @@ import type UsersChart from '@/core/chart/charts/users.js';
import type InstanceChart from '@/core/chart/charts/instance.js';
import type { HashtagService } from '@/core/HashtagService.js';
import { MiUserNotePining } from '@/models/UserNotePining.js';
import { StatusError } from '@/misc/status-error.js';
import type { UtilityService } from '@/core/UtilityService.js';
import type { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
@ -45,6 +44,7 @@ import { HttpRequestService } from '@/core/HttpRequestService.js';
import { verifyFieldLinks } from '@/misc/verify-field-link.js';
import { isRetryableError } from '@/misc/is-retryable-error.js';
import { renderInlineError } from '@/misc/render-inline-error.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { getApId, getApType, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js';
import { extractApHashtags } from './tag.js';
import type { OnModuleInit } from '@nestjs/common';
@ -55,10 +55,8 @@ import type { ApLoggerService } from '../ApLoggerService.js';
import type { ApImageService } from './ApImageService.js';
import type { IActor, ICollection, IObject, IOrderedCollection } from '../type.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
const nameLength = 128;
const summaryLength = 2048;
type Field = Record<'name' | 'value', string>;
@ -220,7 +218,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
if (!(typeof x.summary === 'string' && x.summary.length > 0)) {
throw new UnrecoverableError(`invalid Actor ${uri}: wrong summary`);
}
x.summary = truncate(x.summary, summaryLength);
x.summary = truncate(x.summary, this.config.maxRemoteBioLength);
}
const idHost = this.utilityService.punyHostPSLDomain(x.id);
@ -458,9 +456,9 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
let _description: string | null = null;
if (person._misskey_summary) {
_description = truncate(person._misskey_summary, summaryLength);
_description = truncate(person._misskey_summary, this.config.maxRemoteBioLength);
} else if (person.summary) {
_description = this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag);
_description = this.apMfmService.htmlToMfm(truncate(person.summary, this.config.maxRemoteBioLength), person.tag);
}
await transactionalEntityManager.save(new MiUserProfile({
@ -575,7 +573,6 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
if (exist === null) return;
//#endregion
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
const object = hint ?? await resolver.resolve(uri);
@ -717,9 +714,9 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
let _description: string | null = null;
if (person._misskey_summary) {
_description = truncate(person._misskey_summary, summaryLength);
_description = truncate(person._misskey_summary, this.config.maxRemoteBioLength);
} else if (person.summary) {
_description = this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag);
_description = this.apMfmService.htmlToMfm(truncate(person.summary, this.config.maxRemoteBioLength), person.tag);
}
await this.userProfilesRepository.update({ userId: exist.id }, {

View file

@ -24,7 +24,7 @@ export interface IObject {
cc?: ApObject;
to?: ApObject;
attributedTo?: ApObject;
attachment?: any[];
attachment?: IApDocument[];
inReplyTo?: any;
replies?: ICollection | IOrderedCollection | string;
content?: string | null;

View file

@ -75,6 +75,7 @@ export class ChartManagementService implements OnApplicationShutdown {
public async dispose(): Promise<void> {
clearInterval(this.saveIntervalId);
if (process.env.NODE_ENV !== 'test') {
this.logger.info('Saving charts for shutdown...');
for (const chart of this.charts) {
await chart.save();
}

View file

@ -35,6 +35,7 @@ export class InstanceEntityService {
me?: { id: MiUser['id']; } | null | undefined,
): Promise<Packed<'FederationInstance'>> {
const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false;
const softwareSuspended = this.utilityService.isDeliverSuspendedSoftware(instance);
return {
id: instance.id,
@ -45,8 +46,8 @@ export class InstanceEntityService {
followingCount: instance.followingCount,
followersCount: instance.followersCount,
isNotResponding: instance.isNotResponding,
isSuspended: instance.suspensionState !== 'none',
suspensionState: instance.suspensionState,
isSuspended: instance.suspensionState !== 'none' || Boolean(softwareSuspended),
suspensionState: instance.suspensionState === 'none' && softwareSuspended ? 'softwareSuspended' : instance.suspensionState,
isBlocked: instance.isBlocked,
softwareName: instance.softwareName,
softwareVersion: instance.softwareVersion,

View file

@ -117,6 +117,8 @@ export class MetaEntityService {
maxRemoteCwLength: this.config.maxRemoteCwLength,
maxAltTextLength: this.config.maxAltTextLength,
maxRemoteAltTextLength: this.config.maxRemoteAltTextLength,
maxBioLength: this.config.maxBioLength,
maxRemoteBioLength: this.config.maxRemoteBioLength,
defaultLightTheme,
defaultDarkTheme,
defaultLike: instance.defaultLike,

View file

@ -11,11 +11,12 @@ import type { Packed } from '@/misc/json-schema.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import type { MiUser } from '@/models/User.js';
import type { MiNote } from '@/models/Note.js';
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, MiMeta, MiPollVote, MiPoll, MiChannel, MiFollowing } from '@/models/_.js';
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, MiMeta, MiPollVote, MiPoll, MiChannel, MiFollowing, NoteFavoritesRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { DebounceLoader } from '@/misc/loader.js';
import { IdService } from '@/core/IdService.js';
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
import { QueryService } from '@/core/QueryService.js';
import { isPackedPureRenote } from '@/misc/is-renote.js';
import type { Config } from '@/config.js';
import type { OnModuleInit } from '@nestjs/common';
@ -55,6 +56,7 @@ function getAppearNoteIds(notes: MiNote[]): Set<string> {
return appearNoteIds;
}
// noinspection ES6MissingAwait
@Injectable()
export class NoteEntityService implements OnModuleInit {
private userEntityService: UserEntityService;
@ -96,6 +98,10 @@ export class NoteEntityService implements OnModuleInit {
@Inject(DI.config)
private readonly config: Config,
@Inject(DI.noteFavoritesRepository)
private noteFavoritesRepository: NoteFavoritesRepository,
private readonly queryService: QueryService,
//private userEntityService: UserEntityService,
//private driveFileEntityService: DriveFileEntityService,
//private customEmojiService: CustomEmojiService,
@ -131,9 +137,21 @@ export class NoteEntityService implements OnModuleInit {
return packedNote.visibility;
}
@bindThis
public async hideNotes(notes: Packed<'Note'>[], meId: string | null): Promise<void> {
const myFollowing = meId ? new Map(await this.cacheService.userFollowingsCache.fetch(meId)) : new Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>();
const myBlockers = meId ? new Set(await this.cacheService.userBlockedCache.fetch(meId)) : new Set<string>();
// This shouldn't actually await, but we have to wrap it anyway because hideNote() is async
await Promise.all(notes.map(note => this.hideNote(note, meId, {
myFollowing,
myBlockers,
})));
}
@bindThis
public async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null, hint?: {
myFollowing?: ReadonlyMap<string, unknown>,
myFollowing?: ReadonlyMap<string, Omit<MiFollowing, 'isFollowerHibernated'>> | ReadonlySet<string>,
myBlockers?: ReadonlySet<string>,
}): Promise<void> {
if (meId === packedNote.userId) return;
@ -275,6 +293,142 @@ export class NoteEntityService implements OnModuleInit {
};
}
@bindThis
public async populateMyNoteMutings(notes: Packed<'Note'>[], meId: string): Promise<Set<string>> {
const mutedNotes = await this.cacheService.noteMutingsCache.fetch(meId);
const mutedIds = notes
.filter(note => mutedNotes.has(note.id))
.map(note => note.id);
return new Set(mutedIds);
}
@bindThis
public async populateMyTheadMutings(notes: Packed<'Note'>[], meId: string): Promise<Set<string>> {
const mutedThreads = await this.cacheService.threadMutingsCache.fetch(meId);
const mutedIds = notes
.filter(note => mutedThreads.has(note.threadId))
.map(note => note.id);
return new Set(mutedIds);
}
@bindThis
public async populateMyRenotes(notes: Packed<'Note'>[], meId: string, _hint_?: {
myRenotes: Set<string>;
}): Promise<Set<string>> {
const fetchedRenotes = new Set<string>();
const toFetch = new Set<string>();
if (_hint_) {
for (const note of notes) {
if (_hint_.myRenotes.has(note.id)) {
fetchedRenotes.add(note.id);
} else {
toFetch.add(note.id);
}
}
}
if (toFetch.size > 0) {
const fetched = await this.queryService
.andIsRenote(this.notesRepository.createQueryBuilder('note'), 'note')
.andWhere({
userId: meId,
renoteId: In(Array.from(toFetch)),
})
.select('note.renoteId', 'renoteId')
.getRawMany<{ renoteId: string }>();
for (const { renoteId } of fetched) {
fetchedRenotes.add(renoteId);
}
}
return fetchedRenotes;
}
@bindThis
public async populateMyFavorites(notes: Packed<'Note'>[], meId: string, _hint_?: {
myFavorites: Set<string>;
}): Promise<Set<string>> {
const fetchedFavorites = new Set<string>();
const toFetch = new Set<string>();
if (_hint_) {
for (const note of notes) {
if (_hint_.myFavorites.has(note.id)) {
fetchedFavorites.add(note.id);
} else {
toFetch.add(note.id);
}
}
}
if (toFetch.size > 0) {
const fetched = await this.noteFavoritesRepository.find({
where: {
userId: meId,
noteId: In(Array.from(toFetch)),
},
select: {
noteId: true,
},
}) as { noteId: string }[];
for (const { noteId } of fetched) {
fetchedFavorites.add(noteId);
}
}
return fetchedFavorites;
}
@bindThis
public async populateMyReactions(notes: Packed<'Note'>[], meId: string, _hint_?: {
myReactions: Map<MiNote['id'], string | null>;
}): Promise<Map<string, string>> {
const fetchedReactions = new Map<string, string>();
const toFetch = new Set<string>();
if (_hint_) {
for (const note of notes) {
const fromHint = _hint_.myReactions.get(note.id);
// null means we know there's no reaction, so just skip it.
if (fromHint === null) continue;
if (fromHint) {
const converted = this.reactionService.convertLegacyReaction(fromHint);
fetchedReactions.set(note.id, converted);
} else if (Object.values(note.reactions).some(count => count > 0)) {
// Note has at least one reaction, so we need to fetch
toFetch.add(note.id);
}
}
}
if (toFetch.size > 0) {
const fetched = await this.noteReactionsRepository.find({
where: {
userId: meId,
noteId: In(Array.from(toFetch)),
},
select: {
noteId: true,
reaction: true,
},
});
for (const { noteId, reaction } of fetched) {
const converted = this.reactionService.convertLegacyReaction(reaction);
fetchedReactions.set(noteId, converted);
}
}
return fetchedReactions;
}
@bindThis
public async populateMyReaction(note: { id: MiNote['id']; reactions: MiNote['reactions']; reactionAndUserPairCache?: MiNote['reactionAndUserPairCache']; }, meId: MiUser['id'], _hint_?: {
myReactions: Map<MiNote['id'], string | null>;
@ -306,9 +460,14 @@ export class NoteEntityService implements OnModuleInit {
return undefined;
}
const reaction = await this.noteReactionsRepository.findOneBy({
userId: meId,
noteId: note.id,
const reaction = await this.noteReactionsRepository.findOne({
where: {
userId: meId,
noteId: note.id,
},
select: {
reaction: true,
},
});
if (reaction) {
@ -422,6 +581,10 @@ export class NoteEntityService implements OnModuleInit {
pollVotes: Map<string, Map<string, MiPollVote[]>>;
channels: Map<string, MiChannel>;
notes: Map<string, MiNote>;
mutedThreads: Set<string>;
mutedNotes: Set<string>;
favoriteNotes: Set<string>;
renotedNotes: Set<string>;
};
},
): Promise<Packed<'Note'>> {
@ -460,8 +623,28 @@ export class NoteEntityService implements OnModuleInit {
const packedFiles = options?._hint_?.packedFiles;
const packedUsers = options?._hint_?.packedUsers;
const threadId = note.threadId ?? note.id;
const [mutedThreads, mutedNotes, isFavorited, isRenoted] = await Promise.all([
// mutedThreads
opts._hint_?.mutedThreads
?? (meId ? this.cacheService.threadMutingsCache.fetch(meId) : new Set<string>()),
// mutedNotes
opts._hint_?.mutedNotes
?? (meId ? this.cacheService.noteMutingsCache.fetch(meId) : new Set<string>),
// isFavorited
opts._hint_?.favoriteNotes.has(note.id)
?? (meId ? this.noteFavoritesRepository.existsBy({ userId: meId, noteId: note.id }) : false),
// isRenoted
opts._hint_?.renotedNotes.has(note.id)
?? (meId ? this.queryService
.andIsRenote(this.notesRepository.createQueryBuilder('note'), 'note')
.andWhere({ renoteId: note.id, userId: meId })
.getExists() : false),
]);
const packed: Packed<'Note'> = await awaitAll({
id: note.id,
threadId,
createdAt: this.idService.parse(note.id).date.toISOString(),
updatedAt: note.updatedAt ? note.updatedAt.toISOString() : undefined,
userId: note.userId,
@ -501,6 +684,10 @@ export class NoteEntityService implements OnModuleInit {
poll: opts._hint_?.polls.get(note.id),
myVotes: opts._hint_?.pollVotes.get(note.id)?.get(note.userId),
}) : undefined,
isMutingThread: mutedThreads.has(threadId),
isMutingNote: mutedNotes.has(note.id),
isFavorited,
isRenoted,
...(meId && Object.keys(reactions).length > 0 ? {
myReaction: this.populateMyReaction({
@ -648,7 +835,7 @@ export class NoteEntityService implements OnModuleInit {
const fileIds = new Set(targetNotes.flatMap(n => n.fileIds));
const mentionedUsers = new Set(targetNotes.flatMap(note => note.mentions));
const [{ bufferedReactions, myReactionsMap }, packedFiles, packedUsers, mentionHandles, userFollowings, userBlockers, polls, pollVotes, channels] = await Promise.all([
const [{ bufferedReactions, myReactionsMap }, packedFiles, packedUsers, mentionHandles, userFollowings, userBlockers, polls, pollVotes, channels, mutedThreads, mutedNotes, favoriteNotes, renotedNotes] = await Promise.all([
// bufferedReactions & myReactionsMap
this.getReactions(targetNotes, me),
// packedFiles
@ -659,6 +846,7 @@ export class NoteEntityService implements OnModuleInit {
// mentionHandles
this.getUserHandles(Array.from(mentionedUsers)),
// userFollowings
// TODO this might be wrong
this.cacheService.userFollowingsCache.fetchMany(userIds).then(fs => new Map(fs)),
// userBlockers
this.cacheService.userBlockedCache.fetchMany(userIds).then(bs => new Map(bs)),
@ -683,6 +871,24 @@ export class NoteEntityService implements OnModuleInit {
}, new Map<string, Map<string, MiPollVote[]>>)),
// channels
this.getChannels(targetNotes),
// mutedThreads
me ? this.cacheService.threadMutingsCache.fetch(me.id) : new Set<string>(),
// mutedNotes
me ? this.cacheService.noteMutingsCache.fetch(me.id) : new Set<string>(),
// favoriteNotes
me ? this.noteFavoritesRepository
.createQueryBuilder('favorite')
.select('favorite.noteId', 'noteId')
.where({ userId: me.id, noteId: In(noteIds) })
.getRawMany<{ noteId: string }>()
.then(fs => new Set(fs.map(f => f.noteId))) : new Set<string>(),
// renotedNotes
me ? this.queryService
.andIsRenote(this.notesRepository.createQueryBuilder('note'), 'note')
.andWhere({ userId: me.id, renoteId: In(noteIds) })
.select('note.renoteId', 'renoteId')
.getRawMany<{ renoteId: string }>()
.then(ns => new Set(ns.map(n => n.renoteId))) : new Set<string>(),
// (not returned)
this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(notes)),
]);
@ -701,6 +907,10 @@ export class NoteEntityService implements OnModuleInit {
pollVotes,
channels,
notes: new Map(targetNotes.map(n => [n.id, n])),
mutedThreads,
mutedNotes,
favoriteNotes,
renotedNotes,
},
})));
}

View file

@ -432,8 +432,6 @@ export class UserEntityService implements OnModuleInit {
userIdsByUri?: Map<string, string>,
instances?: Map<string, MiInstance | null>,
securityKeyCounts?: Map<string, number>,
pendingReceivedFollows?: Set<string>,
pendingSentFollows?: Set<string>,
},
): Promise<Packed<S>> {
const opts = Object.assign({
@ -679,8 +677,8 @@ export class UserEntityService implements OnModuleInit {
hasUnreadAntenna: this.getHasUnreadAntenna(user.id),
hasUnreadChannel: false, // 後方互換性のため
hasUnreadNotification: notificationsInfo?.hasUnread, // 後方互換性のため
hasPendingReceivedFollowRequest: opts.pendingReceivedFollows?.has(user.id) ?? this.getHasPendingReceivedFollowRequest(user.id),
hasPendingSentFollowRequest: opts.pendingSentFollows?.has(user.id) ?? this.getHasPendingSentFollowRequest(user.id),
hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id),
hasPendingSentFollowRequest: this.getHasPendingSentFollowRequest(user.id),
unreadNotificationsCount: notificationsInfo?.unreadCount,
mutedWords: profile!.mutedWords,
hardMutedWords: profile!.hardMutedWords,
@ -761,11 +759,8 @@ export class UserEntityService implements OnModuleInit {
const iAmModerator = await this.roleService.isModerator(me as MiUser);
const meId = me ? me.id : null;
const isMe = meId && _userIds.includes(meId);
const isDetailed = options && options.schema !== 'UserLite';
const isDetailedAndMe = isDetailed && isMe;
const isDetailedAndMeOrMod = isDetailed && (isMe || iAmModerator);
const isDetailedAndNotMe = isDetailed && !isMe;
const isDetailedAndMod = isDetailed && iAmModerator;
const userUris = new Set(_users
.flatMap(user => [user.uri, user.movedToUri])
@ -787,14 +782,14 @@ export class UserEntityService implements OnModuleInit {
// -- 実行者の有無や指定スキーマの種別によって要否が異なる値群を取得
const [profilesMap, userMemos, userRelations, pinNotes, userIdsByUri, instances, securityKeyCounts, pendingReceivedFollows, pendingSentFollows] = await Promise.all([
const [profilesMap, userMemos, userRelations, pinNotes, userIdsByUri, instances, securityKeyCounts] = await Promise.all([
// profilesMap
this.cacheService.userProfileCache.fetchMany(_profilesToFetch).then(profiles => new Map(profiles.concat(_profilesFromUsers))),
// userMemos
isDetailed && meId ? this.userMemosRepository.findBy({ userId: meId })
.then(memos => new Map(memos.map(memo => [memo.targetUserId, memo.memo]))) : new Map(),
// userRelations
isDetailedAndNotMe && meId ? this.getRelations(meId, _userIds) : new Map(),
isDetailed && meId ? this.getRelations(meId, _userIds) : new Map(),
// pinNotes
isDetailed ? this.userNotePiningsRepository.createQueryBuilder('pin')
.where('pin.userId IN (:...userIds)', { userIds: _userIds })
@ -828,7 +823,7 @@ export class UserEntityService implements OnModuleInit {
Promise.all(Array.from(userHosts).map(async host => [host, await this.federatedInstanceService.fetch(host)] as const))
.then(hosts => new Map(hosts)),
// securityKeyCounts
isDetailedAndMeOrMod ? this.userSecurityKeysRepository.createQueryBuilder('key')
isDetailedAndMod ? this.userSecurityKeysRepository.createQueryBuilder('key')
.select('key.userId', 'userId')
.addSelect('count(key.id)', 'userCount')
.where({
@ -836,26 +831,8 @@ export class UserEntityService implements OnModuleInit {
})
.groupBy('key.userId')
.getRawMany<{ userId: string, userCount: number }>()
.then(counts => new Map(counts.map(c => [c.userId, c.userCount]))) : new Map(),
// TODO optimization: cache follow requests
// pendingReceivedFollows
isDetailedAndMe ? this.followRequestsRepository.createQueryBuilder('req')
.select('req.followeeId', 'followeeId')
.where({
followeeId: In(_userIds),
})
.groupBy('req.followeeId')
.getRawMany<{ followeeId: string }>()
.then(reqs => new Set(reqs.map(r => r.followeeId))) : new Set<string>(),
// pendingSentFollows
isDetailedAndMe ? this.followRequestsRepository.createQueryBuilder('req')
.select('req.followerId', 'followerId')
.where({
followerId: In(_userIds),
})
.groupBy('req.followerId')
.getRawMany<{ followerId: string }>()
.then(reqs => new Set(reqs.map(r => r.followerId))) : new Set<string>(),
.then(counts => new Map(counts.map(c => [c.userId, c.userCount])))
: undefined, // .pack will fetch the keys for the requesting user if it's in the _userIds
]);
return Promise.all(
@ -872,8 +849,6 @@ export class UserEntityService implements OnModuleInit {
userIdsByUri,
instances,
securityKeyCounts,
pendingReceivedFollows,
pendingSentFollows,
},
)),
);

View file

@ -13,13 +13,32 @@ import type { Config } from '@/config.js';
import { QUEUE, baseQueueOptions } from '@/queue/const.js';
import type { OnApplicationShutdown } from '@nestjs/common';
export interface StatsEntry {
activeSincePrevTick: number,
active: number,
waiting: number,
delayed: number,
}
export interface Stats {
deliver: StatsEntry,
inbox: StatsEntry,
}
const ev = new Xev();
const interval = 10000;
@Injectable()
export class QueueStatsService implements OnApplicationShutdown {
private intervalId: NodeJS.Timeout;
private intervalId?: NodeJS.Timeout;
private activeDeliverJobs = 0;
private activeInboxJobs = 0;
private deliverQueueEvents?: Bull.QueueEvents;
private inboxQueueEvents?: Bull.QueueEvents;
private log?: Stats[];
constructor(
@Inject(DI.config)
@ -29,30 +48,39 @@ export class QueueStatsService implements OnApplicationShutdown {
) {
}
@bindThis
private onDeliverActive() {
this.activeDeliverJobs++;
}
@bindThis
private onInboxActive() {
this.activeInboxJobs++;
}
@bindThis
private onRequestQueueStatsLog(x: { id: string, length?: number }) {
if (this.log) {
ev.emit(`queueStatsLog:${x.id}`, this.log.slice(0, x.length ?? 50));
}
}
/**
* Report queue stats regularly
*/
@bindThis
public start(): void {
const log = [] as any[];
public async start() {
// Just in case start gets called repeatedly
await this.stop();
ev.on('requestQueueStatsLog', x => {
ev.emit(`queueStatsLog:${x.id}`, log.slice(0, x.length ?? 50));
});
this.log = [];
ev.on('requestQueueStatsLog', this.onRequestQueueStatsLog);
let activeDeliverJobs = 0;
let activeInboxJobs = 0;
this.deliverQueueEvents = new Bull.QueueEvents(QUEUE.DELIVER, baseQueueOptions(this.config, QUEUE.DELIVER));
this.inboxQueueEvents = new Bull.QueueEvents(QUEUE.INBOX, baseQueueOptions(this.config, QUEUE.INBOX));
const deliverQueueEvents = new Bull.QueueEvents(QUEUE.DELIVER, baseQueueOptions(this.config, QUEUE.DELIVER));
const inboxQueueEvents = new Bull.QueueEvents(QUEUE.INBOX, baseQueueOptions(this.config, QUEUE.INBOX));
deliverQueueEvents.on('active', () => {
activeDeliverJobs++;
});
inboxQueueEvents.on('active', () => {
activeInboxJobs++;
});
this.deliverQueueEvents.on('active', this.onDeliverActive);
this.inboxQueueEvents.on('active', this.onInboxActive);
const tick = async () => {
const deliverJobCounts = await this.queueService.deliverQueue.getJobCounts();
@ -60,13 +88,13 @@ export class QueueStatsService implements OnApplicationShutdown {
const stats = {
deliver: {
activeSincePrevTick: activeDeliverJobs,
activeSincePrevTick: this.activeDeliverJobs,
active: deliverJobCounts.active,
waiting: deliverJobCounts.waiting,
delayed: deliverJobCounts.delayed,
},
inbox: {
activeSincePrevTick: activeInboxJobs,
activeSincePrevTick: this.activeInboxJobs,
active: inboxJobCounts.active,
waiting: inboxJobCounts.waiting,
delayed: inboxJobCounts.delayed,
@ -75,11 +103,13 @@ export class QueueStatsService implements OnApplicationShutdown {
ev.emit('queueStats', stats);
log.unshift(stats);
if (log.length > 200) log.pop();
if (this.log) {
this.log.unshift(stats);
if (this.log.length > 200) this.log.pop();
}
activeDeliverJobs = 0;
activeInboxJobs = 0;
this.activeDeliverJobs = 0;
this.activeInboxJobs = 0;
};
tick();
@ -88,12 +118,32 @@ export class QueueStatsService implements OnApplicationShutdown {
}
@bindThis
public dispose(): void {
clearInterval(this.intervalId);
public async stop() {
if (this.intervalId) {
clearInterval(this.intervalId);
}
this.log = undefined;
ev.off('requestQueueStatsLog', this.onRequestQueueStatsLog);
this.deliverQueueEvents?.off('active', this.onDeliverActive);
this.inboxQueueEvents?.off('active', this.onInboxActive);
await this.deliverQueueEvents?.close();
await this.inboxQueueEvents?.close();
this.activeDeliverJobs = 0;
this.activeInboxJobs = 0;
}
@bindThis
public onApplicationShutdown(signal?: string | undefined): void {
this.dispose();
public async dispose() {
await this.stop();
ev.dispose();
}
@bindThis
public async onApplicationShutdown(signal?: string | undefined) {
await this.dispose();
}
}

View file

@ -12,6 +12,22 @@ import type { OnApplicationShutdown } from '@nestjs/common';
import { MiMeta } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
export interface Stats {
cpu: number,
mem: {
used: number,
active: number,
},
net: {
rx: number,
tx: number,
},
fs: {
r: number,
w: number,
},
}
const ev = new Xev();
const interval = 2000;
@ -23,12 +39,19 @@ const round = (num: number) => Math.round(num * 10) / 10;
export class ServerStatsService implements OnApplicationShutdown {
private intervalId: NodeJS.Timeout | null = null;
private log: Stats[] = [];
constructor(
@Inject(DI.meta)
private meta: MiMeta,
) {
}
@bindThis
private async onRequestStatsLog(x: { id: string, length: number }) {
ev.emit(`serverStatsLog:${x.id}`, this.log.slice(0, x.length));
}
/**
* Report server stats regularly
*/
@ -36,11 +59,8 @@ export class ServerStatsService implements OnApplicationShutdown {
public async start(): Promise<void> {
if (!this.meta.enableServerMachineStats) return;
const log = [] as any[];
ev.on('requestServerStatsLog', x => {
ev.emit(`serverStatsLog:${x.id}`, log.slice(0, x.length));
});
this.log = [];
ev.on('requestServerStatsLog', this.onRequestStatsLog);
const tick = async () => {
const cpu = await cpuUsage();
@ -64,8 +84,8 @@ export class ServerStatsService implements OnApplicationShutdown {
},
};
ev.emit('serverStats', stats);
log.unshift(stats);
if (log.length > 200) log.pop();
this.log.unshift(stats);
if (this.log.length > 200) this.log.pop();
};
tick();
@ -78,6 +98,11 @@ export class ServerStatsService implements OnApplicationShutdown {
if (this.intervalId) {
clearInterval(this.intervalId);
}
this.log = [];
ev.off('requestServerStatsLog', this.onRequestStatsLog);
ev.dispose();
}
@bindThis
@ -89,9 +114,13 @@ export class ServerStatsService implements OnApplicationShutdown {
// CPU STAT
function cpuUsage(): Promise<number> {
return new Promise((res, rej) => {
osUtils.cpuUsage((cpuUsage) => {
res(cpuUsage);
});
try {
osUtils.cpuUsage((cpuUsage) => {
res(cpuUsage);
});
} catch (err) {
rej(err);
}
});
}

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as mfm from '@transfem-org/sfm-js';
import * as mfm from 'mfm-js';
import { unique } from '@/misc/prelude/array.js';
export function extractCustomEmojisFromMfm(nodes: mfm.MfmNode[]): string[] {

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as mfm from '@transfem-org/sfm-js';
import * as mfm from 'mfm-js';
import { unique } from '@/misc/prelude/array.js';
export function extractHashtags(nodes: mfm.MfmNode[]): string[] {

View file

@ -5,7 +5,7 @@
// test is located in test/extract-mentions
import * as mfm from '@transfem-org/sfm-js';
import * as mfm from 'mfm-js';
export function extractMentions(nodes: mfm.MfmNode[]): mfm.MfmMention['props'][] {
// TODO: 重複を削除

View file

@ -67,6 +67,7 @@ import { packedChatMessageSchema, packedChatMessageLiteSchema, packedChatMessage
import { packedChatRoomSchema } from '@/models/json-schema/chat-room.js';
import { packedChatRoomInvitationSchema } from '@/models/json-schema/chat-room-invitation.js';
import { packedChatRoomMembershipSchema } from '@/models/json-schema/chat-room-membership.js';
import { packedAchievementNameSchema, packedAchievementSchema } from '@/models/json-schema/achievement.js';
export const refs = {
UserLite: packedUserLiteSchema,
@ -78,6 +79,8 @@ export const refs = {
User: packedUserSchema,
UserList: packedUserListSchema,
Achievement: packedAchievementSchema,
AchievementName: packedAchievementNameSchema,
Ad: packedAdSchema,
Announcement: packedAnnouncementSchema,
App: packedAppSchema,

View file

@ -3,14 +3,12 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { substring } from 'stringz';
export function truncate(input: string, size: number): string;
export function truncate(input: string | undefined, size: number): string | undefined;
export function truncate(input: string | undefined, size: number): string | undefined {
if (!input) {
return input;
} else {
return substring(input, 0, size);
return input.slice(0, size);
}
}

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { load as cheerio } from 'cheerio';
import { load as cheerio } from 'cheerio/slim';
import type { HttpRequestService } from '@/core/HttpRequestService.js';
type Field = { name: string, value: string };

View file

@ -770,4 +770,14 @@ export class MiMeta {
default: false,
})
public enableProxyAccount: boolean;
@Column('jsonb', {
default: [],
})
public deliverSuspendedSoftware: SoftwareSuspension[];
}
export type SoftwareSuspension = {
software: string,
versionRange: string,
};

View file

@ -11,6 +11,16 @@ import { MiUser } from './User.js';
import { MiChannel } from './Channel.js';
import type { MiDriveFile } from './DriveFile.js';
// Note: When you create a new index for existing column of this table,
// it might be better to index concurrently under isConcurrentIndexMigrationEnabled flag
// by editing generated migration file since this table is very large,
// and it will make a long lock to create index in most cases.
// Please note that `CREATE INDEX CONCURRENTLY` is not supported in transaction,
// so you need to set `transaction = false` in migration if isConcurrentIndexMigrationEnabled() is true.
// Please refer 1745378064470-composite-note-index.js for example.
// You should not use `@Index({ concurrent: true })` decorator because database initialization for test will fail
// because it will always run CREATE INDEX in transaction based on decorators.
// Not appending `{ concurrent: true }` to `@Index` will not cause any problem in production,
@Index('IDX_724b311e6f883751f261ebe378', ['userId', 'id'])
@Index('IDX_note_userHost_id', { synchronize: false }) // (userHost, id desc)
@Index('IDX_note_for_timelines', { synchronize: false }) // (id desc, channelId, visibility, userHost)

View file

@ -8,7 +8,7 @@ import { id } from './util/id.js';
import { MiUser } from './User.js';
@Entity('note_thread_muting')
@Index(['userId', 'threadId'], { unique: true })
@Index(['userId', 'threadId', 'isPostMute'], { unique: true })
export class MiNoteThreadMuting {
@PrimaryColumn(id())
public id: string;
@ -30,4 +30,10 @@ export class MiNoteThreadMuting {
length: 256,
})
public threadId: string;
@Column('boolean', {
comment: 'If true, then this mute applies only to the referenced note. If false (default), then it applies to all replies as well.',
default: false,
})
public isPostMute: boolean;
}

View file

@ -7,8 +7,8 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typ
import { id } from './util/id.js';
import { MiUser } from './User.js';
// TODO: 同じdomain、同じscope、同じkeyのレコードは二つ以上存在しないように制約付けたい
@Entity('registry_item')
@Index(['userId', 'key', 'scope', 'domain'], { unique: true })
export class MiRegistryItem {
@PrimaryColumn(id())
public id: string;

View file

@ -433,7 +433,7 @@ export type MiPartialRemoteUser = Partial<MiUser> & {
export const localUsernameSchema = { type: 'string', pattern: /^\w{1,20}$/.toString().slice(1, -1) } as const;
export const passwordSchema = { type: 'string', minLength: 1 } as const;
export const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as const;
export const descriptionSchema = { type: 'string', minLength: 1, maxLength: 1500 } as const;
export const descriptionSchema = { type: 'string', minLength: 1 } as const;
export const followedMessageSchema = { type: 'string', minLength: 1, maxLength: 256 } as const;
export const locationSchema = { type: 'string', minLength: 1, maxLength: 50 } as const;
export const listenbrainzSchema = { type: 'string', minLength: 1, maxLength: 128 } as const;

View file

@ -43,8 +43,8 @@ export class MiUserProfile {
})
public listenbrainz: string | null;
@Column('varchar', {
length: 2048, nullable: true,
@Column('text', {
nullable: true,
comment: 'The description (bio) of the User.',
})
public description: string | null;
@ -288,7 +288,7 @@ export class MiUserProfile {
default: [],
})
public achievements: {
name: string;
name: typeof ACHIEVEMENT_TYPES[number];
unlockedAt: number;
}[];
@ -322,3 +322,84 @@ export class MiUserProfile {
}
}
}
export const ACHIEVEMENT_TYPES = [
'notes1',
'notes10',
'notes100',
'notes500',
'notes1000',
'notes5000',
'notes10000',
'notes20000',
'notes30000',
'notes40000',
'notes50000',
'notes60000',
'notes70000',
'notes80000',
'notes90000',
'notes100000',
'login3',
'login7',
'login15',
'login30',
'login60',
'login100',
'login200',
'login300',
'login400',
'login500',
'login600',
'login700',
'login800',
'login900',
'login1000',
'passedSinceAccountCreated1',
'passedSinceAccountCreated2',
'passedSinceAccountCreated3',
'loggedInOnBirthday',
'loggedInOnNewYearsDay',
'noteClipped1',
'noteFavorited1',
'myNoteFavorited1',
'profileFilled',
'markedAsCat',
'following1',
'following10',
'following50',
'following100',
'following300',
'followers1',
'followers10',
'followers50',
'followers100',
'followers300',
'followers500',
'followers1000',
'collectAchievements30',
'viewAchievements3min',
'iLoveMisskey',
'foundTreasure',
'client30min',
'client60min',
'noteDeletedWithin1min',
'postedAtLateNight',
'postedAt0min0sec',
'selfQuote',
'htl20npm',
'viewInstanceChart',
'outputHelloWorldOnScratchpad',
'open3windows',
'driveFolderCircularReference',
'reactWithoutRead',
'clickedClickHere',
'justPlainLucky',
'setNameToSyuilo',
'cookieClicked',
'brainDiver',
'smashTestNotificationButton',
'tutorialCompleted',
'bubbleGameExplodingHead',
'bubbleGameDoubleExplodingHead',
] as const;

View file

@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ACHIEVEMENT_TYPES } from '@/models/UserProfile.js';
export const packedAchievementNameSchema = {
type: 'string',
enum: ACHIEVEMENT_TYPES,
optional: false,
} as const;
export const packedAchievementSchema = {
type: 'object',
properties: {
name: {
ref: 'AchievementName',
},
unlockedAt: {
type: 'number',
optional: false,
},
},
} as const;

View file

@ -48,7 +48,7 @@ export const packedFederationInstanceSchema = {
suspensionState: {
type: 'string',
nullable: false, optional: false,
enum: ['none', 'manuallySuspended', 'goneSuspended', 'autoSuspendedForNotResponding'],
enum: ['none', 'manuallySuspended', 'goneSuspended', 'autoSuspendedForNotResponding', 'softwareSuspended'],
},
isBlocked: {
type: 'boolean',

View file

@ -206,6 +206,14 @@ export const packedMetaLiteSchema = {
type: 'number',
optional: false, nullable: false,
},
maxBioLength: {
type: 'number',
optional: false, nullable: false,
},
maxRemoteBioLength: {
type: 'number',
optional: false, nullable: false,
},
ads: {
type: 'array',
optional: false, nullable: false,

View file

@ -12,6 +12,12 @@ export const packedNoteSchema = {
format: 'id',
example: 'xxxxxxxxxx',
},
threadId: {
type: 'string',
optional: false, nullable: false,
format: 'id',
example: 'xxxxxxxxxx',
},
createdAt: {
type: 'string',
optional: false, nullable: false,
@ -167,6 +173,22 @@ export const packedNoteSchema = {
},
},
},
isMutingThread: {
type: 'boolean',
optional: false, nullable: false,
},
isMutingNote: {
type: 'boolean',
optional: false, nullable: false,
},
isFavorited: {
type: 'boolean',
optional: false, nullable: false,
},
isRenoted: {
type: 'boolean',
optional: false, nullable: false,
},
emojis: {
type: 'object',
optional: true, nullable: false,

View file

@ -3,7 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ACHIEVEMENT_TYPES } from '@/core/AchievementService.js';
import { notificationTypes, userExportableEntities } from '@/types.js';
const baseSchema = {
@ -312,9 +311,7 @@ export const packedNotificationSchema = {
enum: ['achievementEarned'],
},
achievement: {
type: 'string',
optional: false, nullable: false,
enum: ACHIEVEMENT_TYPES,
ref: 'AchievementName',
},
},
}, {

View file

@ -64,6 +64,20 @@ export const packedUserLiteSchema = {
example: 'misskey.example.com',
description: 'The local host is represented with `null`.',
},
createdAt: {
type: 'string',
nullable: false, optional: false,
format: 'date-time',
},
approved: {
type: 'boolean',
nullable: false, optional: false,
},
description: {
type: 'string',
nullable: true, optional: false,
example: 'Hi masters, I am Ai!',
},
avatarUrl: {
type: 'string',
format: 'url',
@ -206,6 +220,18 @@ export const packedUserLiteSchema = {
},
},
},
followersCount: {
type: 'number',
nullable: false, optional: false,
},
followingCount: {
type: 'number',
nullable: false, optional: false,
},
notesCount: {
type: 'number',
nullable: false, optional: false,
},
emojis: {
type: 'object',
nullable: false, optional: false,
@ -278,11 +304,6 @@ export const packedUserDetailedNotMeOnlySchema = {
nullable: false, optional: false,
},
},
createdAt: {
type: 'string',
nullable: false, optional: false,
format: 'date-time',
},
updatedAt: {
type: 'string',
nullable: true, optional: false,
@ -324,11 +345,6 @@ export const packedUserDetailedNotMeOnlySchema = {
nullable: false, optional: false,
example: false,
},
description: {
type: 'string',
nullable: true, optional: false,
example: 'Hi masters, I am Ai!',
},
location: {
type: 'string',
nullable: true, optional: false,
@ -377,18 +393,6 @@ export const packedUserDetailedNotMeOnlySchema = {
format: 'url',
},
},
followersCount: {
type: 'number',
nullable: false, optional: false,
},
followingCount: {
type: 'number',
nullable: false, optional: false,
},
notesCount: {
type: 'number',
nullable: false, optional: false,
},
pinnedNoteIds: {
type: 'array',
nullable: false, optional: false,
@ -715,18 +719,7 @@ export const packedMeDetailedOnlySchema = {
type: 'array',
nullable: false, optional: false,
items: {
type: 'object',
nullable: false, optional: false,
properties: {
name: {
type: 'string',
nullable: false, optional: false,
},
unlockedAt: {
type: 'number',
nullable: false, optional: false,
},
},
ref: 'Achievement',
},
},
loggedInDays: {
@ -762,6 +755,10 @@ export const packedMeDetailedOnlySchema = {
type: 'boolean',
nullable: true, optional: true,
},
signupReason: {
type: 'string',
nullable: true, optional: true,
},
securityKeysList: {
type: 'array',
nullable: false, optional: true,

View file

@ -612,6 +612,8 @@ export class QueueProcessorService implements OnApplicationShutdown {
@bindThis
public async onApplicationShutdown(signal?: string | undefined): Promise<void> {
this.logger.info('Stopping BullMQ workers...');
await this.stop();
this.logger.info('Workers disposed.');
}
}

View file

@ -4,14 +4,17 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import { IsNull, MoreThan, Not } from 'typeorm';
import { IsNull, MoreThan, Not, Brackets } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { MiDriveFile, DriveFilesRepository } from '@/models/_.js';
import { MiUser } from '@/models/_.js';
import type Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js';
import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq';
import type { CleanRemoteFilesJobData } from '../types.js';
import { IdService } from '@/core/IdService.js';
@Injectable()
export class CleanRemoteFilesProcessorService {
@ -23,35 +26,54 @@ export class CleanRemoteFilesProcessorService {
private driveService: DriveService,
private queueLoggerService: QueueLoggerService,
private idService: IdService,
) {
this.logger = this.queueLoggerService.logger.createSubLogger('clean-remote-files');
}
@bindThis
public async process(job: Bull.Job<Record<string, unknown>>): Promise<void> {
public async process(job: Bull.Job<CleanRemoteFilesJobData>): Promise<void> {
this.logger.info('Deleting cached remote files...');
const olderThanTimestamp = Date.now() - (job.data.olderThanSeconds ?? 0) * 1000;
const olderThanDate = new Date(olderThanTimestamp);
const keepFilesInUse = job.data.keepFilesInUse ?? false;
let deletedCount = 0;
let cursor: MiDriveFile['id'] | null = null;
let errorCount = 0;
const total = await this.driveFilesRepository.countBy({
userHost: Not(IsNull()),
isLink: false,
});
const filesQuery = this.driveFilesRepository.createQueryBuilder('file')
.where('file.userHost IS NOT NULL') // remote files
.andWhere('file.isLink = FALSE') // cached
.andWhere('file.id <= :id', { id: this.idService.gen(olderThanTimestamp) }) // and old
.orderBy('file.id', 'ASC');
if (keepFilesInUse) {
filesQuery
// are they used as avatar&&c?
.leftJoinAndSelect(
MiUser, 'fileuser',
'fileuser."avatarId"="file"."id" OR fileuser."bannerId"="file"."id" OR fileuser."backgroundId"="file"."id"'
)
.andWhere(
new Brackets((qb) => {
qb.where('fileuser.id IS NULL') // not used
.orWhere( // or attached to a user
new Brackets((qb) => {
qb.where('fileuser.lastFetchedAt IS NOT NULL') // weird? maybe this only applies to local users
.andWhere('fileuser.lastFetchedAt < :old', { old: olderThanDate }); // old user
})
);
})
);
}
const total = await filesQuery.clone().getCount();
while (true) {
const files = await this.driveFilesRepository.find({
where: {
userHost: Not(IsNull()),
isLink: false,
...(cursor ? { id: MoreThan(cursor) } : {}),
},
take: 256,
order: {
id: 1,
},
});
const thisBatchQuery = filesQuery.clone();
if (cursor) thisBatchQuery.andWhere('file.id > :cursor', { cursor });
const files = await thisBatchQuery.take(256).getMany();
if (files.length === 0) {
job.updateProgress(100);

View file

@ -71,6 +71,15 @@ export class DeliverProcessorService {
return 'skip (suspended)';
}
const i = await (this.meta.enableStatsForFederatedInstances
? this.federatedInstanceService.fetchOrRegister(host)
: this.federatedInstanceService.fetch(host));
// suspend server by software
if (i != null && this.utilityService.isDeliverSuspendedSoftware(i)) {
return 'skip (software suspended)';
}
try {
await this.apRequestService.signedPost(job.data.user, job.data.to, job.data.content, job.data.digest);
@ -79,10 +88,6 @@ export class DeliverProcessorService {
// Update instance stats
process.nextTick(async () => {
const i = await (this.meta.enableStatsForFederatedInstances
? this.federatedInstanceService.fetchOrRegister(host)
: this.federatedInstanceService.fetch(host));
if (i == null) return;
if (i.isNotResponding) {

View file

@ -40,6 +40,11 @@ export type RelationshipJobData = {
withReplies?: boolean;
};
export type CleanRemoteFilesJobData = {
keepFilesInUse: boolean;
olderThanSeconds: number;
};
export type DbJobData<T extends keyof DbJobMap> = DbJobMap[T];
export type DbJobMap = {

View file

@ -33,7 +33,7 @@ import type Logger from '@/logger.js';
import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js';
import { IActivity, IAnnounce, ICreate } from '@/core/activitypub/type.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
import { isPureRenote, isQuote, isRenote } from '@/misc/is-renote.js';
import * as Acct from '@/misc/acct.js';
import { CacheService } from '@/core/CacheService.js';
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify';
@ -571,7 +571,7 @@ export class ActivityPubServerService {
const pinnedNotes = (await Promise.all(pinings.map(pining =>
this.notesRepository.findOneByOrFail({ id: pining.noteId }))))
.filter(note => !note.localOnly && ['public', 'home'].includes(note.visibility));
.filter(note => !note.localOnly && ['public', 'home'].includes(note.visibility) && !isPureRenote(note));
const renderedNotes = await Promise.all(pinnedNotes.map(note => this.apRendererService.renderNote(note, user)));
@ -791,6 +791,10 @@ export class ActivityPubServerService {
reply.header('Access-Control-Allow-Origin', '*');
reply.header('Access-Control-Expose-Headers', 'Vary');
// Tell crawlers not to index AP endpoints.
// https://developers.google.com/search/docs/crawling-indexing/block-indexing
reply.header('X-Robots-Tag', 'noindex');
/* tell any caching proxy that they should not cache these
responses: we wouldn't want the proxy to return a 403 to
someone presenting a valid signature, or return a cached
@ -838,6 +842,11 @@ export class ActivityPubServerService {
return;
}
// Boosts don't federate directly - they should only be referenced as an activity
if (isPureRenote(note)) {
return 404;
}
this.setResponseType(request, reply);
const author = await this.usersRepository.findOneByOrFail({ id: note.userId });

View file

@ -70,6 +70,10 @@ export class FileServerService {
fastify.addHook('onRequest', (request, reply, done) => {
reply.header('Content-Security-Policy', 'default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
reply.header('Access-Control-Allow-Origin', '*');
// Tell crawlers not to index files endpoints.
// https://developers.google.com/search/docs/crawling-indexing/block-indexing
reply.header('X-Robots-Tag', 'noindex');
done();
});

View file

@ -128,6 +128,8 @@ export class NodeinfoServerService {
maxRemoteCwLength: this.config.maxRemoteCwLength,
maxAltTextLength: this.config.maxAltTextLength,
maxRemoteAltTextLength: this.config.maxRemoteAltTextLength,
maxBioLength: this.config.maxBioLength,
maxRemoteBioLength: this.config.maxRemoteBioLength,
enableEmail: meta.enableEmail,
enableServiceWorker: meta.enableServiceWorker,
proxyAccountName: proxyAccount.username,

View file

@ -309,8 +309,13 @@ export class ServerService implements OnApplicationShutdown {
@bindThis
public async dispose(): Promise<void> {
this.logger.info('Disconnecting WebSocket clients...');
await this.streamingApiServerService.detach();
this.logger.info('Disconnecting HTTP clients....;');
await this.#fastify.close();
this.logger.info('Server disposed.');
}
/**

View file

@ -148,6 +148,10 @@ export class ApiCallService implements OnApplicationShutdown {
request: FastifyRequest<{ Body: Record<string, unknown> | undefined, Querystring: Record<string, unknown> }>,
reply: FastifyReply,
): void {
// Tell crawlers not to index API endpoints.
// https://developers.google.com/search/docs/crawling-indexing/block-indexing
reply.header('X-Robots-Tag', 'noindex');
const body = request.method === 'GET'
? request.query
: request.body;

View file

@ -147,7 +147,7 @@ export class SignupApiService {
let ticket: MiRegistrationTicket | null = null;
if (this.meta.disableRegistration) {
if (this.meta.disableRegistration && process.env.NODE_ENV !== 'test') {
if (invitationCode == null || typeof invitationCode !== 'string') {
reply.code(400);
return;

View file

@ -4,13 +4,12 @@
*/
import { EventEmitter } from 'events';
import { Inject, Injectable } from '@nestjs/common';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import * as Redis from 'ioredis';
import * as WebSocket from 'ws';
import proxyAddr from 'proxy-addr';
import ms from 'ms';
import { DI } from '@/di-symbols.js';
import type { UsersRepository, MiAccessToken, MiUser } from '@/models/_.js';
import type { UsersRepository, MiAccessToken, MiUser, NoteReactionsRepository, NotesRepository, NoteFavoritesRepository } from '@/models/_.js';
import type { Config } from '@/config.js';
import type { Keyed, RateLimit } from '@/misc/rate-limit-utils.js';
import { NotificationService } from '@/core/NotificationService.js';
@ -22,6 +21,7 @@ import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
import { getIpHash } from '@/misc/get-ip-hash.js';
import { LoggerService } from '@/core/LoggerService.js';
import { SkRateLimiterService } from '@/server/SkRateLimiterService.js';
import { QueryService } from '@/core/QueryService.js';
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
import MainStreamConnection from './stream/Connection.js';
import { ChannelsService } from './stream/ChannelsService.js';
@ -32,11 +32,12 @@ import type * as http from 'node:http';
const MAX_CONNECTIONS_PER_CLIENT = 32;
@Injectable()
export class StreamingApiServerService {
export class StreamingApiServerService implements OnApplicationShutdown {
#wss: WebSocket.WebSocketServer;
#connections = new Map<WebSocket.WebSocket, number>();
#connectionsByClient = new Map<string, Set<WebSocket.WebSocket>>(); // key: IP / user ID -> value: connection
#cleanConnectionsIntervalId: NodeJS.Timeout | null = null;
readonly #globalEv = new EventEmitter();
constructor(
@Inject(DI.redisForSub)
@ -45,6 +46,16 @@ export class StreamingApiServerService {
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.noteReactionsRepository)
private readonly noteReactionsRepository: NoteReactionsRepository,
@Inject(DI.notesRepository)
private readonly notesRepository: NotesRepository,
@Inject(DI.noteFavoritesRepository)
private readonly noteFavoritesRepository: NoteFavoritesRepository,
private readonly queryService: QueryService,
private cacheService: CacheService,
private authenticateService: AuthenticateService,
private channelsService: ChannelsService,
@ -57,6 +68,14 @@ export class StreamingApiServerService {
@Inject(DI.config)
private config: Config,
) {
this.redisForSub.on('message', this.onRedis);
}
@bindThis
onApplicationShutdown() {
this.redisForSub.off('message', this.onRedis);
this.#globalEv.removeAllListeners();
// Other shutdown logic is handled by detach(), which gets called by ServerServer's own shutdown handler.
}
@bindThis
@ -69,6 +88,12 @@ export class StreamingApiServerService {
return rateLimit.blocked;
}
@bindThis
private onRedis(_: string, data: string) {
const parsed = JSON.parse(data);
this.#globalEv.emit('message', parsed);
}
@bindThis
public attach(server: http.Server): void {
this.#wss = new WebSocket.WebSocketServer({
@ -168,6 +193,10 @@ export class StreamingApiServerService {
};
const stream = new MainStreamConnection(
this.noteReactionsRepository,
this.notesRepository,
this.noteFavoritesRepository,
this.queryService,
this.channelsService,
this.notificationService,
this.cacheService,
@ -199,13 +228,6 @@ export class StreamingApiServerService {
});
});
const globalEv = new EventEmitter();
this.redisForSub.on('message', (_: string, data: string) => {
const parsed = JSON.parse(data);
globalEv.emit('message', parsed);
});
this.#wss.on('connection', async (connection: WebSocket.WebSocket, request: http.IncomingMessage, ctx: {
stream: MainStreamConnection,
user: MiLocalUser | null;
@ -219,12 +241,13 @@ export class StreamingApiServerService {
ev.emit(data.channel, data.message);
}
globalEv.on('message', onRedisMessage);
this.#globalEv.on('message', onRedisMessage);
await stream.listen(ev, connection);
this.#connections.set(connection, Date.now());
// TODO use collapsed queue
const userUpdateIntervalId = user ? setInterval(() => {
this.usersService.updateLastActiveDate(user);
}, 1000 * 60 * 5) : null;
@ -235,7 +258,7 @@ export class StreamingApiServerService {
connection.once('close', () => {
ev.removeAllListeners();
stream.dispose();
globalEv.off('message', onRedisMessage);
this.#globalEv.off('message', onRedisMessage);
this.#connections.delete(connection);
if (userUpdateIntervalId) clearInterval(userUpdateIntervalId);
});
@ -260,13 +283,24 @@ export class StreamingApiServerService {
}
@bindThis
public detach(): Promise<void> {
public async detach(): Promise<void> {
if (this.#cleanConnectionsIntervalId) {
clearInterval(this.#cleanConnectionsIntervalId);
this.#cleanConnectionsIntervalId = null;
}
return new Promise((resolve) => {
this.#wss.close(() => resolve());
for (const connection of this.#connections.keys()) {
connection.close();
}
this.#connections.clear();
this.#connectionsByClient.clear();
await new Promise<void>((resolve, reject) => {
this.#wss.close(err => {
if (err) reject(err);
else resolve();
});
});
}
}

View file

@ -88,6 +88,7 @@ export * as 'admin/reset-password' from './endpoints/admin/reset-password.js';
export * as 'admin/resolve-abuse-user-report' from './endpoints/admin/resolve-abuse-user-report.js';
export * as 'admin/roles/assign' from './endpoints/admin/roles/assign.js';
export * as 'admin/roles/create' from './endpoints/admin/roles/create.js';
export * as 'admin/roles/clone' from './endpoints/admin/roles/clone.js';
export * as 'admin/roles/delete' from './endpoints/admin/roles/delete.js';
export * as 'admin/roles/list' from './endpoints/admin/roles/list.js';
export * as 'admin/roles/show' from './endpoints/admin/roles/show.js';

View file

@ -18,7 +18,10 @@ export const meta = {
export const paramDef = {
type: 'object',
properties: {},
properties: {
olderThanSeconds: { type: 'number' },
keepFilesInUse: { type: 'boolean' },
},
required: [],
} as const;
@ -30,7 +33,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) {
super(meta, paramDef, async (ps, me) => {
await this.moderationLogService.log(me, 'clearRemoteFiles', {});
await this.queueService.createCleanRemoteFilesJob();
await this.queueService.createCleanRemoteFilesJob(
ps.olderThanSeconds ?? 0,
ps.keepFilesInUse ?? false,
);
});
}
}

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