Compare commits

...

30 Commits

Author SHA1 Message Date
zyoshoka 6247e9bbc7
Merge 1003caecd9 into 3eaa05a5d9 2025-05-04 01:52:43 +00:00
github-actions[bot] 3eaa05a5d9 Bump version to 2025.5.0-alpha.0 2025-05-04 00:41:28 +00:00
syuilo b0b6962661
New Crowdin updates (#15922)
* New translations ja-jp.yml (Catalan)

* New translations ja-jp.yml (English)

* New translations ja-jp.yml (Chinese Simplified)

* New translations ja-jp.yml (Chinese Simplified)

* New translations ja-jp.yml (Chinese Traditional)

* New translations ja-jp.yml (Catalan)

* New translations ja-jp.yml (German)

* New translations ja-jp.yml (English)
2025-05-04 09:40:06 +09:00
おさむのひと 8c0c503c6c
refactor: ファイルアップロード時のテストを追加 (#15928)
* refactor: ファイルアップロード時のテストを追加

* なぜかsemverが消えてた
2025-05-04 09:38:35 +09:00
syuilo 590cb5f4dd perf(frontend): improve MkInstanceTicker rendering performance 2025-05-04 06:43:53 +09:00
syuilo 90e69f4d10
add note 2025-05-03 21:51:58 +09:00
syuilo e76e2534d7 perf(frontend): improve MkPullToRefresh render performance 2025-05-03 21:40:18 +09:00
syuilo 27682b980c
tweak MkPullToRefresh.vue 2025-05-03 21:14:59 +09:00
syuilo ef79cc290f perf(frontend): tweak PageWithHeader 2025-05-03 20:15:18 +09:00
syuilo e7c170cf0c tweak MkPullToRefresh 2025-05-03 18:35:43 +09:00
syuilo f0544ede87 tweak MkPullToRefresh 2025-05-03 16:51:23 +09:00
syuilo 00008d3763
Update CHANGELOG.md 2025-05-03 16:25:09 +09:00
おさむのひと 526057cc61
Revert "fix: 添付ファイルのあるリクエストを受けたときの初動を改善 (#15896)" (#15927)
* Revert "fix: 添付ファイルのあるリクエストを受けたときの初動を改善 (#15896)"

This reverts commit 7e8cc4d7c0.

* fix CHANGELOG.md
2025-05-03 16:23:06 +09:00
おさむのひと c13aa0c224
fix(backend): チャンネルフォロー一覧のsinceId/untilIdによる絞り込みが上手く動いていないのを修正 (#13698)
* fix(backend): チャンネルフォロー一覧のsinceId/untilIdによる絞り込みが上手く動いていないのを修正

* fix CHANGELOG.md

* docs(changelog): fix mistaken changelog insertion (restore newline)

* docs(changelog): update insertion position

---------

Co-authored-by: Sayamame-beans <61457993+Sayamame-beans@users.noreply.github.com>
2025-05-03 15:40:57 +09:00
anatawa12 1af98b690b
feat: CREATE INDEX CONCURRENTLY for "userId" "id" composite note index if admin wish. (#15915)
* chore: CREATE INDEX CONCURRENTLY for "userId" "id" composite note index

* chore: remove { concurrent: true } and comment why

* update comment

* feat: add MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY option

* fix: spdx license header

* alter comment

* chore: improve behavior when migration failure

* docs(changelog): 2025.4.1 で追加されたインデックスの再生成をノートの追加しながら行えるようになりました

* ちょっと表現を変更

---------

Co-authored-by: 饺子w (Yumechi) <35571479+eternal-flame-ad@users.noreply.github.com>
2025-05-03 12:57:50 +09:00
syuilo d25af911cf fix(frontend): tweak universal ui rendering 2025-05-03 11:19:55 +09:00
syuilo df1a3742dd feat(frontend): マウスでもタイムラインを引っ張って更新できるように & MkPullToRefreshのパフォーマンス向上 2025-05-03 10:26:40 +09:00
syuilo c5235a7b2f perf(frontend): improve timeline page performance 2025-05-02 20:25:51 +09:00
anatawa12 795b8366b5
Block deliver by software (#15727)
* feat(backend): suspend instance by software

* feat(frontend): suspend instance by software

* docs(chaangelog): 連合先のソフトウェア及びバージョン名により配信停止を行えるようになりました

* chore: 例で使うバージョン名を変える

* fix: broken lockfile

* fix: broken lock file

* fix broken lock file

* update changelog

* fix dependencies

* Update CHANGELOG.md

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2025-05-01 17:58:34 +09:00
anatawa12 2fcb50273d
Exclude suspended users note from most timelines (#15775)
* feat: exclude notes by suspended user from FTT timeline endpoint

* feat: exclude notes by suspended user from DB based timelines

* chore: fix types

* chore: fix types

* chore: fix non-reply / renote

* chore: fix non-reply / renote

* test: update test

* docs(changelog): 凍結されたユーザのノートが各種タイムラインで表示されないように

* Exclude suspended users note from featured

* fix: join user

* Update CHANGELOG.md

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2025-05-01 17:56:24 +09:00
github-actions[bot] 70232d3d73 [skip ci] Update CHANGELOG.md (prepend template) 2025-04-30 09:01:49 +00:00
github-actions[bot] 979cfc1bcd Release: 2025.4.1 2025-04-30 09:01:43 +00:00
github-actions[bot] 2bd4faafbc Bump version to 2025.4.1-rc.0 2025-04-30 00:15:24 +00:00
zyoshoka 1003caecd9
fix(frontend): allow only plain MFM on channel description 2024-09-29 19:09:49 +09:00
zyoshoka d432a26d5b
Update CHANGELOG.md 2024-09-29 18:48:10 +09:00
zyoshoka ac4fb4fc3d
Merge branch 'develop' into fix-mfm-doesnt-work-in-some-places 2024-09-29 18:47:26 +09:00
zyoshoka 394f6c09cb
Merge branch 'develop' into fix-mfm-doesnt-work-in-some-places 2024-08-17 12:46:36 +09:00
zyoshoka bd68b09fcf
Update CHANGELOG.md 2024-08-14 16:06:25 +09:00
zyoshoka 896352bdfd
fix(frontend): fix MFM doesn't work in main column name 2024-08-14 16:06:05 +09:00
zyoshoka 10dbd8f07f
fix(frontend): MFM doesn't work in some places 2024-08-14 15:43:32 +09:00
74 changed files with 699 additions and 330 deletions

View File

@ -1,3 +1,25 @@
## 2025.5.0
### General
-
### Client
- Feat: マウスでもタイムラインを引っ張って更新できるように
- アクセシビリティ設定からオフにすることもできます
- Enhance: タイムラインのパフォーマンスを向上
### 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: ファイルのアップロードに失敗することがある問題を修正
## 2025.4.1
### General
@ -461,6 +483,7 @@
- Fix: ファイルの詳細ページのファイルの説明で改行が正しく表示されない問題を修正
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/commit/bde6bb0bd2e8b0d027e724d2acdb8ae0585a8110)
- Fix: 一部画面のページネーションが動作しにくくなっていたのを修正 ( #12766 , #11449 )
- Fix: MFMが機能すべき箇所で機能していないところがある問題を修正
### Server
- Feat: Misskey® Reactions Boost Technology™ (RBT)により、リアクションの作成負荷を低減することが可能に

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)"
@ -1423,6 +1424,7 @@ _settings:
ifOn: "Quan s'activa"
ifOff: "Quan es desactiva"
enableSyncThemesBetweenDevices: "Sincronitzar els temes instal·lats entre dispositius"
enablePullToRefresh: "Lliscar i actualitzar "
_chat:
showSenderName: "Mostrar el nom del remitent"
sendOnEnter: "Introdueix per enviar"
@ -1468,6 +1470,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 +1602,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)"
@ -1423,6 +1424,7 @@ _settings:
ifOn: "Wenn eingeschaltet"
ifOff: "Wenn ausgeschaltet"
enableSyncThemesBetweenDevices: "Synchronisierung von installierten Themen auf verschiedenen Endgeräten"
enablePullToRefresh: "Ziehen zum Aktualisieren"
_chat:
showSenderName: "Name des Absenders anzeigen"
sendOnEnter: "Eingabetaste sendet Nachricht"
@ -1468,6 +1470,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 +1602,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)"
@ -1423,6 +1424,7 @@ _settings:
ifOn: "When turned on"
ifOff: "When turned off"
enableSyncThemesBetweenDevices: "Synchronize installed themes across devices"
enablePullToRefresh: "Pull to Refresh"
_chat:
showSenderName: "Show sender's name"
sendOnEnter: "Press Enter to send"
@ -1468,6 +1470,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 +1602,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"

20
locales/index.d.ts vendored
View File

@ -898,6 +898,10 @@ export interface Locale extends ILocale {
*
*/
"software": string;
/**
*
*/
"softwareName": string;
/**
*
*/
@ -5705,6 +5709,10 @@ export interface Locale extends ILocale {
*
*/
"enableSyncThemesBetweenDevices": string;
/**
*
*/
"enablePullToRefresh": string;
"_chat": {
/**
*
@ -5871,6 +5879,10 @@ export interface Locale extends ILocale {
*
*/
"autoSuspendedForNotResponding": string;
/**
*
*/
"softwareSuspended": string;
};
};
"_bubbleGame": {
@ -6356,6 +6368,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;
};
"_accountMigration": {
/**

View File

@ -220,6 +220,7 @@ silenceThisInstance: "サーバーをサイレンス"
mediaSilenceThisInstance: "サーバーをメディアサイレンス"
operations: "操作"
software: "ソフトウェア"
softwareName: "ソフトウェア名"
version: "バージョン"
metadata: "メタデータ"
withNFiles: "{n}つのファイル"
@ -1426,6 +1427,7 @@ _settings:
ifOn: "オンのとき"
ifOff: "オフのとき"
enableSyncThemesBetweenDevices: "デバイス間でインストールしたテーマを同期"
enablePullToRefresh: "ひっぱって更新"
_chat:
showSenderName: "送信者の名前を表示"
@ -1477,6 +1479,7 @@ _delivery:
manuallySuspended: "手動停止中"
goneSuspended: "サーバー削除のため停止中"
autoSuspendedForNotResponding: "サーバー応答なしのため停止中"
softwareSuspended: "配信停止中のソフトウェアであるため停止中"
_bubbleGame:
howToPlay: "遊び方"
@ -1615,6 +1618,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

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

View File

@ -220,6 +220,7 @@ silenceThisInstance: "禁言此伺服器"
mediaSilenceThisInstance: "將這個伺服器的媒體設為禁言"
operations: "操作"
software: "軟體"
softwareName: "軟體名稱"
version: "版本"
metadata: "詮釋資料"
withNFiles: "{n} 個檔案"
@ -1423,6 +1424,7 @@ _settings:
ifOn: "開啟時"
ifOff: "關閉時"
enableSyncThemesBetweenDevices: "在裝置之間同步已安裝的主題"
enablePullToRefresh: "下拉更新"
_chat:
showSenderName: "顯示發送者的名稱"
sendOnEnter: "按下 Enter 發送訊息"
@ -1468,6 +1470,7 @@ _delivery:
manuallySuspended: "手動暫停中"
goneSuspended: "因為伺服器刪除所以暫停中"
autoSuspendedForNotResponding: "因為伺服器沒有回應所以暫停中"
softwareSuspended: "此軟體因已停止發佈,目前無法使用"
_bubbleGame:
howToPlay: "玩法說明"
hold: "保留"
@ -1599,6 +1602,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": "misskey",
"version": "2025.4.1-beta.9",
"version": "2025.5.0-alpha.0",
"codename": "nasubi",
"repository": {
"type": "git",

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 || 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,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,4 +15,5 @@ export default new DataSource({
extra: config.db.extra,
entities: entities,
migrations: ['migration/*.js'],
migrationsTransactionMode: isConcurrentIndexMigrationEnabled() ? 'each' : 'all',
});

View File

@ -169,6 +169,7 @@
"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",

View File

@ -24,8 +24,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.log(e);
throw e;
}
},
inject: [DI.config],
};

View File

@ -36,6 +36,7 @@ type TimelineOptions = {
excludeNoFiles?: boolean;
excludeReplies?: boolean;
excludePureRenotes: boolean;
ignoreAuthorFromUserSuspension?: boolean;
dbFallback: (untilId: string | null, sinceId: string | null, limit: number) => Promise<MiNote[]>,
};
@ -139,6 +140,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

@ -43,29 +43,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;
}
@ -287,4 +294,26 @@ export class QueryService {
.andWhere(instanceSuspension('renoteUser'));
}
}
// 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

@ -235,6 +235,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);
@ -297,11 +298,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,10 +6,12 @@
import { URL, domainToASCII } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import RE2 from 're2';
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 {
@ -143,4 +145,20 @@ export class UtilityService {
const host = this.extractDbHost(uri);
return this.isFederationAllowedHost(host);
}
@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

@ -31,6 +31,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,
@ -41,8 +42,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: this.utilityService.isBlockedHost(this.meta.blockedHosts, instance.host),
softwareName: instance.softwareName,
softwareVersion: instance.softwareVersion,

View File

@ -664,4 +664,14 @@ export class MiMeta {
nullable: true,
})
public googleAnalyticsMeasurementId: string | null;
@Column('jsonb', {
default: [],
})
public deliverSuspendedSoftware: SoftwareSuspension[];
}
export type SoftwareSuspension = {
software: string,
versionRange: string,
};

View File

@ -10,6 +10,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(['userId', 'id'])
@Entity('note')
export class MiNote {
@ -229,7 +239,6 @@ export class MiNote {
comment: '[Denormalized]',
})
public renoteUserHost: string | null;
//#endregion
constructor(data: Partial<MiNote>) {
if (data == null) return;

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

@ -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

@ -7,7 +7,7 @@ import cluster from 'node:cluster';
import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import Fastify, { FastifyInstance } from 'fastify';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyStatic from '@fastify/static';
import fastifyRawBody from 'fastify-raw-body';
import { IsNull } from 'typeorm';
@ -73,7 +73,7 @@ export class ServerService implements OnApplicationShutdown {
}
@bindThis
public async launch() {
public async launch(): Promise<void> {
const fastify = Fastify({
trustProxy: true,
logger: false,
@ -133,8 +133,8 @@ export class ServerService implements OnApplicationShutdown {
reply.header('content-type', 'text/plain; charset=utf-8');
reply.header('link', `<${encodeURI(location)}>; rel="canonical"`);
done(null, [
'Refusing to relay remote ActivityPub object lookup.',
'',
"Refusing to relay remote ActivityPub object lookup.",
"",
`Please remove 'application/activity+json' and 'application/ld+json' from the Accept header or fetch using the authoritative URL at ${location}.`,
].join('\n'));
});
@ -301,7 +301,6 @@ export class ServerService implements OnApplicationShutdown {
}
await fastify.ready();
return fastify;
}
@bindThis
@ -310,6 +309,13 @@ export class ServerService implements OnApplicationShutdown {
await this.#fastify.close();
}
/**
* Get the Fastify instance for testing.
*/
public get fastify(): FastifyInstance {
return this.#fastify;
}
@bindThis
async onApplicationShutdown(signal: string): Promise<void> {
await this.dispose();

View File

@ -6,11 +6,8 @@
import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs';
import * as stream from 'node:stream/promises';
import { Transform } from 'node:stream';
import { type MultipartFile } from '@fastify/multipart';
import { Inject, Injectable } from '@nestjs/common';
import * as Sentry from '@sentry/node';
import { AttachmentFile } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { getIpHash } from '@/misc/get-ip-hash.js';
import type { MiLocalUser, MiUser } from '@/models/User.js';
@ -19,7 +16,7 @@ import type Logger from '@/logger.js';
import type { MiMeta, UserIpsRepository } from '@/models/_.js';
import { createTemp } from '@/misc/create-temp.js';
import { bindThis } from '@/decorators.js';
import { type RolePolicies, RoleService } from '@/core/RoleService.js';
import { RoleService } from '@/core/RoleService.js';
import type { Config } from '@/config.js';
import { ApiError } from './error.js';
import { RateLimiterService } from './RateLimiterService.js';
@ -203,6 +200,18 @@ export class ApiCallService implements OnApplicationShutdown {
return;
}
const [path, cleanup] = await createTemp();
await stream.pipeline(multipartData.file, fs.createWriteStream(path));
// ファイルサイズが制限を超えていた場合
// なお truncated はストリームを読み切ってからでないと機能しないため、stream.pipeline より後にある必要がある
if (multipartData.file.truncated) {
cleanup();
reply.code(413);
reply.send();
return;
}
const fields = {} as Record<string, unknown>;
for (const [k, v] of Object.entries(multipartData.fields)) {
fields[k] = typeof v === 'object' && 'value' in v ? v.value : undefined;
@ -217,7 +226,10 @@ export class ApiCallService implements OnApplicationShutdown {
return;
}
this.authenticateService.authenticate(token).then(([user, app]) => {
this.call(endpoint, user, app, fields, multipartData, request).then((res) => {
this.call(endpoint, user, app, fields, {
name: multipartData.filename,
path: path,
}, request).then((res) => {
this.send(reply, res);
}).catch((err: ApiError) => {
this.#sendApiError(reply, err);
@ -282,7 +294,10 @@ export class ApiCallService implements OnApplicationShutdown {
user: MiLocalUser | null | undefined,
token: MiAccessToken | null | undefined,
data: any,
multipartFile: MultipartFile | null,
file: {
name: string;
path: string;
} | null,
request: FastifyRequest<{ Body: Record<string, unknown> | undefined, Querystring: Record<string, unknown> }>,
) {
const isSecure = user != null && token == null;
@ -356,37 +371,6 @@ export class ApiCallService implements OnApplicationShutdown {
}
}
// Cast non JSON input
if ((ep.meta.requireFile || request.method === 'GET') && ep.params.properties) {
for (const k of Object.keys(ep.params.properties)) {
const param = ep.params.properties![k];
if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') {
try {
data[k] = JSON.parse(data[k]);
} catch (e) {
throw new ApiError({
message: 'Invalid param.',
code: 'INVALID_PARAM',
id: '0b5f1631-7c1a-41a6-b399-cce335f34d85',
}, {
param: k,
reason: `cannot cast to ${param.type}`,
});
}
}
}
}
if (token && ((ep.meta.kind && !token.permission.some(p => p === ep.meta.kind))
|| (!ep.meta.kind && (ep.meta.requireCredential || ep.meta.requireModerator || ep.meta.requireAdmin)))) {
throw new ApiError({
message: 'Your app does not have the necessary permissions to use this endpoint.',
code: 'PERMISSION_DENIED',
kind: 'permission',
id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838',
});
}
if ((ep.meta.requireModerator || ep.meta.requireAdmin) && (this.meta.rootUserId !== user!.id)) {
const myRoles = await this.roleService.getUserRoles(user!.id);
if (ep.meta.requireModerator && !myRoles.some(r => r.isModerator || r.isAdministrator)) {
@ -420,91 +404,49 @@ export class ApiCallService implements OnApplicationShutdown {
}
}
let attachmentFile: AttachmentFile | null = null;
let cleanup = () => {};
if (ep.meta.requireFile && request.method === 'POST' && multipartFile) {
const policies = await this.roleService.getUserPolicies(user!.id);
const result = await this.handleAttachmentFile(
Math.min((policies.maxFileSizeMb * 1024 * 1024), this.config.maxFileSize),
multipartFile,
);
attachmentFile = result.attachmentFile;
cleanup = result.cleanup;
if (token && ((ep.meta.kind && !token.permission.some(p => p === ep.meta.kind))
|| (!ep.meta.kind && (ep.meta.requireCredential || ep.meta.requireModerator || ep.meta.requireAdmin)))) {
throw new ApiError({
message: 'Your app does not have the necessary permissions to use this endpoint.',
code: 'PERMISSION_DENIED',
kind: 'permission',
id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838',
});
}
// Cast non JSON input
if ((ep.meta.requireFile || request.method === 'GET') && ep.params.properties) {
for (const k of Object.keys(ep.params.properties)) {
const param = ep.params.properties![k];
if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') {
try {
data[k] = JSON.parse(data[k]);
} catch (e) {
throw new ApiError({
message: 'Invalid param.',
code: 'INVALID_PARAM',
id: '0b5f1631-7c1a-41a6-b399-cce335f34d85',
}, {
param: k,
reason: `cannot cast to ${param.type}`,
});
}
}
}
}
// API invoking
if (this.config.sentryForBackend) {
return await Sentry.startSpan({
name: 'API: ' + ep.name,
}, () => {
return ep.exec(data, user, token, attachmentFile, request.ip, request.headers)
.catch((err: Error) => this.#onExecError(ep, data, err, user?.id))
.finally(() => cleanup());
});
}, () => ep.exec(data, user, token, file, request.ip, request.headers)
.catch((err: Error) => this.#onExecError(ep, data, err, user?.id)));
} else {
return await ep.exec(data, user, token, attachmentFile, request.ip, request.headers)
.catch((err: Error) => this.#onExecError(ep, data, err, user?.id))
.finally(() => cleanup());
return await ep.exec(data, user, token, file, request.ip, request.headers)
.catch((err: Error) => this.#onExecError(ep, data, err, user?.id));
}
}
@bindThis
private async handleAttachmentFile(
fileSizeLimit: number,
multipartFile: MultipartFile,
) {
function createTooLongError() {
return new ApiError({
httpStatusCode: 413,
kind: 'client',
message: 'File size is too large.',
code: 'FILE_SIZE_TOO_LARGE',
id: 'ff827ce8-9b4b-4808-8511-422222a3362f',
});
}
function createLimitStream(limit: number) {
let total = 0;
return new Transform({
transform(chunk, _, callback) {
total += chunk.length;
if (total > limit) {
callback(createTooLongError());
} else {
callback(null, chunk);
}
},
});
}
const [path, cleanup] = await createTemp();
try {
await stream.pipeline(
multipartFile.file,
createLimitStream(fileSizeLimit),
fs.createWriteStream(path),
);
// ファイルサイズが制限を超えていた場合
// なお truncated はストリームを読み切ってからでないと機能しないため、stream.pipeline より後にある必要がある
if (multipartFile.file.truncated) {
throw createTooLongError();
}
} catch (err) {
cleanup();
throw err;
}
return {
attachmentFile: {
name: multipartFile.filename,
path,
},
cleanup,
};
}
@bindThis
public dispose(): void {
clearInterval(this.userIpHistoriesClearIntervalId);

View File

@ -21,23 +21,23 @@ ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/);
export type Response = Record<string, any> | void;
export type AttachmentFile = {
type File = {
name: string | null;
path: string;
};
// TODO: paramsの型をT['params']のスキーマ定義から推論する
type Executor<T extends IEndpointMeta, Ps extends Schema> =
(params: SchemaType<Ps>, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: AttachmentFile, cleanup?: () => any, ip?: string | null, headers?: Record<string, string> | null) =>
Promise<T['res'] extends undefined ? Response : SchemaType<NonNullable<T['res']>>>;
(params: SchemaType<Ps>, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: File, cleanup?: () => any, ip?: string | null, headers?: Record<string, string> | null) =>
Promise<T['res'] extends undefined ? Response : SchemaType<NonNullable<T['res']>>>;
export abstract class Endpoint<T extends IEndpointMeta, Ps extends Schema> {
public exec: (params: any, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: AttachmentFile, ip?: string | null, headers?: Record<string, string> | null) => Promise<any>;
public exec: (params: any, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: File, ip?: string | null, headers?: Record<string, string> | null) => Promise<any>;
constructor(meta: T, paramDef: Ps, cb: Executor<T, Ps>) {
const validate = ajv.compile(paramDef);
this.exec = (params: any, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: AttachmentFile, ip?: string | null, headers?: Record<string, string> | null) => {
this.exec = (params: any, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: File, ip?: string | null, headers?: Record<string, string> | null) => {
let cleanup: undefined | (() => void) = undefined;
if (meta.requireFile) {

View File

@ -528,6 +528,24 @@ export const meta = {
optional: false, nullable: false,
},
},
deliverSuspendedSoftware: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
properties: {
software: {
type: 'string',
optional: false, nullable: false,
},
versionRange: {
type: 'string',
optional: false, nullable: false,
},
},
},
},
},
},
} as const;
@ -672,6 +690,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
urlPreviewSummaryProxyUrl: instance.urlPreviewSummaryProxyUrl,
federation: instance.federation,
federationHosts: instance.federationHosts,
deliverSuspendedSoftware: instance.deliverSuspendedSoftware,
};
});
}

View File

@ -185,6 +185,17 @@ export const paramDef = {
type: 'string',
},
},
deliverSuspendedSoftware: {
type: 'array',
items: {
type: 'object',
properties: {
software: { type: 'string' },
versionRange: { type: 'string' },
},
required: ['software', 'versionRange'],
},
},
},
required: [],
} as const;
@ -671,6 +682,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.federation = ps.federation;
}
if (ps.deliverSuspendedSoftware !== undefined) {
set.deliverSuspendedSoftware = ps.deliverSuspendedSoftware;
}
if (Array.isArray(ps.federationHosts)) {
set.federationHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase());
}

View File

@ -112,6 +112,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// https://github.com/misskey-dev/misskey/pull/15346#discussion_r1929950255
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);

View File

@ -48,7 +48,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.channelFollowingsRepository.createQueryBuilder(), ps.sinceId, ps.untilId)
const query = this.queryService
.makePaginationQuery(
this.channelFollowingsRepository.createQueryBuilder(),
ps.sinceId,
ps.untilId,
null,
null,
'followeeId',
)
.andWhere({ followerId: me.id });
const followings = await query

View File

@ -122,6 +122,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('note.channel', 'channel');
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);

View File

@ -85,9 +85,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser')
.andWhere('clipNote.clipId = :clipId', { clipId: clip.id });
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
// this.queryService.generateSuspendedUserQueryForNote(query); // To avoid problems with removing notes, ignoring suspended user for now
if (me) {
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
}

View File

@ -61,6 +61,7 @@ export const meta = {
message: 'Cannot upload the file because it exceeds the maximum file size.',
code: 'MAX_FILE_SIZE_EXCEEDED',
id: 'b9d8c348-33f0-4673-b9a9-5d4da058977a',
httpStatusCode: 413,
},
},
} as const;

View File

@ -71,6 +71,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);

View File

@ -97,6 +97,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('note.channel', 'channel');
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
const notes = (await query.getMany()).filter(note => {
if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;

View File

@ -244,6 +244,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);

View File

@ -157,6 +157,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
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);
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);

View File

@ -73,6 +73,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateMutedNoteThreadQuery(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);

View File

@ -73,6 +73,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
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);

View File

@ -57,6 +57,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
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);

View File

@ -82,6 +82,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
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);

View File

@ -200,6 +200,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);

View File

@ -185,6 +185,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);

View File

@ -103,6 +103,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);

View File

@ -88,6 +88,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('note.channel', 'channel');
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
const notes = (await query.getMany()).filter(note => {
if (me && isUserRelated(note, userIdsWhoBlockingMe, false)) return false;

View File

@ -130,6 +130,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
useDbFallback: true,
ignoreAuthorFromMute: true,
ignoreAuthorFromInstanceBlock: true,
ignoreAuthorFromUserSuspension: true,
excludeReplies: ps.withChannelNotes && !ps.withReplies, // userTimelineWithChannel may include replies
excludeNoFiles: ps.withChannelNotes && ps.withFiles, // userTimelineWithChannel may include notes without files
excludePureRenotes: !ps.withRenotes,
@ -186,6 +187,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query, true);
this.queryService.generateSuspendedUserQueryForNote(query, true);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me, { id: ps.userId });
this.queryService.generateBlockedUserQueryForNotes(query, me);

View File

@ -99,10 +99,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('reaction.userId = :userId', { userId: ps.userId })
.leftJoinAndSelect('reaction.note', 'note');
.leftJoinAndSelect('reaction.note', 'note')
.leftJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
const reactions = (await query
.limit(ps.limit)

View File

@ -159,8 +159,8 @@ describe('API', () => {
user: { token: application3 },
}, {
status: 403,
code: 'PERMISSION_DENIED',
id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838',
code: 'ROLE_PERMISSION_DENIED',
id: 'c3d38592-54c0-429d-be96-5636b0431a61',
});
await failedApiCall({

View File

@ -909,7 +909,7 @@ describe('クリップ', () => {
assert.deepStrictEqual(res.map(x => x.id), [aliceNote.id]);
});
test('はPublicなクリップなら認証なしでも取得できる。(非公開ノートはhideされて返ってくる)', async () => {
test('はPublicなクリップなら認証なしでも取得できる。(非公開ノートは含まれない)', async () => {
const publicClip = await create({ isPublic: true });
await addNote({ clipId: publicClip.id, noteId: aliceNote.id });
await addNote({ clipId: publicClip.id, noteId: aliceHomeNote.id });
@ -919,8 +919,6 @@ describe('クリップ', () => {
const res = await notes({ clipId: publicClip.id }, { user: undefined });
const expects = [
aliceNote, aliceHomeNote,
// 認証なしだと非公開ートは結果には含むけどhideされる。
hiddenNote(aliceFollowersNote), hiddenNote(aliceSpecifiedNote),
];
assert.deepStrictEqual(
res.sort(compareBy(s => s.id)).map(x => x.id),

View File

@ -3,29 +3,31 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { S3Client } from '@aws-sdk/client-s3';
import { Test, TestingModule } from '@nestjs/testing';
import { mockClient } from 'aws-sdk-client-mock';
import { FastifyInstance } from 'fastify';
import request from 'supertest';
import { randomString } from '../../../../../utils.js';
import { CoreModule } from '@/core/CoreModule.js';
import { RoleService } from '@/core/RoleService.js';
import { DI } from '@/di-symbols.js';
import { GlobalModule } from '@/GlobalModule.js';
import { MiRole, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import { DriveFoldersRepository, MiDriveFolder, MiRole, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import { MiUser } from '@/models/User.js';
import { ServerModule } from '@/server/ServerModule.js';
import { ServerService } from '@/server/ServerService.js';
import { IdService } from '@/core/IdService.js';
describe('/drive/files/create', () => {
let module: TestingModule;
let server: FastifyInstance;
const s3Mock = mockClient(S3Client);
let roleService: RoleService;
let idService: IdService;
let root: MiUser;
let role_tinyAttachment: MiRole;
let folder: MiDriveFolder;
beforeAll(async () => {
module = await Test.createTestingModule({
imports: [GlobalModule, CoreModule, ServerModule],
@ -33,21 +35,34 @@ describe('/drive/files/create', () => {
module.enableShutdownHooks();
const serverService = module.get<ServerService>(ServerService);
server = await serverService.launch();
await serverService.launch();
server = serverService.fastify;
idService = module.get(IdService);
const usersRepository = module.get<UsersRepository>(DI.usersRepository);
await usersRepository.delete({});
root = await usersRepository.insert({
id: 'root',
id: idService.gen(),
username: 'root',
usernameLower: 'root',
token: '1234567890123456',
}).then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
const userProfilesRepository = module.get<UserProfilesRepository>(DI.userProfilesRepository);
await userProfilesRepository.delete({});
await userProfilesRepository.insert({
userId: root.id,
});
const driveFoldersRepository = module.get<DriveFoldersRepository>(DI.driveFoldersRepository);
folder = await driveFoldersRepository.insertOne({
id: idService.gen(),
name: 'root-folder',
parentId: null,
userId: root.id,
});
roleService = module.get<RoleService>(RoleService);
role_tinyAttachment = await roleService.create({
name: 'test-role001',
@ -65,8 +80,8 @@ describe('/drive/files/create', () => {
});
beforeEach(async () => {
s3Mock.reset();
await roleService.unassign(root.id, role_tinyAttachment.id).catch(() => {});
await roleService.unassign(root.id, role_tinyAttachment.id).catch(() => {
});
});
afterAll(async () => {
@ -74,35 +89,76 @@ describe('/drive/files/create', () => {
await module.close();
});
test('200 ok', async () => {
const result = await request(server.server)
async function postFile(props: {
name: string,
comment: string,
isSensitive: boolean,
force: boolean,
fileContent: Buffer | string,
}) {
const { name, comment, isSensitive, force, fileContent } = props;
return await request(server.server)
.post('/api/drive/files/create')
.set('Content-Type', 'multipart/form-data')
.set('Authorization', `Bearer ${root.token}`)
.attach('file', Buffer.from('a'.repeat(1024 * 1024)));
.attach('file', fileContent)
.field('name', name)
.field('comment', comment)
.field('isSensitive', isSensitive)
.field('force', force)
.field('folderId', folder.id)
.field('i', root.token ?? '');
}
test('200 ok', async () => {
const name = randomString();
const comment = randomString();
const result = await postFile({
name: name,
comment: comment,
isSensitive: true,
force: true,
fileContent: Buffer.from('a'.repeat(1000 * 1000)),
});
expect(result.statusCode).toBe(200);
expect(result.body.name).toBe(name + '.unknown');
expect(result.body.comment).toBe(comment);
expect(result.body.isSensitive).toBe(true);
expect(result.body.folderId).toBe(folder.id);
});
test('200 ok(with role)', async () => {
await roleService.assign(root.id, role_tinyAttachment.id);
const result = await request(server.server)
.post('/api/drive/files/create')
.set('Content-Type', 'multipart/form-data')
.set('Authorization', `Bearer ${root.token}`)
.attach('file', Buffer.from('a'.repeat(10)));
const name = randomString();
const comment = randomString();
const result = await postFile({
name: name,
comment: comment,
isSensitive: true,
force: true,
fileContent: Buffer.from('a'.repeat(10)),
});
expect(result.statusCode).toBe(200);
expect(result.body.name).toBe(name + '.unknown');
expect(result.body.comment).toBe(comment);
expect(result.body.isSensitive).toBe(true);
expect(result.body.folderId).toBe(folder.id);
});
test('413 too large', async () => {
await roleService.assign(root.id, role_tinyAttachment.id);
const result = await request(server.server)
.post('/api/drive/files/create')
.set('Content-Type', 'multipart/form-data')
.set('Authorization', `Bearer ${root.token}`)
.attach('file', Buffer.from('a'.repeat(11)));
const name = randomString();
const comment = randomString();
const result = await postFile({
name: name,
comment: comment,
isSensitive: true,
force: true,
fileContent: Buffer.from('a'.repeat(11)),
});
expect(result.statusCode).toBe(413);
expect(result.body.error.code).toBe('FILE_SIZE_TOO_LARGE');
expect(result.body.error.code).toBe('MAX_FILE_SIZE_EXCEEDED');
});
});

View File

@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<article v-if="channel.description">
<p :title="channel.description">{{ channel.description.length > 85 ? channel.description.slice(0, 85) + '…' : channel.description }}</p>
<Mfm :text="channel.description.length > 85 ? channel.description.slice(0, 85) + '…' : channel.description" :plain="true"/>
</article>
<footer>
<span v-if="channel.lastNotedAt">

View File

@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</p>
<footer>
<img class="icon" :src="flash.user.avatarUrl"/>
<p>{{ userName(flash.user) }}</p>
<MkUserName class="name" :user="flash.user"/>
</footer>
</article>
</MkA>
@ -23,7 +23,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { } from 'vue';
import * as Misskey from 'misskey-js';
import { userName } from '@/filters/user.js';
const props = defineProps<{
flash: Misskey.entities.Flash;
@ -80,7 +79,7 @@ const props = defineProps<{
vertical-align: top;
}
> p {
> .name {
display: inline-block;
margin: 0;
font-size: 0.8em;

View File

@ -12,8 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed } from 'vue';
import type { CSSProperties } from 'vue';
import { instanceName as localInstanceName } from '@@/js/config.js';
import type { CSSProperties } from 'vue';
import { instance as localInstance } from '@/instance.js';
import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js';
@ -61,19 +61,9 @@ $height: 2ex;
border-radius: 4px 0 0 4px;
overflow: clip;
color: #fff;
text-shadow: /* .866 ≈ sin(60deg) */
1px 0 1px #000,
.866px .5px 1px #000,
.5px .866px 1px #000,
0 1px 1px #000,
-.5px .866px 1px #000,
-.866px .5px 1px #000,
-1px 0 1px #000,
-.866px -.5px 1px #000,
-.5px -.866px 1px #000,
0 -1px 1px #000,
.5px -.866px 1px #000,
.866px -.5px 1px #000;
// text-shadow使
mask-image: linear-gradient(90deg,
rgb(0,0,0),
rgb(0,0,0) calc(100% - 16px),

View File

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkPullToRefresh :refresher="() => reload()">
<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reload()">
<MkPagination ref="pagingComponent" :pagination="pagination">
<template #empty>
<div class="_fullinfo">
@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</component>
</template>
</MkPagination>
</MkPullToRefresh>
</component>
</template>
<script lang="ts" setup>

View File

@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<p v-if="page.summary" :title="page.summary">{{ page.summary.length > 85 ? page.summary.slice(0, 85) + '…' : page.summary }}</p>
<footer>
<img v-if="page.user.avatarUrl" class="icon" :src="page.user.avatarUrl"/>
<p>{{ userName(page.user) }}</p>
<MkUserName class="name" :user="page.user"/>
</footer>
</article>
</MkA>
@ -30,7 +30,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { } from 'vue';
import * as Misskey from 'misskey-js';
import { userName } from '@/filters/user.js';
import MediaImage from '@/components/MkMediaImage.vue';
const props = defineProps<{
@ -112,7 +111,7 @@ const props = defineProps<{
vertical-align: top;
}
> p {
> .name {
display: inline-block;
margin: 0;
font-size: 0.8em;

View File

@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header>
<template v-if="pageMetadata">
<i v-if="pageMetadata.icon" :class="pageMetadata.icon" style="margin-right: 0.5em;"></i>
<span>{{ pageMetadata.title }}</span>
<Mfm :text="pageMetadata.title" :plain="true"/>
</template>
</template>

View File

@ -4,13 +4,14 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div ref="rootEl">
<div v-if="isPullStart" :class="$style.frame" :style="`--frame-min-height: ${pullDistance / (PULL_BRAKE_BASE + (pullDistance / PULL_BRAKE_FACTOR))}px;`">
<div ref="rootEl" :class="isPulling ? $style.isPulling : null">
<!-- 小数が含まれるとレンダリングが高頻度になりすぎパフォーマンスが悪化するためround -->
<div v-if="isPulling" :class="$style.frame" :style="`--frame-min-height: ${Math.round(pullDistance / (PULL_BRAKE_BASE + (pullDistance / PULL_BRAKE_FACTOR)))}px;`">
<div :class="$style.frameContent">
<MkLoading v-if="isRefreshing" :class="$style.loader" :em="true"/>
<i v-else class="ti ti-arrow-bar-to-down" :class="[$style.icon, { [$style.refresh]: isPullEnd }]"></i>
<i v-else class="ti ti-arrow-bar-to-down" :class="[$style.icon, { [$style.refresh]: isPulledEnough }]"></i>
<div :class="$style.text">
<template v-if="isPullEnd">{{ i18n.ts.releaseToRefresh }}</template>
<template v-if="isPulledEnough">{{ i18n.ts.releaseToRefresh }}</template>
<template v-else-if="isRefreshing">{{ i18n.ts.refreshing }}</template>
<template v-else>{{ i18n.ts.pullDownToRefresh }}</template>
</div>
@ -29,24 +30,21 @@ import { isHorizontalSwipeSwiping } from '@/utility/touch.js';
const SCROLL_STOP = 10;
const MAX_PULL_DISTANCE = Infinity;
const FIRE_THRESHOLD = 230;
const FIRE_THRESHOLD = 200;
const RELEASE_TRANSITION_DURATION = 200;
const PULL_BRAKE_BASE = 1.5;
const PULL_BRAKE_FACTOR = 170;
const isPullStart = ref(false);
const isPullEnd = ref(false);
const isPulling = ref(false);
const isPulledEnough = ref(false);
const isRefreshing = ref(false);
const pullDistance = ref(0);
let supportPointerDesktop = false;
let startScreenY: number | null = null;
const rootEl = useTemplateRef('rootEl');
let scrollEl: HTMLElement | null = null;
let disabled = false;
const props = withDefaults(defineProps<{
refresher: () => Promise<void>;
}>(), {
@ -57,18 +55,53 @@ const emit = defineEmits<{
(ev: 'refresh'): void;
}>();
function getScreenY(event) {
if (supportPointerDesktop) {
function getScreenY(event: TouchEvent | MouseEvent | PointerEvent): number {
if (event.touches && event.touches[0] && event.touches[0].screenY != null) {
return event.touches[0].screenY;
} else {
return event.screenY;
}
return event.touches[0].screenY;
}
function moveStart(event) {
if (!isPullStart.value && !isRefreshing.value && !disabled) {
isPullStart.value = true;
startScreenY = getScreenY(event);
pullDistance.value = 0;
// When at the top of the page, disable vertical overscroll so passive touch listeners can take over.
function lockDownScroll() {
if (scrollEl == null) return;
scrollEl.style.touchAction = 'pan-x pan-down pinch-zoom';
scrollEl.style.overscrollBehavior = 'none';
}
function unlockDownScroll() {
if (scrollEl == null) return;
scrollEl.style.touchAction = 'auto';
scrollEl.style.overscrollBehavior = 'contain';
}
function moveStart(event: PointerEvent) {
const scrollPos = scrollEl!.scrollTop;
if (scrollPos === 0) {
lockDownScroll();
if (!isPulling.value && !isRefreshing.value) {
isPulling.value = true;
startScreenY = getScreenY(event);
pullDistance.value = 0;
// PointerEvent使TouchEventMouseEvent使
if (event.pointerType === 'mouse') {
window.addEventListener('mousemove', moving, { passive: true });
window.addEventListener('mouseup', () => {
window.removeEventListener('mousemove', moving);
onPullRelease();
}, { passive: true, once: true });
} else {
window.addEventListener('touchmove', moving, { passive: true });
window.addEventListener('touchend', () => {
window.removeEventListener('touchmove', moving);
onPullRelease();
}, { passive: true, once: true });
}
}
} else {
unlockDownScroll();
}
}
@ -108,31 +141,39 @@ async function closeContent() {
}
}
function moveEnd() {
if (isPullStart.value && !isRefreshing.value) {
startScreenY = null;
if (isPullEnd.value) {
isPullEnd.value = false;
isRefreshing.value = true;
fixOverContent().then(() => {
emit('refresh');
props.refresher().then(() => {
refreshFinished();
});
function onPullRelease() {
window.document.body.removeAttribute('inert');
startScreenY = null;
if (isPulledEnough.value) {
isPulledEnough.value = false;
isRefreshing.value = true;
fixOverContent().then(() => {
emit('refresh');
props.refresher().then(() => {
refreshFinished();
});
} else {
closeContent().then(() => isPullStart.value = false);
}
});
} else {
closeContent().then(() => isPulling.value = false);
}
}
function moving(event: TouchEvent | PointerEvent) {
if (!isPullStart.value || isRefreshing.value || disabled) return;
function toggleScrollLockOnTouchEnd() {
const scrollPos = scrollEl!.scrollTop;
if (scrollPos === 0) {
lockDownScroll();
} else {
unlockDownScroll();
}
}
if ((scrollEl?.scrollTop ?? 0) > (supportPointerDesktop ? SCROLL_STOP : SCROLL_STOP + pullDistance.value) || isHorizontalSwipeSwiping.value) {
function moving(event: MouseEvent | TouchEvent) {
if (!isPulling.value || isRefreshing.value) return;
if ((scrollEl?.scrollTop ?? 0) > SCROLL_STOP + pullDistance.value || isHorizontalSwipeSwiping.value) {
pullDistance.value = 0;
isPullEnd.value = false;
moveEnd();
isPulledEnough.value = false;
onPullRelease();
return;
}
@ -144,15 +185,12 @@ function moving(event: TouchEvent | PointerEvent) {
const moveHeight = moveScreenY - startScreenY!;
pullDistance.value = Math.min(Math.max(moveHeight, 0), MAX_PULL_DISTANCE);
if (pullDistance.value > 0) {
if (event.cancelable) event.preventDefault();
// pull
if (pullDistance.value > 3) { //
window.document.body.setAttribute('inert', 'true');
}
if (pullDistance.value > SCROLL_STOP) {
event.stopPropagation();
}
isPullEnd.value = pullDistance.value >= FIRE_THRESHOLD;
isPulledEnough.value = pullDistance.value >= FIRE_THRESHOLD;
}
/**
@ -162,65 +200,31 @@ function moving(event: TouchEvent | PointerEvent) {
*/
function refreshFinished() {
closeContent().then(() => {
isPullStart.value = false;
isPulling.value = false;
isRefreshing.value = false;
});
}
function setDisabled(value) {
disabled = value;
}
function onScrollContainerScroll() {
const scrollPos = scrollEl!.scrollTop;
// When at the top of the page, disable vertical overscroll so passive touch listeners can take over.
if (scrollPos === 0) {
scrollEl!.style.touchAction = 'pan-x pan-down pinch-zoom';
registerEventListenersForReadyToPull();
} else {
scrollEl!.style.touchAction = 'auto';
unregisterEventListenersForReadyToPull();
}
}
function registerEventListenersForReadyToPull() {
if (rootEl.value == null) return;
rootEl.value.addEventListener('touchstart', moveStart, { passive: true });
rootEl.value.addEventListener('touchmove', moving, { passive: false }); // passive: falsepreventDefault使
}
function unregisterEventListenersForReadyToPull() {
if (rootEl.value == null) return;
rootEl.value.removeEventListener('touchstart', moveStart);
rootEl.value.removeEventListener('touchmove', moving);
}
onMounted(() => {
if (rootEl.value == null) return;
scrollEl = getScrollContainer(rootEl.value);
if (scrollEl == null) return;
scrollEl.addEventListener('scroll', onScrollContainerScroll, { passive: true });
rootEl.value.addEventListener('touchend', moveEnd, { passive: true });
registerEventListenersForReadyToPull();
lockDownScroll();
rootEl.value.addEventListener('pointerdown', moveStart, { passive: true });
rootEl.value.addEventListener('touchend', toggleScrollLockOnTouchEnd, { passive: true });
});
onUnmounted(() => {
if (scrollEl) scrollEl.removeEventListener('scroll', onScrollContainerScroll);
unregisterEventListenersForReadyToPull();
});
defineExpose({
setDisabled,
unlockDownScroll();
if (rootEl.value) rootEl.value.removeEventListener('pointerdown', moveStart);
if (rootEl.value) rootEl.value.removeEventListener('touchend', toggleScrollLockOnTouchEnd);
});
</script>
<style lang="scss" module>
.isPulling {
will-change: contents;
}
.frame {
position: relative;
overflow: clip;
@ -242,7 +246,6 @@ defineExpose({
display: flex;
flex-direction: column;
align-items: center;
font-size: 14px;
> .icon, > .loader {
margin: 6px 0;
@ -258,6 +261,7 @@ defineExpose({
> .text {
margin: 5px 0;
font-size: 90%;
}
}
</style>

View File

@ -4,8 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkPullToRefresh ref="prComponent" :refresher="() => reloadTimeline()">
<MkPagination v-if="paginationQuery" ref="pagingComponent" :pagination="paginationQuery" @queue="emit('queue', $event)" @status="prComponent?.setDisabled($event)">
<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reloadTimeline()">
<MkPagination v-if="paginationQuery" ref="pagingComponent" :pagination="paginationQuery" @queue="emit('queue', $event)">
<template #empty>
<div class="_fullinfo">
<img :src="infoImageUrl" draggable="false"/>
@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</component>
</template>
</MkPagination>
</MkPullToRefresh>
</component>
</template>
<script lang="ts" setup>
@ -93,7 +93,6 @@ type TimelineQueryType = {
roleId?: string
};
const prComponent = useTemplateRef('prComponent');
const pagingComponent = useTemplateRef('pagingComponent');
let tlNotesCount = 0;

View File

@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<div v-if="showing" class="_acrylic" :class="$style.root" :style="{ zIndex }">
<div style="padding: 16px 24px;">
{{ message }}
<Mfm :text="message" :plain="true" :nowrap="true"/>
</div>
</div>
</Transition>

View File

@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" v-bind="pageHeaderProps"/></template>
<div :class="$style.body">
<MkSwiper v-if="swipable && (props.tabs?.length ?? 1) > 1" v-model:tab="tab" :class="$style.swiper" :tabs="props.tabs">
<MkSwiper v-if="prefer.s.enableHorizontalSwipe && swipable && (props.tabs?.length ?? 1) > 1" v-model:tab="tab" :class="$style.swiper" :tabs="props.tabs">
<slot></slot>
</MkSwiper>
<slot v-else></slot>
@ -25,6 +25,7 @@ import type { PageHeaderProps } from './MkPageHeader.vue';
import { useScrollPositionKeeper } from '@/use/use-scroll-position-keeper.js';
import MkSwiper from '@/components/MkSwiper.vue';
import { useRouter } from '@/router.js';
import { prefer } from '@/preferences.js';
const props = withDefaults(defineProps<PageHeaderProps & {
reversed?: boolean;

View File

@ -10,10 +10,6 @@ export const acct = (user: Misskey.Acct) => {
return Misskey.acct.toString(user);
};
export const userName = (user: Misskey.entities.User) => {
return user.name || user.username;
};
export const userPage = (user: Misskey.Acct, path?: string, absolute = false) => {
return `${absolute ? url : ''}/@${acct(user)}${(path ? `/${path}` : '')}`;
};

View File

@ -230,6 +230,31 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.federationAllowedHosts }}<span v-if="federationForm.modifiedStates.federationHosts" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts.federationAllowedHostsDescription }}</template>
</MkTextarea>
<MkFolder>
<template #icon><i class="ti ti-list"></i></template>
<template #label><SearchLabel>{{ i18n.ts._serverSettings.deliverSuspendedSoftware }}</SearchLabel></template>
<template #footer>
<div class="_buttons">
<MkButton @click="federationForm.state.deliverSuspendedSoftware.push({software: '', versionRange: ''})"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
</div>
</template>
<div :class="$style.metadataRoot" class="_gaps_s">
<MkInfo>{{ i18n.ts._serverSettings.deliverSuspendedSoftwareDescription }}</MkInfo>
<div v-for="(element, index) in federationForm.state.deliverSuspendedSoftware" :key="index" v-panel :class="$style.fieldDragItem">
<button class="_button" :class="$style.dragItemRemove" @click="federationForm.state.deliverSuspendedSoftware.splice(index, 1)"><i class="ti ti-x"></i></button>
<div :class="$style.dragItemForm">
<FormSplit :minWidth="200">
<MkInput v-model="element.software" small :placeholder="i18n.ts.softwareName">
</MkInput>
<MkInput v-model="element.versionRange" small :placeholder="i18n.ts.version">
</MkInput>
</FormSplit>
</div>
</div>
</div>
</MkFolder>
</div>
</MkFolder>
@ -368,10 +393,12 @@ const urlPreviewForm = useForm({
const federationForm = useForm({
federation: meta.federation,
federationHosts: meta.federationHosts.join('\n'),
deliverSuspendedSoftware: meta.deliverSuspendedSoftware,
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
federation: state.federation,
federationHosts: state.federationHosts.split('\n'),
deliverSuspendedSoftware: state.deliverSuspendedSoftware,
});
fetchInstance(true);
});
@ -398,4 +425,53 @@ definePage(() => ({
font-size: 0.85em;
color: color(from var(--MI_THEME-fg) srgb r g b / 0.75);
}
.metadataRoot {
container-type: inline-size;
}
.fieldDragItem {
display: flex;
padding: 10px;
align-items: flex-end;
border-radius: 6px;
/* (drag button) 32px + (drag button margin) 8px + (input width) 200px * 2 + (input gap) 12px = 452px */
@container (max-width: 452px) {
align-items: center;
}
}
.dragItemHandle {
cursor: grab;
width: 32px;
height: 32px;
margin: 0 8px 0 0;
opacity: 0.5;
flex-shrink: 0;
&:active {
cursor: grabbing;
}
}
.dragItemRemove {
@extend .dragItemHandle;
color: #ff2a2a;
opacity: 1;
cursor: pointer;
&:hover, &:focus {
opacity: .7;
}
&:active {
cursor: pointer;
}
}
.dragItemForm {
flex-grow: 1;
}
</style>

View File

@ -42,7 +42,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
</MkKeyValue>
<MkButton v-if="suspensionState === 'none'" :disabled="!instance" danger @click="stopDelivery">{{ i18n.ts._delivery.stop }}</MkButton>
<MkButton v-if="suspensionState !== 'none'" :disabled="!instance" @click="resumeDelivery">{{ i18n.ts._delivery.resume }}</MkButton>
<MkButton v-if="suspensionState !== 'none'" :disabled="!instance || suspensionState == 'softwareSuspended'" @click="resumeDelivery">{{ i18n.ts._delivery.resume }}</MkButton>
<MkSwitch v-model="isBlocked" :disabled="!meta || !instance" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch>
<MkSwitch v-model="isSilenced" :disabled="!meta || !instance" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch>
<MkSwitch v-model="isMediaSilenced" :disabled="!meta || !instance" @update:modelValue="toggleMediaSilenced">{{ i18n.ts.mediaSilenceThisInstance }}</MkSwitch>
@ -164,7 +164,7 @@ const tab = ref('overview');
const chartSrc = ref<ChartSrc>('instance-requests');
const meta = ref<Misskey.entities.AdminMetaResponse | null>(null);
const instance = ref<Misskey.entities.FederationInstance | null>(null);
const suspensionState = ref<'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding'>('none');
const suspensionState = ref<'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding' | 'softwareSuspended'>('none');
const isBlocked = ref(false);
const isSilenced = ref(false);
const isMediaSilenced = ref(false);

View File

@ -471,6 +471,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['swipe', 'pull', 'refresh']">
<MkPreferenceContainer k="enablePullToRefresh">
<MkSwitch v-model="enablePullToRefresh">
<template #label><SearchLabel>{{ i18n.ts._settings.enablePullToRefresh }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['keep', 'screen', 'display', 'on']">
<MkPreferenceContainer k="keepScreenOn">
<MkSwitch v-model="keepScreenOn">
@ -800,6 +808,7 @@ const animatedMfm = prefer.model('animatedMfm');
const disableShowingAnimatedImages = prefer.model('disableShowingAnimatedImages');
const keepScreenOn = prefer.model('keepScreenOn');
const enableHorizontalSwipe = prefer.model('enableHorizontalSwipe');
const enablePullToRefresh = prefer.model('enablePullToRefresh');
const useNativeUiForVideoAudioPlayer = prefer.model('useNativeUiForVideoAudioPlayer');
const contextMenu = prefer.model('contextMenu');
const menuStyle = prefer.model('menuStyle');
@ -857,6 +866,8 @@ watch([
fontSize,
useSystemFont,
makeEveryTextElementsSelectable,
enableHorizontalSwipe,
enablePullToRefresh,
], async () => {
await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true });
});

View File

@ -300,6 +300,9 @@ export const PREF_DEF = {
enableHorizontalSwipe: {
default: true,
},
enablePullToRefresh: {
default: true,
},
useNativeUiForVideoAudioPlayer: {
default: false,
},

View File

@ -9,6 +9,7 @@ import type { RouteDef } from '@/lib/nirax.js';
import { $i, iAmModerator } from '@/i.js';
import MkLoading from '@/pages/_loading_.vue';
import MkError from '@/pages/_error_.vue';
import PageTimeline from '@/pages/timeline.vue';
export const page = (loader: AsyncComponentLoader) => defineAsyncComponent({
loader: loader,
@ -21,6 +22,13 @@ function chatPage(...args: Parameters<typeof page>) {
}
export const ROUTE_DEF = [{
name: 'index',
path: '/',
component: $i ? PageTimeline : page(() => import('@/pages/welcome.vue')),
}, {
path: '/timeline',
component: PageTimeline,
}, {
path: '/@:username/pages/:pageName(*)',
component: page(() => import('@/pages/page.vue')),
}, {
@ -579,13 +587,6 @@ export const ROUTE_DEF = [{
path: '/reversi/g/:gameId',
component: page(() => import('@/pages/reversi/game.vue')),
loginRequired: false,
}, {
path: '/timeline',
component: page(() => import('@/pages/timeline.vue')),
}, {
name: 'index',
path: '/',
component: $i ? page(() => import('@/pages/timeline.vue')) : page(() => import('@/pages/welcome.vue')),
}, {
// テスト用リダイレクト設定。ログイン中ユーザのプロフィールにリダイレクトする
path: '/redirect-test',

View File

@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header>
<template v-if="pageMetadata">
<i :class="pageMetadata.icon"></i>
{{ pageMetadata.title }}
<Mfm :text="pageMetadata.title" :plain="true"/>
</template>
</template>

View File

@ -39,6 +39,7 @@ import type { PageMetadata } from '@/page.js';
import XMobileFooterMenu from '@/ui/_common_/mobile-footer-menu.vue';
import XPreferenceRestore from '@/ui/_common_/PreferenceRestore.vue';
import XTitlebar from '@/ui/_common_/titlebar.vue';
import XSidebar from '@/ui/_common_/navbar.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { $i } from '@/i.js';
@ -51,7 +52,6 @@ import { shouldSuggestRestoreBackup } from '@/preferences/utility.js';
import { DI } from '@/di.js';
const XWidgets = defineAsyncComponent(() => import('./_common_/widgets.vue'));
const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue'));
const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue'));
const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue'));

View File

@ -5,6 +5,7 @@
import { computed, reactive, watch } from 'vue';
import type { Reactive } from 'vue';
import { deepEqual } from '@/utility/deep-equal';
function copy<T>(v: T): T {
return JSON.parse(JSON.stringify(v));
@ -27,7 +28,7 @@ export function useForm<T extends Record<string, any>>(initialState: T, save: (n
watch([currentState, previousState], () => {
for (const key in modifiedStates) {
modifiedStates[key] = currentState[key] !== previousState[key];
modifiedStates[key] = !deepEqual(currentState[key], previousState[key]);
}
}, { deep: true });

View File

@ -46,6 +46,7 @@
},
"compileOnSave": false,
"include": [
"./lib/**/*.ts",
"./src/**/*.ts",
"./src/**/*.vue",
"./test/**/*.ts",

View File

@ -1,7 +1,7 @@
{
"type": "module",
"name": "misskey-js",
"version": "2025.4.1-beta.9",
"version": "2025.5.0-alpha.0",
"description": "Misskey SDK for JavaScript",
"license": "MIT",
"main": "./built/index.js",

View File

@ -4989,7 +4989,7 @@ export type components = {
isNotResponding: boolean;
isSuspended: boolean;
/** @enum {string} */
suspensionState: 'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding';
suspensionState: 'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding' | 'softwareSuspended';
isBlocked: boolean;
/** @example misskey */
softwareName: string | null;
@ -8765,6 +8765,10 @@ export type operations = {
/** @enum {string} */
federation: 'all' | 'specified' | 'none';
federationHosts: string[];
deliverSuspendedSoftware: {
software: string;
versionRange: string;
}[];
};
};
};
@ -11431,6 +11435,10 @@ export type operations = {
/** @enum {string} */
federation?: 'all' | 'none' | 'specified';
federationHosts?: string[];
deliverSuspendedSoftware?: {
software: string;
versionRange: string;
}[];
};
};
};

View File

@ -390,6 +390,9 @@ importers:
secure-json-parse:
specifier: 3.0.2
version: 3.0.2
semver:
specifier: 7.7.1
version: 7.7.1
sharp:
specifier: 0.34.1
version: 0.34.1
@ -9564,11 +9567,6 @@ packages:
engines: {node: '>=10'}
hasBin: true
semver@7.6.3:
resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==}
engines: {node: '>=10'}
hasBin: true
semver@7.7.1:
resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==}
engines: {node: '>=10'}
@ -17051,7 +17049,7 @@ snapshots:
natural-compare: 1.4.0
nth-check: 2.1.1
postcss-selector-parser: 6.1.2
semver: 7.6.3
semver: 7.7.1
vue-eslint-parser: 10.1.3(eslint@9.25.1)
xml-name-validator: 4.0.0
@ -20987,8 +20985,6 @@ snapshots:
dependencies:
lru-cache: 6.0.0
semver@7.6.3: {}
semver@7.7.1: {}
send@0.19.0: