diff --git a/.config/example.yml b/.config/example.yml index 90bda4617b..ff83777ca3 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -252,7 +252,9 @@ id: 'aidx' #outgoingAddressFamily: ipv4 # Proxy for HTTP/HTTPS -#proxy: http://127.0.0.1:3128 +# + + proxyBypassHosts: - api.deepl.com diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index db899ba386..28049cd1a4 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -6,7 +6,7 @@ on: workflow_dispatch: env: - REGISTRY_IMAGE: misskey/misskey + REGISTRY_IMAGE: mattyacocacora/prsmsk-msk TAGS: | type=edge type=ref,event=pr diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a28c9ef64..07b3a9e6fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,19 +16,7 @@ ### Server - チャート生成時にinstance.suspentionStateに置き換えられたinstance.isSuspendedが参照されてしまう問題を修正 - Feat: レートリミット制限に引っかかったときに`Retry-After`ヘッダーを返すように (#13949) -- Fix: ユーザーのフィードページのMFMをHTMLに展開するように (#14006) - Fix: アンテナ・クリップ・リスト・ウェブフックがロールポリシーの上限より一つ多く作れてしまうのを修正 (#14036) -- Enhance: エンドポイント`clips/update`の必須項目を`clipId`のみに -- Enhance: エンドポイント`admin/roles/update`の必須項目を`roleId`のみに -- Enhance: エンドポイント`pages/update`の必須項目を`pageId`のみに -- Enhance: エンドポイント`gallery/posts/update`の必須項目を`postId`のみに -- Enhance: エンドポイント`i/webhook/update`の必須項目を`webhookId`のみに -- Enhance: エンドポイント`admin/ad/update`の必須項目を`id`のみに -- Fix: notRespondingSinceが実装される前に不通になったインスタンスが自動的に配信停止にならない (#14059) -- Fix: FTT有効時、タイムライン用エンドポイントで`sinceId`にキャッシュ内最古のものより古いものを指定した場合に正しく結果が返ってこない問題を修正 -- Fix: 自分以外のクリップ内のノート個数が見えることがあるのを修正 -- Fix: 空文字列のリアクションはフォールバックされるように -- Fix: リノートにリアクションできないように ## 2024.5.0 diff --git a/CHANGELOG_CHERRYPICK.md b/CHANGELOG_CHERRYPICK.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/README.md b/README.md index a02e9895c1..000a4ae67c 100644 --- a/README.md +++ b/README.md @@ -15,35 +15,22 @@ create an instance - - become a contributor - - - join the community - - - become a patron - +--- -## Thanks +# 当フォークについて -Sentry +当フォークは PrisMisskey.space で使用しているフォークになります。 +このコードを一部でも使用する場合はAbout内にクレジット表示をお願いします。 -Thanks to [Sentry](https://sentry.io/) for providing the error tracking platform that helps us catch unexpected errors. +# Special Thanks -Chromatic +[mkkey source](https://github.com/emtkmkk/mkkey) -Thanks to [Chromatic](https://www.chromatic.com/) for providing the visual testing platform that helps us review UI changes and catch visual regressions. +[mkkey Server](https://mkkey.net) -Codecov +[MisskeyIO source](https://github.com/MisskeyIO/misskey) -Thanks to [Codecov](https://about.codecov.io/for/open-source/) for providing the code coverage platform that helps us improve our test coverage. +[MisskeyIO Server](https://Misskey.io) -Crowdin - -Thanks to [Crowdin](https://crowdin.com/) for providing the localization platform that helps us translate Misskey into many languages. - -Docker - -Thanks to [Docker](https://hub.docker.com/) for providing the container platform that helps us run Misskey in production. +[CherryPick source](https://github.com/kokonect-link/cherrypick) diff --git a/locales/en-US.yml b/locales/en-US.yml index 189e371e08..d04279c3a6 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -9,6 +9,8 @@ notifications: "Notifications" username: "Username" password: "Password" forgotPassword: "Forgot password" +setDefaultProfileConfirm: "Do you want to make this profile the default?" +emojiPickerProfile: "Emoji picker profile" fetchingAsApObject: "Fetching from the Fediverse..." ok: "OK" gotIt: "Got it!" @@ -175,6 +177,8 @@ flagAsCat: "Mark this account as a cat" flagAsCatDescription: "Enable this option to mark this account as a cat." flagShowTimelineReplies: "Show replies in timeline" flagShowTimelineRepliesDescription: "Shows replies of users to notes of other users in the timeline if turned on." +showMediaTimeline: "Show Media timeline" +showMediaTimelineInfo: "When on, the media timeline is displayed on the top bar. When turned off, it will not be displayed." autoAcceptFollowed: "Automatically approve follow requests from users you're following" addAccount: "Add account" reloadAccountsList: "Reload account list" @@ -305,6 +309,8 @@ location: "Location" theme: "Themes" themeForLightMode: "Theme to use in Light Mode" themeForDarkMode: "Theme to use in Dark Mode" +gamingMode: "Gaming Mode" +gamingModeInfo: "It makes a nice gradation of buttons and other decorations. There is no intense blinking, etc." light: "Light" dark: "Dark" lightThemes: "Light themes" @@ -1072,6 +1078,9 @@ videos: "Videos" audio: "Audio" audioFiles: "Audio" dataSaver: "Data Saver" +cellularWithDataSaver: "Turn on Data Saver in Mobile Data Communications" +UltimatedataSaver: "Ultimate Data Saver" +cellularWithUltimateDataSaver: "Turn on Ultimate Data Saver in Mobile Data Communications" accountMigration: "Account Migration" accountMoved: "This user has moved to a new account:" accountMovedShort: "This account has been migrated." @@ -1811,6 +1820,7 @@ _aboutType4ny: contributors: "Main contributors" allContributors: "All contributors" source: "Source code" + forksource: "Source code for this fork" original: "Original" thisIsModifiedVersion: "{name} uses a modified version of the original Misskey." translation: "Translate Misskey" @@ -1852,6 +1862,7 @@ _wordMute: muteWords: "Muted words" muteWordsDescription: "Separate with spaces for an AND condition or with line breaks for an OR condition." muteWordsDescription2: "Surround keywords with slashes to use regular expressions." + hideMutedNotes: "Hide notes containing muted words" _instanceMute: instanceMuteDescription: "This will mute any notes/renotes from the listed instances, including those of users replying to a user from a muted instance." instanceMuteDescription2: "Separate with newlines" @@ -1966,6 +1977,17 @@ _time: minute: "Minute(s)" hour: "Hour(s)" day: "Day(s)" +_timelineTutorial: + title: "How to use Misskey" + step1_1: "This is the \"timeline\". All \"notes\" submitted on {name} will be chronologically displayed here." + step1_2: "There are a few different timelines. For example, the \"Home timeline\" will contain notes of users you follow, and the \"Local timeline\" will contain notes from all users of {name}." + step1_3: 'Besides these two, "Social Timeline" is like Home TL + Local TL, and "Media Timeline" is a stream of notes posted with some file at {name}.' + step2_1: "Let's try posting a note next. You can do so by pressing the button with a pencil icon." + step2_2: "How about writing a self-introduction, or just \"Hello {name}!\" if you don't feel like it?" + step3_1: "Finished posting your first note?" + step3_2: "Your first note should now be displayed on your timeline." + step4_1: "You can also attach \"Reactions\" to notes." + step4_2: "To attach a reaction, press the \"+\" mark on a note and choose an emoji you'd like to react with." _2fa: alreadyRegistered: "You have already registered a 2-factor authentication device." registerTOTP: "Register authenticator app" @@ -2238,6 +2260,7 @@ _instanceCharts: _timelines: home: "Home" local: "Local" + media: "Media" social: "Social" global: "Global" _play: diff --git a/locales/index.d.ts b/locales/index.d.ts index b87b28ba02..7439e0844b 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -60,6 +60,46 @@ export interface Locale extends ILocale { * OK */ "ok": string; + /** + * ノートの投稿フォームを開き直した際に、下書きを復元しないようにします。 + */ + "disableNoteDraftingDescription": string; + /** + * このプロファイルをデフォルトにしますか? + */ + "setDefaultProfileConfirm": string; + /** + * 絵文字ピッカーのプロファイル + */ + "emojiPickerProfile": string; + /** + * 通知のインジケーターの数字を表示する + */ + "notificationIndicator": string; + /** + * アイコンとバナーを反転させる + */ + "hanntenn": string; + /** + * ダークだったらライトのアイコンに、ライトだったらダークのアイコンに。 + */ + "hanntennInfo": string; + /** + * ルビ + */ + "ruby": string; + /** + * ノートの下書きの復元を無効化 + */ + "disableNoteDrafting": string; + /** + * 隠れ家 + */ + "kakuregaFeature": string; + /** + * ピン留めされたチャンネル + */ + "pinnedChannel": string; /** * わかった */ @@ -68,6 +108,10 @@ export interface Locale extends ILocale { * キャンセル */ "cancel": string; + /** + * 自分の作成したリスト + */ + "myLists": string; /** * やめておく */ @@ -76,6 +120,30 @@ export interface Locale extends ILocale { * ユーザー名を入力 */ "enterUsername": string; + /** + * グローバルタイムラインを表示する + */ + "showGlobalTimeline": string; + /** + * ホームタイムラインを表示する + */ + "showHomeTimeline": string; + /** + * ローカルタイムラインを表示する + */ + "showLocalTimeline": string; + /** + * トップバーのカスタムをする + */ + "topbarCustom": string; + /** + * ソーシャルタイムラインを表示する + */ + "showSocialTimeline": string; + /** + * 上のバーにTLの名前を省略して表示する + */ + "topBarNameShown": string; /** * {user}がリノート */ @@ -100,6 +168,14 @@ export interface Locale extends ILocale { * 通知の設定 */ "notificationSettings": string; + /** + * このサーバーの公開のリスト + */ + "localListList": string; + /** + * お気に入りのリスト + */ + "favoriteLists": string; /** * 基本設定 */ @@ -320,6 +396,22 @@ export interface Locale extends ILocale { * ファイル「{name}」を削除しますか?このファイルを使用した一部のコンテンツも削除されます。 */ "driveFileDeleteConfirm": ParameterizedString<"name">; + /** + * {name}つのファイルを削除しますか?このファイルを使用した一部のコンテンツも削除されます。 + */ + "driveFilesDeleteConfirm": ParameterizedString<"name">; + /** + * {name}つのファイルをセンシティブにしますか? + */ + "driveFilesSensitiveonConfirm": ParameterizedString<"name">; + /** + * {name}つのファイルのセンシティブを解除しますか? + */ + "driveFilesSensitiveoffConfirm": ParameterizedString<"name">; + /** + * フォルダ「{name}」を削除しますか?このフォルダの中に存在するファイルを使用した一部のコンテンツも削除されます。 + */ + "driveFolderDeleteConfirm": ParameterizedString<"name">; /** * {name}のフォローを解除しますか? */ @@ -356,6 +448,10 @@ export interface Locale extends ILocale { * フォロワー */ "followers": string; + /** + * プリズム + */ + "points": string; /** * フォローされています */ @@ -704,14 +800,30 @@ export interface Locale extends ILocale { * にゃああああああああああああああ!!!!!!!!!!!! */ "flagAsCat": string; + /** + * ウホウホウホホウホウホウホウホホホ!!!!!!!!!!! + */ + "flagAsGorilla": string; /** * にゃにゃにゃ?? */ "flagAsCatDescription": string; + /** + * ウホウホウホ?? + */ + "flagAsGorillaDescription": string; /** * タイムラインにノートへの返信を表示する */ "flagShowTimelineReplies": string; + /** + * メディアタイムラインを表示する + */ + "showMediaTimeline": string; + /** + * オンにするとメディアタイムラインを上のバーに表示します。 オフにすると表示しなくなります。 + */ + "showMediaTimelineInfo": string; /** * オンにすると、タイムラインにユーザーのノート以外にもそのユーザーの他のノートへの返信を表示します。 */ @@ -1084,6 +1196,10 @@ export interface Locale extends ILocale { * 削除しました */ "removed": string; + /** + * 「{x}」のリクエストを承認しますか? + */ + "requestApprovalAreYouSure": ParameterizedString<"x">; /** * 「{x}」を削除しますか? */ @@ -1092,6 +1208,10 @@ export interface Locale extends ILocale { * 「{x}」を削除しますか? */ "deleteAreYouSure": ParameterizedString<"x">; + /** + * 「{x}」をドラフト解除しますか? + */ + "undraftAreYouSure": ParameterizedString<"x">; /** * リセットしますか? */ @@ -1116,6 +1236,10 @@ export interface Locale extends ILocale { * オリジナル画像を保持 */ "keepOriginalUploading": string; + /** + * ホーム投稿で通知する + */ + "isNotifyIsHome": string; /** * 画像をアップロードする時にオリジナル版を保持します。オフにするとアップロード時にブラウザでWeb公開用画像を生成します。 */ @@ -1236,6 +1360,14 @@ export interface Locale extends ILocale { * ダークモードで使うテーマ */ "themeForDarkMode": string; + /** + * ゲーミングモード + */ + "gamingMode": string; + /** + * ボタンなどの装飾をいい感じのグラデーションにします。 激しい点滅などはございません。 + */ + "gamingModeInfo": string; /** * ライト */ @@ -1248,6 +1380,10 @@ export interface Locale extends ILocale { * 明るいテーマ */ "lightThemes": string; + /** + * アイコンなどが正常に表示されない場合にここをクリックしてください。 + */ + "remoteUserInfoUpdate": string; /** * 暗いテーマ */ @@ -1304,6 +1440,10 @@ export interface Locale extends ILocale { * フォルダーを削除 */ "deleteFolder": string; + /** + * フォルダー + */ + "Folder": string; /** * フォルダー */ @@ -2120,6 +2260,10 @@ export interface Locale extends ILocale { * アカウント設定 */ "accountSettings": string; + /** + * タイムラインのヘッダー + */ + "timelineHeader": string; /** * プロモーション */ @@ -2228,6 +2372,14 @@ export interface Locale extends ILocale { * タイムライン上部に投稿フォームを表示する(チャンネル) */ "showFixedPostFormInChannel": string; + /** + * ユーザーのページで最新のノートを表示する + */ + "FeaturedOrNote": string; + /** + * ユーザーのページに行ったときにハイライトか最新のノートを表示するかを選択することができます。 オフでハイライト オンで最新のノート です + */ + "FeaturedOrNoteInfo": string; /** * フォローする際、デフォルトで返信をTLに含むようにする */ @@ -2476,6 +2628,10 @@ export interface Locale extends ILocale { * アンケート */ "poll": string; + /** + * 予約投稿 + */ + "schedulePost": string; /** * 内容を隠す */ @@ -2568,6 +2724,10 @@ export interface Locale extends ILocale { * アクセストークンの発行 */ "generateAccessToken": string; + /** + * アクセストークン + */ + "accessToken": string; /** * 権限 */ @@ -2704,6 +2864,10 @@ export interface Locale extends ILocale { * ログ */ "logs": string; + /** + * mfm 装飾 + */ + "mfm": string; /** * 遅延 */ @@ -2756,6 +2920,14 @@ export interface Locale extends ILocale { * スペースで区切って複数設定できます。 */ "setMultipleBySeparatingWithSpace": string; + /** + * 名前には英数字と_が利用できます。 + */ + "emojiNameValidation": string; + /** + * センシティブ + */ + "isSensitive": string; /** * ファイルIDまたはURL */ @@ -2816,6 +2988,14 @@ export interface Locale extends ILocale { * 送信 */ "send": string; + /** + * ファイル付きのみ + */ + "fileAttachedOnly": string; + /** + * 通報されたノート + */ + "reportedNote": string; /** * 対応済みにする */ @@ -2944,6 +3124,14 @@ export interface Locale extends ILocale { * アンケートに投票した数 */ "pollVotesCount": string; + /** + * タイムラインの絞り込みを保存する + */ + "onlyAndWithSave": string; + /** + * ファイルのみ や リプライのみ などが保存されるようになります + */ + "onlyAndWithSaveInfo": string; /** * アンケートに投票された数 */ @@ -3432,6 +3620,18 @@ export interface Locale extends ILocale { * 低 */ "low": string; + /** + * 一覧 + */ + "list": string; + /** + * ゲーミングの光るスピードの調整 + */ + "GamingSpeedChange": string; + /** + * 左にすれば早くなる、右にすれば遅くなる。それだけ。 + */ + "GamingSpeedChangeInfo": string; /** * メールアドレスの設定がされていません。 */ @@ -3440,6 +3640,18 @@ export interface Locale extends ILocale { * 比率 */ "ratio": string; + /** + * ノートの公開範囲を色付けする + */ + "showVisibilityColor": string; + /** + * 新しい絵文字 + */ + "newEmojis": string; + /** + * 申請されている絵文字 + */ + "draftEmojis": string; /** * 本文をプレビュー */ @@ -3568,6 +3780,10 @@ export interface Locale extends ILocale { * アカウント登録にメールアドレスを必須にする */ "emailRequiredForSignup": string; + /** + * GDPRモードを有効にする + */ + "enableGDPRMode": string; /** * 未読 */ @@ -4052,6 +4268,10 @@ export interface Locale extends ILocale { * カスタム絵文字の管理 */ "manageCustomEmojis": string; + /** + * カスタム絵文字のリクエスト + */ + "requestCustomEmojis": string; /** * アバターデコレーションの管理 */ @@ -4248,6 +4468,26 @@ export interface Locale extends ILocale { * ライセンス */ "license": string; + /** + * 申請中 + */ + "requestPending": string; + /** + * 承認 + */ + "approval": string; + /** + * リクエストされている絵文字 + */ + "requestingEmojis": string; + /** + * ドラフト + */ + "draft": string; + /** + * ドラフト解除 + */ + "undrafted": string; /** * お気に入り解除しますか? */ @@ -4316,6 +4556,18 @@ export interface Locale extends ILocale { * データセーバー */ "dataSaver": string; + /** + * モバイルデータ通信でデータセーバーをオンにする + */ + "cellularWithDataSaver": string; + /** + * 究極のデータセーバー + */ + "UltimateDataSaver": string; + /** + * モバイルデータ通信で究極のデータセーバーをオンにする + */ + "cellularWithUltimateDataSaver": string; /** * アカウントの移行 */ @@ -4680,6 +4932,10 @@ export interface Locale extends ILocale { * リノートを表示 */ "showRenotes": string; + /** + * CWを非表示 + */ + "showCw": string; /** * 編集済み */ @@ -4696,10 +4952,6 @@ export interface Locale extends ILocale { * フォロー中またはフォロワー */ "followingOrFollower": string; - /** - * ファイル付きのみ - */ - "fileAttachedOnly": string; /** * TLに他の人への返信を含める */ @@ -4984,6 +5236,74 @@ export interface Locale extends ILocale { * お問い合わせ */ "inquiry": string; + /** + * ノートの自己消滅 + */ + "scheduledNoteDelete": string; + /** + * このノートは{time}に消滅します + */ + "noteDeletationAt": ParameterizedString<"time">; + /** + * 1年以上先の日時を指定することはできません + */ + "cannotScheduleLaterThanOneYear": string; + /** + * アクティビティを非公開にする + */ + "hideActivity": string; + /** + * 自分のプロフィールのアクティビティ (概要/アクティビティタブ) を他人が見れないようにします。このオプションを有効にしても、自分であればプロフィールのアクティビティタブから引き続き閲覧できます。 + */ + "hideActivityDescription": string; + /** + * このお知らせはチャンネルのタイムライン上部に表示されます。最初の1行がタイトルとして表示され、2行目以降はお知らせをタップすることで表示されるようになります。 + */ + "channelAnnouncementDescription": string; + /** + * 投稿フォーム + */ + "postForm": string; + /** + * 投稿フォームの下部に表示される項目の並び替えが出来ます。項目をクリックすると削除できます。 + */ + "postFormBottomSettingsDescription": string; + /** + * 投稿フォームをリセット + */ + "clearPost": string; + /** + * 絵文字ピッカーに追加 + */ + "addToEmojiPicker": string; + /** + * リアクション数の非表示 + */ + "hideReactionCount": string; + /** + * 誰がリアクションをしたのかを非表示にする + */ + "hideReactionUsers": string; + /** + * リアクションをホバーした際のユーザー一覧と、ノート詳細ページのリアクションタブにあるリアクションをしたユーザー一覧を非表示にします + */ + "hideReactionUsersDescription": string; + /** + * 下書き + */ + "drafts": string; + /** + * 下書きの保存に関する動作 + */ + "draftSavingBehavior": string; + /** + * 下書きとして保存 + */ + "saveAsDraft": string; + /** + * 下書きを適用すると現在入力されている内容はリセットされます。よろしいですか? + */ + "draftOverwriteConfirm": string; "_delivery": { /** * 配信状態 @@ -6550,6 +6870,10 @@ export interface Locale extends ILocale { * グローバルタイムラインの閲覧 */ "gtlAvailable": string; + /** + * 絵文字ピッカーのプロファイルの上限数(最大5) + */ + "emojiPickerProfileLimit": string; /** * ローカルタイムラインの閲覧 */ @@ -6558,6 +6882,14 @@ export interface Locale extends ILocale { * パブリック投稿の許可 */ "canPublicNote": string; + /** + * ノートの編集 + */ + "canEditNote": string; + /** + * 予約投稿の許可 + */ + "canScheduleNote": string; /** * ノート内の最大メンション数 */ @@ -6582,6 +6914,10 @@ export interface Locale extends ILocale { * カスタム絵文字の管理 */ "canManageCustomEmojis": string; + /** + * カスタム絵文字のリクエスト + */ + "canRequestCustomEmojis": string; /** * アバターデコレーションの管理 */ @@ -6650,6 +6986,14 @@ export interface Locale extends ILocale { * アイコンデコレーションの最大取付個数 */ "avatarDecorationLimit": string; + /** + * ピン留めリストの最大数 + */ + "listPinnedLimit": string; + /** + * 他鯖のローカルTL除けるやつ(最大値5) + */ + "localTimelineAnyLimit": string; }; "_condition": { /** @@ -7203,6 +7547,10 @@ export interface Locale extends ILocale { * キーワードをスラッシュで囲むと正規表現になります。 */ "muteWordsDescription2": string; + /** + * ミュートされた単語を含むノートを非表示にする + */ + "hideMutedNotes": string; }; "_instanceMute": { /** @@ -7644,6 +7992,48 @@ export interface Locale extends ILocale { */ "day": string; }; + "_timelineTutorial": { + /** + * Misskeyの使い方 + */ + "title": string; + /** + * この画面は「タイムライン」です。{name}に投稿された「ノート」が時系列で表示されます。 + */ + "step1_1": ParameterizedString<"name">; + /** + * タイムラインにはいくつか種類があり、例えば「ホームタイムライン」にはあなたがフォローしている人のノートが流れ、「ローカルタイムライン」には{name}全体のノートが流れます。 + */ + "step1_2": ParameterizedString<"name">; + /** + * この2つ以外にも、「ソーシャルタイムライン」は ホームTL + ローカルTL のようなもので、 「メディアタイムライン」 には{name}で何かしらのファイル付きで投稿されたノートが流れます。 + */ + "step1_3": ParameterizedString<"name">; + /** + * 試しに、何かノートを投稿してみましょう。画面上にある鉛筆マークのボタンを押すとフォームが開きます。 + */ + "step2_1": string; + /** + * 初めてのノートの内容は、あなたの自己紹介や「{name}始めました」などがおすすめです。 + */ + "step2_2": ParameterizedString<"name">; + /** + * 投稿できましたか? + */ + "step3_1": string; + /** + * あなたのノートがタイムラインに表示されていれば成功です。 + */ + "step3_2": string; + /** + * ノートには、「リアクション」を付けることができます。 + */ + "step4_1": string; + /** + * リアクションを付けるには、ノートの「+」マークをクリックして、好きな絵文字を選択します。 + */ + "step4_2": string; + }; "_2fa": { /** * 既に設定は完了しています。 @@ -8203,6 +8593,14 @@ export interface Locale extends ILocale { * 通知 */ "notifications": string; + /** + * ゲーミングモード + */ + "gamingMode": string; + /** + * 反転モード + */ + "gyakubariMode": string; /** * タイムライン */ @@ -8697,6 +9095,10 @@ export interface Locale extends ILocale { * ローカル */ "local": string; + /** + * メディア + */ + "media": string; /** * ソーシャル */ @@ -9037,6 +9439,10 @@ export interface Locale extends ILocale { * 実績を獲得 */ "achievementEarned": string; + /** + * ログインボーナス + */ + "loginbonus": string; /** * 通知テスト */ @@ -9126,6 +9532,10 @@ export interface Locale extends ILocale { * 実績の獲得 */ "achievementEarned": string; + /** + * ログインボーナス + */ + "loginbonus": string; /** * 連携アプリからの通知 */ @@ -9758,6 +10168,40 @@ export interface Locale extends ILocale { }; }; }; + "_schedulePost": { + /** + * 予約投稿一覧 + */ + "list": string; + /** + * 日付 + */ + "postDate": string; + /** + * 時刻 + */ + "postTime": string; + /** + * 端末に設定されているタイムゾーンの時刻で投稿されます。 + */ + "localTime": string; + /** + * 予約設定 + */ + "addSchedule": string; + /** + * {date}に投稿予約しました。 + */ + "willBePostedAtX": ParameterizedString<"date">; + /** + * 予約投稿を削除しますか? + */ + "deleteAreYouSure": string; + /** + * 予約投稿を削除して編集しますか? + */ + "deleteAndEditConfirm": string; + }; "_dataSaver": { "_media": { /** @@ -10070,6 +10514,16 @@ export interface Locale extends ILocale { * その他の貢献者 */ "etcContributor": string; + "_draftSavingBehavior": { + /** + * 自動的に保存する + */ + "auto": string; + /** + * 都度確認する + */ + "manual": string; + }; } declare const locales: { [lang: string]: Locale; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 149c0b128f..f684e55ad5 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -11,16 +11,36 @@ password: "パスワード" forgotPassword: "パスワードを忘れた" fetchingAsApObject: "連合に照会中" ok: "OK" + +disableNoteDraftingDescription: "ノートの投稿フォームを開き直した際に、下書きを復元しないようにします。" +setDefaultProfileConfirm: "このプロファイルをデフォルトにしますか?" +emojiPickerProfile: "絵文字ピッカーのプロファイル" +notificationIndicator: "通知のインジケーターの数字を表示する" +hanntenn: "アイコンとバナーを反転させる" +hanntennInfo: "ダークだったらライトのアイコンに、ライトだったらダークのアイコンに。" +ruby: "ルビ" +disableNoteDrafting: "ノートの下書きの復元を無効化" +kakuregaFeature: "隠れ家" +pinnedChannel: "ピン留めされたチャンネル" gotIt: "わかった" cancel: "キャンセル" +myLists: "自分の作成したリスト" noThankYou: "やめておく" enterUsername: "ユーザー名を入力" +showGlobalTimeline: "グローバルタイムラインを表示する" +showHomeTimeline: "ホームタイムラインを表示する" +showLocalTimeline: "ローカルタイムラインを表示する" +topbarCustom: "トップバーのカスタムをする" +showSocialTimeline: "ソーシャルタイムラインを表示する" +topBarNameShown: "上のバーにTLの名前を省略して表示する" renotedBy: "{user}がリノート" noNotes: "ノートはありません" noNotifications: "通知はありません" instance: "サーバー" settings: "設定" notificationSettings: "通知の設定" +localListList: "このサーバーの公開のリスト" +favoriteLists: "お気に入りのリスト" basicSettings: "基本設定" otherSettings: "その他の設定" openInWindow: "ウィンドウで開く" @@ -76,6 +96,10 @@ export: "エクスポート" files: "ファイル" download: "ダウンロード" driveFileDeleteConfirm: "ファイル「{name}」を削除しますか?このファイルを使用した一部のコンテンツも削除されます。" +driveFilesDeleteConfirm: "{name}つのファイルを削除しますか?このファイルを使用した一部のコンテンツも削除されます。" +driveFilesSensitiveonConfirm: "{name}つのファイルをセンシティブにしますか?" +driveFilesSensitiveoffConfirm: "{name}つのファイルのセンシティブを解除しますか?" +driveFolderDeleteConfirm: "フォルダ「{name}」を削除しますか?このフォルダの中に存在するファイルを使用した一部のコンテンツも削除されます。" unfollowConfirm: "{name}のフォローを解除しますか?" exportRequested: "エクスポートをリクエストしました。これには時間がかかる場合があります。エクスポートが終わると、「ドライブ」に追加されます。" importRequested: "インポートをリクエストしました。これには時間がかかる場合があります。" @@ -85,6 +109,7 @@ note: "ノート" notes: "ノート" following: "フォロー" followers: "フォロワー" +points: "プリズム" followsYou: "フォローされています" createList: "リスト作成" manageLists: "リストの管理" @@ -172,8 +197,12 @@ cacheRemoteSensitiveFilesDescription: "この設定を無効にすると、リ flagAsBot: "Botとして設定" flagAsBotDescription: "このアカウントがプログラムによって運用される場合は、このフラグをオンにします。オンにすると、反応の連鎖を防ぐためのフラグとして他の開発者に役立ったり、Type4nyのシステム上での扱いがBotに合ったものになります。" flagAsCat: "にゃああああああああああああああ!!!!!!!!!!!!" +flagAsGorilla: "ウホウホウホホウホウホウホウホホホ!!!!!!!!!!!" flagAsCatDescription: "にゃにゃにゃ??" +flagAsGorillaDescription: "ウホウホウホ??" flagShowTimelineReplies: "タイムラインにノートへの返信を表示する" +showMediaTimeline: "メディアタイムラインを表示する" +showMediaTimelineInfo: "オンにするとメディアタイムラインを上のバーに表示します。 オフにすると表示しなくなります。" flagShowTimelineRepliesDescription: "オンにすると、タイムラインにユーザーのノート以外にもそのユーザーの他のノートへの返信を表示します。" autoAcceptFollowed: "フォロー中ユーザーからのフォロリクを自動承認" addAccount: "アカウントを追加" @@ -267,14 +296,17 @@ announcements: "お知らせ" imageUrl: "画像URL" remove: "削除" removed: "削除しました" +requestApprovalAreYouSure: "「{x}」のリクエストを承認しますか?" removeAreYouSure: "「{x}」を削除しますか?" deleteAreYouSure: "「{x}」を削除しますか?" +undraftAreYouSure: "「{x}」をドラフト解除しますか?" resetAreYouSure: "リセットしますか?" areYouSure: "よろしいですか?" saved: "保存しました" messaging: "チャット" upload: "アップロード" keepOriginalUploading: "オリジナル画像を保持" +isNotifyIsHome: "ホーム投稿で通知する" keepOriginalUploadingDescription: "画像をアップロードする時にオリジナル版を保持します。オフにするとアップロード時にブラウザでWeb公開用画像を生成します。" fromDrive: "ドライブから" fromUrl: "URLから" @@ -305,9 +337,12 @@ location: "場所" theme: "テーマ" themeForLightMode: "ライトモードで使うテーマ" themeForDarkMode: "ダークモードで使うテーマ" +gamingMode: 'ゲーミングモード' +gamingModeInfo: "ボタンなどの装飾をいい感じのグラデーションにします。 激しい点滅などはございません。" light: "ライト" dark: "ダーク" lightThemes: "明るいテーマ" +remoteUserInfoUpdate: "アイコンなどが正常に表示されない場合にここをクリックしてください。" darkThemes: "暗いテーマ" syncDeviceDarkMode: "デバイスのダークモードと同期する" drive: "ドライブ" @@ -322,6 +357,7 @@ folderName: "フォルダー名" createFolder: "フォルダーを作成" renameFolder: "フォルダー名を変更" deleteFolder: "フォルダーを削除" +Folder: "フォルダー" folder: "フォルダー" addFile: "ファイルを追加" emptyDrive: "ドライブは空です" @@ -526,6 +562,7 @@ dayOverDayChanges: "前日比" appearance: "アピアランス" clientSettings: "クライアント設定" accountSettings: "アカウント設定" +timelineHeader: "タイムラインのヘッダー" promotion: "プロモーション" promote: "プロモート" numberOfDays: "日数" @@ -553,6 +590,8 @@ serverLogs: "サーバーログ" deleteAll: "全て削除" showFixedPostForm: "タイムライン上部に投稿フォームを表示する" showFixedPostFormInChannel: "タイムライン上部に投稿フォームを表示する(チャンネル)" +FeaturedOrNote: "ユーザーのページで最新のノートを表示する" +FeaturedOrNoteInfo: "ユーザーのページに行ったときにハイライトか最新のノートを表示するかを選択することができます。 オフでハイライト オンで最新のノート です" withRepliesByDefaultForNewlyFollowed: "フォローする際、デフォルトで返信をTLに含むようにする" newNoteRecived: "新しいノートがあります" sounds: "サウンド" @@ -615,6 +654,7 @@ invisibleNote: "非公開の投稿" enableInfiniteScroll: "自動でもっと見る" visibility: "公開範囲" poll: "アンケート" +schedulePost: "予約投稿" useCw: "内容を隠す" enablePlayer: "プレイヤーを開く" disablePlayer: "プレイヤーを閉じる" @@ -638,6 +678,7 @@ large: "大" medium: "中" small: "小" generateAccessToken: "アクセストークンの発行" +accessToken: "アクセストークン" permission: "権限" adminPermission: "管理者権限" enableAll: "全て有効にする" @@ -672,6 +713,7 @@ copy: "コピー" metrics: "メトリクス" overview: "概要" logs: "ログ" +mfm: 'mfm 装飾' delayed: "遅延" database: "データベース" channel: "チャンネル" @@ -685,6 +727,8 @@ regenerateLoginToken: "ログイントークンを再生成" regenerateLoginTokenDescription: "ログインに使用される内部トークンを再生成します。通常この操作を行う必要はありません。再生成すると、全てのデバイスでログアウトされます。" theKeywordWhenSearchingForCustomEmoji: "カスタム絵文字を検索する時のキーワードになります。" setMultipleBySeparatingWithSpace: "スペースで区切って複数設定できます。" +emojiNameValidation: "名前には英数字と_が利用できます。" +isSensitive: "センシティブ" fileIdOrUrl: "ファイルIDまたはURL" behavior: "動作" sample: "サンプル" @@ -700,6 +744,8 @@ reporterOrigin: "通報元" forwardReport: "リモートサーバーに通報を転送する" forwardReportIsAnonymous: "リモートサーバーからはあなたの情報は見れず、匿名のシステムアカウントとして表示されます。" send: "送信" +fileAttachedOnly: "ファイル付きのみ" +reportedNote: "通報されたノート" abuseMarkAsResolved: "対応済みにする" openInNewTab: "新しいタブで開く" openInSideView: "サイドビューで開く" @@ -732,6 +778,8 @@ followersCount: "フォロワー数" sentReactionsCount: "リアクションした数" receivedReactionsCount: "リアクションされた数" pollVotesCount: "アンケートに投票した数" +onlyAndWithSave: "タイムラインの絞り込みを保存する" +onlyAndWithSaveInfo: "ファイルのみ や リプライのみ などが保存されるようになります" pollVotedCount: "アンケートに投票された数" yes: "はい" no: "いいえ" @@ -854,8 +902,14 @@ priority: "優先度" high: "高" middle: "中" low: "低" +list: "一覧" +GamingSpeedChange: "ゲーミングの光るスピードの調整" +GamingSpeedChangeInfo: "左にすれば早くなる、右にすれば遅くなる。それだけ。" emailNotConfiguredWarning: "メールアドレスの設定がされていません。" ratio: "比率" +showVisibilityColor: "ノートの公開範囲を色付けする" +newEmojis: "新しい絵文字" +draftEmojis: "申請されている絵文字" previewNoteText: "本文をプレビュー" customCss: "カスタムCSS" customCssWarn: "この設定は必ず知識のある方が行ってください。不適切な設定を行うとクライアントが正常に使用できなくなる恐れがあります。" @@ -888,6 +942,7 @@ itsOff: "オフになっています" on: "オン" off: "オフ" emailRequiredForSignup: "アカウント登録にメールアドレスを必須にする" +enableGDPRMode: "GDPRモードを有効にする" unread: "未読" filter: "フィルタ" controlPanel: "コントロールパネル" @@ -1009,6 +1064,7 @@ assign: "アサイン" unassign: "アサインを解除" color: "色" manageCustomEmojis: "カスタム絵文字の管理" +requestCustomEmojis: "カスタム絵文字のリクエスト" manageAvatarDecorations: "アバターデコレーションの管理" youCannotCreateAnymore: "これ以上作成することはできません。" cannotPerformTemporary: "一時的に利用できません" @@ -1058,6 +1114,11 @@ hiddenTags: "非表示ハッシュタグ" hiddenTagsDescription: "設定したタグをトレンドに表示させないようにします。改行で区切って複数設定できます。" notesSearchNotAvailable: "ノート検索は利用できません。" license: "ライセンス" +requestPending: "申請中" +approval: "承認" +requestingEmojis: "リクエストされている絵文字" +draft: "ドラフト" +undrafted: "ドラフト解除" unfavoriteConfirm: "お気に入り解除しますか?" myClips: "自分のクリップ" drivecleaner: "ドライブクリーナー" @@ -1075,6 +1136,9 @@ videos: "動画" audio: "音声" audioFiles: "音声" dataSaver: "データセーバー" +cellularWithDataSaver: "モバイルデータ通信でデータセーバーをオンにする" +UltimateDataSaver: "究極のデータセーバー" +cellularWithUltimateDataSaver: "モバイルデータ通信で究極のデータセーバーをオンにする" accountMigration: "アカウントの移行" accountMoved: "このユーザーは新しいアカウントに移行しました:" accountMovedShort: "このアカウントは移行されています" @@ -1166,11 +1230,11 @@ authentication: "認証" authenticationRequiredToContinue: "続けるには認証を行ってください" dateAndTime: "日時" showRenotes: "リノートを表示" +showCw: "CWを非表示" edited: "編集済み" notificationRecieveConfig: "通知の受信設定" mutualFollow: "相互フォロー" followingOrFollower: "フォロー中またはフォロワー" -fileAttachedOnly: "ファイル付きのみ" showRepliesToOthersInTimeline: "TLに他の人への返信を含める" hideRepliesToOthersInTimeline: "TLに他の人への返信を含めない" showRepliesToOthersInTimelineAll: "TLに現在フォロー中の人全員の返信を含めるようにする" @@ -1242,6 +1306,23 @@ keepOriginalFilenameDescription: "この設定をオフにすると、アップ noDescription: "説明文はありません" alwaysConfirmFollow: "フォローの際常に確認する" inquiry: "お問い合わせ" +scheduledNoteDelete: "ノートの自己消滅" +noteDeletationAt: "このノートは{time}に消滅します" +cannotScheduleLaterThanOneYear: "1年以上先の日時を指定することはできません" +hideActivity: "アクティビティを非公開にする" +hideActivityDescription: "自分のプロフィールのアクティビティ (概要/アクティビティタブ) を他人が見れないようにします。このオプションを有効にしても、自分であればプロフィールのアクティビティタブから引き続き閲覧できます。" +channelAnnouncementDescription: "このお知らせはチャンネルのタイムライン上部に表示されます。最初の1行がタイトルとして表示され、2行目以降はお知らせをタップすることで表示されるようになります。" +postForm: "投稿フォーム" +postFormBottomSettingsDescription: "投稿フォームの下部に表示される項目の並び替えが出来ます。項目をクリックすると削除できます。" +clearPost: "投稿フォームをリセット" +addToEmojiPicker: "絵文字ピッカーに追加" +hideReactionCount: "リアクション数の非表示" +hideReactionUsers: "誰がリアクションをしたのかを非表示にする" +hideReactionUsersDescription: "リアクションをホバーした際のユーザー一覧と、ノート詳細ページのリアクションタブにあるリアクションをしたユーザー一覧を非表示にします" +drafts: "下書き" +draftSavingBehavior: "下書きの保存に関する動作" +saveAsDraft: "下書きとして保存" +draftOverwriteConfirm: "下書きを適用すると現在入力されている内容はリセットされます。よろしいですか?" _delivery: status: "配信状態" @@ -1694,14 +1775,18 @@ _role: high: "高" _options: gtlAvailable: "グローバルタイムラインの閲覧" + emojiPickerProfileLimit: "絵文字ピッカーのプロファイルの上限数(最大5)" ltlAvailable: "ローカルタイムラインの閲覧" canPublicNote: "パブリック投稿の許可" + canEditNote: "ノートの編集" + canScheduleNote: "予約投稿の許可" mentionMax: "ノート内の最大メンション数" canInvite: "サーバー招待コードの発行" inviteLimit: "招待コードの作成可能数" inviteLimitCycle: "招待コードの発行間隔" inviteExpirationTime: "招待コードの有効期限" canManageCustomEmojis: "カスタム絵文字の管理" + canRequestCustomEmojis: "カスタム絵文字のリクエスト" canManageAvatarDecorations: "アバターデコレーションの管理" driveCapacity: "ドライブ容量" alwaysMarkNsfw: "ファイルにNSFWを常に付与" @@ -1719,6 +1804,8 @@ _role: canSearchNotes: "ノート検索の利用" canUseTranslator: "翻訳機能の利用" avatarDecorationLimit: "アイコンデコレーションの最大取付個数" + listPinnedLimit: "ピン留めリストの最大数" + localTimelineAnyLimit: "他鯖のローカルTL除けるやつ(最大値5)" _condition: roleAssignedTo: "マニュアルロールにアサイン済み" isLocal: "ローカルユーザー" @@ -1842,6 +1929,7 @@ _aboutType4ny: source: "ソースコード" original: "オリジナル" thisIsModifiedVersion: "{name}はオリジナルのType4nyを改変したバージョンを使用しています。" + forksource: "当フォークのソースコード" translation: "Type4nyを翻訳" donate: "Type4nyに寄付" morePatrons: "他にも多くの方が支援してくれています。ありがとうございます🥰" @@ -1887,6 +1975,7 @@ _wordMute: muteWords: "ミュートするワード" muteWordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。" muteWordsDescription2: "キーワードをスラッシュで囲むと正規表現になります。" + hideMutedNotes: "ミュートされた単語を含むノートを非表示にする" _instanceMute: instanceMuteDescription: "ミュートしたサーバーのユーザーへの返信を含めて、設定したサーバーの全てのノートとRenoteをミュートします。" @@ -2010,6 +2099,18 @@ _time: hour: "時間" day: "日" +_timelineTutorial: + title: "Misskeyの使い方" + step1_1: "この画面は「タイムライン」です。{name}に投稿された「ノート」が時系列で表示されます。" + step1_2: "タイムラインにはいくつか種類があり、例えば「ホームタイムライン」にはあなたがフォローしている人のノートが流れ、「ローカルタイムライン」には{name}全体のノートが流れます。" + step1_3: "この2つ以外にも、「ソーシャルタイムライン」は ホームTL + ローカルTL のようなもので、 「メディアタイムライン」 には{name}で何かしらのファイル付きで投稿されたノートが流れます。" + step2_1: "試しに、何かノートを投稿してみましょう。画面上にある鉛筆マークのボタンを押すとフォームが開きます。" + step2_2: "初めてのノートの内容は、あなたの自己紹介や「{name}始めました」などがおすすめです。" + step3_1: "投稿できましたか?" + step3_2: "あなたのノートがタイムラインに表示されていれば成功です。" + step4_1: "ノートには、「リアクション」を付けることができます。" + step4_2: "リアクションを付けるには、ノートの「+」マークをクリックして、好きな絵文字を選択します。" + _2fa: alreadyRegistered: "既に設定は完了しています。" registerTOTP: "認証アプリの設定を開始" @@ -2158,6 +2259,8 @@ _widgets: instanceInfo: "サーバー情報" memo: "付箋" notifications: "通知" + gamingMode: "ゲーミングモード" + gyakubariMode: "反転モード" timeline: "タイムライン" calendar: "カレンダー" trends: "トレンド" @@ -2296,6 +2399,7 @@ _instanceCharts: _timelines: home: "ホーム" local: "ローカル" + media: "メディア" social: "ソーシャル" global: "グローバル" @@ -2389,6 +2493,7 @@ _notification: roleAssigned: "ロールが付与されました" emptyPushNotificationMessage: "プッシュ通知の更新をしました" achievementEarned: "実績を獲得" + loginbonus: "ログインボーナス" testNotification: "通知テスト" checkNotificationBehavior: "通知の表示を確かめる" sendTestNotification: "テスト通知を送信する" @@ -2413,6 +2518,7 @@ _notification: followRequestAccepted: "フォローが受理された" roleAssigned: "ロールが付与された" achievementEarned: "実績の獲得" + loginbonus: "ログインボーナス" app: "連携アプリからの通知" _actions: @@ -2597,6 +2703,16 @@ _externalResourceInstaller: title: "テーマのインストールに失敗しました" description: "テーマのインストール中に問題が発生しました。もう一度お試しください。エラーの詳細はJavascriptコンソールをご覧ください。" +_schedulePost: + list: "予約投稿一覧" + postDate: "日付" + postTime: "時刻" + localTime: "端末に設定されているタイムゾーンの時刻で投稿されます。" + addSchedule: "予約設定" + willBePostedAtX: "{date}に投稿予約しました。" + deleteAreYouSure: "予約投稿を削除しますか?" + deleteAndEditConfirm: "予約投稿を削除して編集しますか?" + _dataSaver: _media: title: "メディアの読み込みを無効化" @@ -2686,3 +2802,7 @@ _mediaControls: loop: "ループ再生" etcContributor: "その他の貢献者" + +_draftSavingBehavior: + auto: "自動的に保存する" + manual: "都度確認する" diff --git a/misskey-assets b/misskey-assets index 0179793ec8..cf3ce27b2e 160000 --- a/misskey-assets +++ b/misskey-assets @@ -1 +1 @@ -Subproject commit 0179793ec891856d6f37a3be16ba4c22f67a81b5 +Subproject commit cf3ce27b2eb8417233072e3d6d2fb7c5356c2364 diff --git a/package.json b/package.json index 10d04d9631..ca78eca1bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "type4ny", - "version": "2024.5.0", + "version": "2024.5.0-mattyatea4", "codename": "nasubi", "repository": { "type": "git", diff --git a/packages/backend/migration/1684236161625-addEmojiDraftFlag.js b/packages/backend/migration/1684236161625-addEmojiDraftFlag.js new file mode 100644 index 0000000000..b0a13ea498 --- /dev/null +++ b/packages/backend/migration/1684236161625-addEmojiDraftFlag.js @@ -0,0 +1,11 @@ +export class AddEmojiDraftFlag1684236161625 { + name = 'AddEmojiDraftFlag1684236161625' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "emoji" ADD "draft" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "draft"`); + } +} diff --git a/packages/backend/migration/1696044626209-noteEditHistory.js b/packages/backend/migration/1696044626209-noteEditHistory.js new file mode 100644 index 0000000000..37a542aa1e --- /dev/null +++ b/packages/backend/migration/1696044626209-noteEditHistory.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class NoteEditHistory1696044626209 { + name = 'NoteEditHistory1696044626209' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" ADD "noteEditHistory" varchar(3000) array DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" DROP "noteEditHistory"`); + } +} diff --git a/packages/backend/migration/1696318192428-noteUpdatedAtHistory.js b/packages/backend/migration/1696318192428-noteUpdatedAtHistory.js new file mode 100644 index 0000000000..70220f3058 --- /dev/null +++ b/packages/backend/migration/1696318192428-noteUpdatedAtHistory.js @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey, cherrypick contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class NoteUpdateAtHistory1696318192428 { + name = 'NoteUpdateAtHistory1696318192428' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" ADD "updatedAtHistory" TIMESTAMP WITH TIME ZONE array`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" DROP "updatedAtHistory"`); + } + +} diff --git a/packages/backend/migration/1696604429200-revert-revert-note-editting.js b/packages/backend/migration/1696604429200-revert-revert-note-editting.js new file mode 100644 index 0000000000..f723e68b44 --- /dev/null +++ b/packages/backend/migration/1696604429200-revert-revert-note-editting.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class revertrevertnoteeditting1696604429200 { + name = 'revertrevertnoteeditting1696604429200' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" ADD "updatedAt" TIMESTAMP WITH TIME ZONE`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "updatedAt"`); + } +} diff --git a/packages/backend/migration/1696604572677-poll_vote_poll.js b/packages/backend/migration/1696604572677-poll_vote_poll.js new file mode 100644 index 0000000000..da52904565 --- /dev/null +++ b/packages/backend/migration/1696604572677-poll_vote_poll.js @@ -0,0 +1,12 @@ +export class PollVotePoll1696604572677 { + name = 'PollVotePoll1696604572677'; + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "poll_vote" ADD CONSTRAINT "FK_poll_vote_poll" FOREIGN KEY ("noteId") REFERENCES "poll"("noteId") ON DELETE CASCADE`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "poll_vote" DROP CONSTRAINT "FK_poll_vote_poll"`); + } + +} diff --git a/packages/backend/migration/1697641012204-DiscordWebhookUrl.js b/packages/backend/migration/1697641012204-DiscordWebhookUrl.js new file mode 100644 index 0000000000..bde713177d --- /dev/null +++ b/packages/backend/migration/1697641012204-DiscordWebhookUrl.js @@ -0,0 +1,11 @@ +export class DiscordWebhookUrl1697641012204 { + name = 'DiscordWebhookUrl1697641012204' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "DiscordWebhookUrl" character varying(1024)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "DiscordWebhookUrl"`); + } +} diff --git a/packages/backend/migration/1697642704514-EmojiBotToken.js b/packages/backend/migration/1697642704514-EmojiBotToken.js new file mode 100644 index 0000000000..8e284ede41 --- /dev/null +++ b/packages/backend/migration/1697642704514-EmojiBotToken.js @@ -0,0 +1,11 @@ +export class EmojiBotToken1697642704514 { + name = 'EmojiBotToken1697642704514' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "EmojiBotToken" character varying(1024)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "EmojiBotToken"`); + } +} diff --git a/packages/backend/migration/1697645425687-BaseUrl.js b/packages/backend/migration/1697645425687-BaseUrl.js new file mode 100644 index 0000000000..1d1020b1cf --- /dev/null +++ b/packages/backend/migration/1697645425687-BaseUrl.js @@ -0,0 +1,15 @@ +export class BaseUrl1697645425687 { + name = 'BaseUrl1697645425687' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "ApiBase" character varying(1024)`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "preservedUsernames" SET DEFAULT '{ "admin", "administrator", "root", "system", "maintainer", "host", "mod", "moderator", "owner", "superuser", "staff", "auth", "i", "me", "everyone", "all", "mention", "mentions", "example", "user", "users", "account", "accounts", "official", "help", "helps", "support", "supports", "info", "information", "informations", "announce", "announces", "announcement", "announcements", "notice", "notification", "notifications", "dev", "developer", "developers", "tech", "misskey" }'`); + await queryRunner.query(`ALTER TABLE "flash" ALTER COLUMN "visibility" SET NOT NULL`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "flash" ALTER COLUMN "visibility" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "preservedUsernames" SET DEFAULT '{admin,administrator,root,system,maintainer,host,mod,moderator,owner,superuser,staff,auth,i,me,everyone,all,mention,mentions,example,user,users,account,accounts,official,help,helps,support,supports,info,information,informations,announce,announces,announcement,announcements,notice,notification,notifications,dev,developer,developers,tech,misskey}'`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "ApiBase"`); + } +} diff --git a/packages/backend/migration/1698131657286-EmojiRequest.js b/packages/backend/migration/1698131657286-EmojiRequest.js new file mode 100644 index 0000000000..7db57c9cd0 --- /dev/null +++ b/packages/backend/migration/1698131657286-EmojiRequest.js @@ -0,0 +1,13 @@ +export class EmojiRequest1698131657286 { + name = 'EmojiRequest1698131657286' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "emoji_request" ("id" character varying(32) NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE, "name" character varying(128) NOT NULL, "category" character varying(128), "originalUrl" character varying(512) NOT NULL, "publicUrl" character varying(512) NOT NULL DEFAULT '', "type" character varying(64), "aliases" character varying(128) array NOT NULL DEFAULT '{}', "license" character varying(1024), "fileId" character varying(1024) NOT NULL, "localOnly" boolean NOT NULL DEFAULT false, "isSensitive" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_3c74521e048dc744f0c7eb65f4a" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_ea1d771e867e9843300f09d02c" ON "emoji_request" ("name") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_ea1d771e867e9843300f09d02c"`); + await queryRunner.query(`DROP TABLE "emoji_request"`); + } +} diff --git a/packages/backend/migration/1698907074200-gorillamode.js b/packages/backend/migration/1698907074200-gorillamode.js new file mode 100644 index 0000000000..6d7d747407 --- /dev/null +++ b/packages/backend/migration/1698907074200-gorillamode.js @@ -0,0 +1,13 @@ +export class Gorillamode1698907074200 { + name = 'Gorillamode1698907074200' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "isGorilla" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`COMMENT ON COLUMN "user"."isGorilla" IS 'Whether the User is a gorilla.'`); + } + + async down(queryRunner) { + await queryRunner.query(`COMMENT ON COLUMN "user"."isGorilla" IS 'Whether the User is a gorilla.'`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "isGorilla"`); + } +} diff --git a/packages/backend/migration/1699437894737-schedulenote.js b/packages/backend/migration/1699437894737-schedulenote.js new file mode 100644 index 0000000000..d0188a45ee --- /dev/null +++ b/packages/backend/migration/1699437894737-schedulenote.js @@ -0,0 +1,12 @@ +export class Schedulenote1699437894737 { + name = 'Schedulenote1699437894737' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "note_schedule" ("id" character varying(32) NOT NULL, "note" jsonb NOT NULL, "userId" character varying(260) NOT NULL, "expiresAt" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_3a1ae2db41988f4994268218436" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_e798958c40009bf0cdef4f28b5" ON "note_schedule" ("userId") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP TABLE "note_schedule"`); + } +} diff --git a/packages/backend/migration/1699949373507-schedulenote2.js b/packages/backend/migration/1699949373507-schedulenote2.js new file mode 100644 index 0000000000..b8d8a6394b --- /dev/null +++ b/packages/backend/migration/1699949373507-schedulenote2.js @@ -0,0 +1,11 @@ +export class Schedulenote21699949373507 { + name = 'Schedulenote21699949373507' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note_schedule" RENAME COLUMN "expiresAt" TO "scheduledAt"`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note_schedule" RENAME COLUMN "scheduledAt" TO "expiresAt"`); + } +} diff --git a/packages/backend/migration/1702149469508-abusenoteselect.js b/packages/backend/migration/1702149469508-abusenoteselect.js new file mode 100644 index 0000000000..eb9d5b34d8 --- /dev/null +++ b/packages/backend/migration/1702149469508-abusenoteselect.js @@ -0,0 +1,11 @@ +export class Abusenoteselect1702149469508 { + name = 'Abusenoteselect1702149469508' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD "notes" jsonb NOT NULL DEFAULT '[]'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP COLUMN "notes"`); + } +} diff --git a/packages/backend/migration/1703294653915-requestemoji.js b/packages/backend/migration/1703294653915-requestemoji.js new file mode 100644 index 0000000000..358e77bf07 --- /dev/null +++ b/packages/backend/migration/1703294653915-requestemoji.js @@ -0,0 +1,11 @@ +export class Requestemoji1703294653915 { + name = 'Requestemoji1703294653915' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "requestEmojiAllOk" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "requestEmojiAllOk"`); + } +} diff --git a/packages/backend/migration/1703704097603-GDPRMode.js b/packages/backend/migration/1703704097603-GDPRMode.js new file mode 100644 index 0000000000..86a7e5d85f --- /dev/null +++ b/packages/backend/migration/1703704097603-GDPRMode.js @@ -0,0 +1,11 @@ +export class GDPRMode1703704097603 { + name = 'GDPRMode1703704097603' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "enableGDPRMode" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) {; + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableGDPRMode"`); + } +} diff --git a/packages/backend/migration/1704005554275-abusenoteIds.js b/packages/backend/migration/1704005554275-abusenoteIds.js new file mode 100644 index 0000000000..e52f8850b7 --- /dev/null +++ b/packages/backend/migration/1704005554275-abusenoteIds.js @@ -0,0 +1,11 @@ +export class AbusenoteId1704005554275 { + name = 'AbusenoteId1704005554275' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD "noteIds" jsonb NOT NULL DEFAULT '[]'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP COLUMN "noteIds"`); + } +} diff --git a/packages/backend/migration/1704206095136-avatardecoration.js b/packages/backend/migration/1704206095136-avatardecoration.js new file mode 100644 index 0000000000..797d91a74e --- /dev/null +++ b/packages/backend/migration/1704206095136-avatardecoration.js @@ -0,0 +1,11 @@ +export class Avatardecoration1704206095136 { + name = 'Avatardecoration1704206095136' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "avatar_decoration" ADD "category" character varying(256) NOT NULL DEFAULT ''`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "avatar_decoration" DROP COLUMN "category"`); + } +} diff --git a/packages/backend/migration/1704343998612-avatardecoration_fed.js b/packages/backend/migration/1704343998612-avatardecoration_fed.js new file mode 100644 index 0000000000..8e182f92f4 --- /dev/null +++ b/packages/backend/migration/1704343998612-avatardecoration_fed.js @@ -0,0 +1,13 @@ +export class AvatardecorationFed1704343998612 { + name = 'AvatardecorationFed1704343998612' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "avatar_decoration" ADD "host" character varying(256)`); + await queryRunner.query(`ALTER TABLE "avatar_decoration" ALTER COLUMN "category" SET DEFAULT ''`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "avatar_decoration" ALTER COLUMN "category" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "avatar_decoration" DROP COLUMN "host"`); + } +} diff --git a/packages/backend/migration/1707888646527-proxycheckio.js b/packages/backend/migration/1707888646527-proxycheckio.js new file mode 100644 index 0000000000..62ecc0ddc7 --- /dev/null +++ b/packages/backend/migration/1707888646527-proxycheckio.js @@ -0,0 +1,13 @@ +export class Proxycheckio1707888646527 { + name = 'Proxycheckio1707888646527' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "enableProxyCheckio" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "meta" ADD "proxyCheckioApiKey" character varying(32)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "proxyCheckioApiKey"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableProxyCheckio"`); + } +} diff --git a/packages/backend/migration/1708081353629-discordwebohookwordbrock.js b/packages/backend/migration/1708081353629-discordwebohookwordbrock.js new file mode 100644 index 0000000000..aee2bfacc4 --- /dev/null +++ b/packages/backend/migration/1708081353629-discordwebohookwordbrock.js @@ -0,0 +1,11 @@ +export class Discordwebohookwordbrock1708081353629 { + name = 'Discordwebohookwordbrock1708081353629' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "DiscordWebhookUrlWordBlock" character varying(1024)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "DiscordWebhookUrlWordBlock"`); +} +} diff --git a/packages/backend/migration/1714831133155-outsideprismisskey.js b/packages/backend/migration/1714831133155-outsideprismisskey.js new file mode 100644 index 0000000000..0da10f603d --- /dev/null +++ b/packages/backend/migration/1714831133155-outsideprismisskey.js @@ -0,0 +1,17 @@ +export class Outsideprismisskey1714831133155 { + name = 'Outsideprismisskey1714831133155' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "bannerDark" character varying(1024)`); + await queryRunner.query(`ALTER TABLE "meta" ADD "bannerLight" character varying(1024)`); + await queryRunner.query(`ALTER TABLE "meta" ADD "iconDark" character varying(1024)`); + await queryRunner.query(`ALTER TABLE "meta" ADD "iconLight" character varying(1024)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "iconLight"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "iconDark"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "bannerLight"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "bannerDark"`); + } +} diff --git a/packages/backend/migration/1714831133156-avatardecoration_fed.js b/packages/backend/migration/1714831133156-avatardecoration_fed.js new file mode 100644 index 0000000000..94093092bd --- /dev/null +++ b/packages/backend/migration/1714831133156-avatardecoration_fed.js @@ -0,0 +1,11 @@ +export class AvatardecorationFed1714831133156 { + name = 'AvatardecorationFed1714831133156' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "avatar_decoration" DROP COLUMN "host"`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "avatar_decoration" ADD "host" character varying(256)`); + } +} diff --git a/packages/backend/migration/1715791271605-loginbonus1.js b/packages/backend/migration/1715791271605-loginbonus1.js new file mode 100644 index 0000000000..6d9407a5b1 --- /dev/null +++ b/packages/backend/migration/1715791271605-loginbonus1.js @@ -0,0 +1,11 @@ +export class Loginbonus11715791271605 { + name = 'Loginbonus11715791271605' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "getPoints" integer NOT NULL DEFAULT '0'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" ADD "getPoints" integer NOT NULL DEFAULT '0'`); + } +} diff --git a/packages/backend/migration/1716911535226-gapikey.js b/packages/backend/migration/1716911535226-gapikey.js new file mode 100644 index 0000000000..5ec4594aeb --- /dev/null +++ b/packages/backend/migration/1716911535226-gapikey.js @@ -0,0 +1,11 @@ +export class Gapikey1716911535226 { + name = 'Gapikey1716911535226' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "googleAnalyticsId" character varying(1024)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "googleAnalyticsId"`); + } +} diff --git a/packages/backend/migration/1718857094676-emojimore.js b/packages/backend/migration/1718857094676-emojimore.js new file mode 100644 index 0000000000..596b3242b9 --- /dev/null +++ b/packages/backend/migration/1718857094676-emojimore.js @@ -0,0 +1,16 @@ +export class Emojimore1718857094676 { + name = 'Emojimore1718857094676' + + async up(queryRunner) { + await queryRunner.query(`DROP INDEX "IDX_ad0c221b25672daf2df320a817"`); + await queryRunner.query(`CREATE INDEX "IDX_ad0c221b25672daf2df320a817" ON "note_reaction" ("userId", "noteId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_a7751b74317122d11575bff31c" ON "note_reaction" ("userId", "noteId", "reaction") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_a7751b74317122d11575bff31c"`); + await queryRunner.query(`DROP INDEX "public"."IDX_ad0c221b25672daf2df320a817"`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_ad0c221b25672daf2df320a817" ON "note_reaction" ("userId", "noteId") `); + + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 0467ab0bee..38666e707e 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -154,6 +154,7 @@ "pkce-challenge": "4.1.0", "probe-image-size": "7.2.3", "promise-limit": "2.7.0", + "proxycheck-ts": "^0.0.9", "pug": "3.0.2", "punycode": "2.3.1", "qrcode": "1.5.3", @@ -180,6 +181,7 @@ "typescript": "5.5.2", "ulid": "2.3.0", "vary": "1.1.2", + "w3c-xmlserializer": "^5.0.0", "web-push": "3.6.7", "ws": "8.17.0", "xev": "3.0.2" @@ -225,6 +227,7 @@ "@types/tinycolor2": "1.4.6", "@types/tmp": "0.2.6", "@types/vary": "1.1.3", + "@types/w3c-xmlserializer": "^2.0.4", "@types/web-push": "3.6.3", "@types/ws": "8.5.10", "@typescript-eslint/eslint-plugin": "7.7.1", diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index c719d08725..dbcd91c76d 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -130,7 +130,7 @@ export class CacheService implements OnApplicationShutdown { case 'userChangeSuspendedState': case 'userChangeDeletedState': case 'remoteUserUpdated': - case 'localUserUpdated': { + { const user = await this.usersRepository.findOneBy({ id: body.id }); if (user == null) { this.userByIdCache.delete(body.id); diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 18f7177dbc..3c64fb09b6 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -40,6 +40,7 @@ import { MetaService } from './MetaService.js'; import { MfmService } from './MfmService.js'; import { ModerationLogService } from './ModerationLogService.js'; import { NoteCreateService } from './NoteCreateService.js'; +import { NoteUpdateService } from './NoteUpdateService.js'; import { NoteDeleteService } from './NoteDeleteService.js'; import { NotePiningService } from './NotePiningService.js'; import { NoteReadService } from './NoteReadService.js'; @@ -101,6 +102,7 @@ import { ClipEntityService } from './entities/ClipEntityService.js'; import { DriveFileEntityService } from './entities/DriveFileEntityService.js'; import { DriveFolderEntityService } from './entities/DriveFolderEntityService.js'; import { EmojiEntityService } from './entities/EmojiEntityService.js'; +import { EmojiRequestsEntityService } from './entities/EmojiRequestsEntityService.js'; import { FollowingEntityService } from './entities/FollowingEntityService.js'; import { FollowRequestEntityService } from './entities/FollowRequestEntityService.js'; import { GalleryLikeEntityService } from './entities/GalleryLikeEntityService.js'; @@ -181,6 +183,7 @@ const $MetaService: Provider = { provide: 'MetaService', useExisting: MetaServic const $MfmService: Provider = { provide: 'MfmService', useExisting: MfmService }; const $ModerationLogService: Provider = { provide: 'ModerationLogService', useExisting: ModerationLogService }; const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService }; +const $NoteUpdateService: Provider = { provide: 'NoteUpdateService', useExisting: NoteUpdateService }; const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService }; const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService }; const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService }; @@ -245,6 +248,7 @@ const $ClipEntityService: Provider = { provide: 'ClipEntityService', useExisting const $DriveFileEntityService: Provider = { provide: 'DriveFileEntityService', useExisting: DriveFileEntityService }; const $DriveFolderEntityService: Provider = { provide: 'DriveFolderEntityService', useExisting: DriveFolderEntityService }; const $EmojiEntityService: Provider = { provide: 'EmojiEntityService', useExisting: EmojiEntityService }; +const $EmojiRequestsEntityService: Provider = { provide: 'EmojiRequestsEntityService', useExisting: EmojiRequestsEntityService }; const $FollowingEntityService: Provider = { provide: 'FollowingEntityService', useExisting: FollowingEntityService }; const $FollowRequestEntityService: Provider = { provide: 'FollowRequestEntityService', useExisting: FollowRequestEntityService }; const $GalleryLikeEntityService: Provider = { provide: 'GalleryLikeEntityService', useExisting: GalleryLikeEntityService }; @@ -327,6 +331,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting MfmService, ModerationLogService, NoteCreateService, + NoteUpdateService, NoteDeleteService, NotePiningService, NoteReadService, @@ -391,6 +396,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting DriveFileEntityService, DriveFolderEntityService, EmojiEntityService, + EmojiRequestsEntityService, FollowingEntityService, FollowRequestEntityService, GalleryLikeEntityService, @@ -469,6 +475,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $MfmService, $ModerationLogService, $NoteCreateService, + $NoteUpdateService, $NoteDeleteService, $NotePiningService, $NoteReadService, @@ -533,6 +540,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $DriveFileEntityService, $DriveFolderEntityService, $EmojiEntityService, + $EmojiRequestsEntityService, $FollowingEntityService, $FollowRequestEntityService, $GalleryLikeEntityService, @@ -612,6 +620,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting MfmService, ModerationLogService, NoteCreateService, + NoteUpdateService, NoteDeleteService, NotePiningService, NoteReadService, @@ -675,6 +684,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting DriveFileEntityService, DriveFolderEntityService, EmojiEntityService, + EmojiRequestsEntityService, FollowingEntityService, FollowRequestEntityService, GalleryLikeEntityService, @@ -753,6 +763,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $MfmService, $ModerationLogService, $NoteCreateService, + $NoteUpdateService, $NoteDeleteService, $NotePiningService, $NoteReadService, @@ -816,6 +827,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $DriveFileEntityService, $DriveFolderEntityService, $EmojiEntityService, + $EmojiRequestsEntityService, $FollowingEntityService, $FollowRequestEntityService, $GalleryLikeEntityService, diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index eddd801192..7e7bab4c54 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -12,13 +12,13 @@ import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiEmoji } from '@/models/Emoji.js'; -import type { EmojisRepository, MiRole, MiUser } from '@/models/_.js'; +import type { EmojisRepository, EmojiRequestsRepository, MiRole, MiUser } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js'; import { UtilityService } from '@/core/UtilityService.js'; -import { query } from '@/misc/prelude/url.js'; import type { Serialized } from '@/types.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { MiEmojiRequest } from '@/models/EmojiRequest.js'; const parseEmojiStrRegexp = /^([-\w]+)(?:@([\w.-]+))?$/; @@ -34,6 +34,9 @@ export class CustomEmojiService implements OnApplicationShutdown { @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, + @Inject(DI.emojiRequestsRepository) + private emojiRequestsRepository: EmojiRequestsRepository, + private utilityService: UtilityService, private idService: IdService, private emojiEntityService: EmojiEntityService, @@ -56,6 +59,41 @@ export class CustomEmojiService implements OnApplicationShutdown { }); } + @bindThis + public async request(data: { + driveFile: MiDriveFile; + name: string; + category: string | null; + aliases: string[]; + license: string | null; + isSensitive: boolean; + localOnly: boolean; + }, me?: MiUser): Promise { + const emoji = await this.emojiRequestsRepository.insert({ + id: this.idService.gen(), + updatedAt: new Date(), + name: data.name, + category: data.category, + aliases: data.aliases, + originalUrl: data.driveFile.url, + publicUrl: data.driveFile.webpublicUrl ?? data.driveFile.url, + type: data.driveFile.webpublicType ?? data.driveFile.type, + license: data.license, + isSensitive: data.isSensitive, + localOnly: data.localOnly, + fileId: data.driveFile.id, + }).then(x => this.emojiRequestsRepository.findOneByOrFail(x.identifiers[0])); + + if (me) { + this.moderationLogService.log(me, 'addCustomEmoji', { + emojiId: emoji.id, + emoji: emoji, + }); + } + + return emoji; + } + @bindThis public async add(data: { driveFile: MiDriveFile; @@ -159,6 +197,36 @@ export class CustomEmojiService implements OnApplicationShutdown { } } + @bindThis + public async updateRequest(id: MiEmojiRequest['id'], data: { + driveFile?: MiDriveFile; + name?: string; + category?: string | null; + aliases?: string[]; + license?: string | null; + isSensitive?: boolean; + localOnly?: boolean; + }, moderator?: MiUser): Promise { + const emoji = await this.emojiRequestsRepository.findOneByOrFail({ id: id }); + const sameNameEmoji = await this.emojiRequestsRepository.findOneBy({ name: data.name }); + if (sameNameEmoji != null && sameNameEmoji.id !== id) throw new Error('name already exists'); + + await this.emojiRequestsRepository.update(emoji.id, { + updatedAt: new Date(), + name: data.name, + category: data.category, + aliases: data.aliases, + license: data.license, + isSensitive: data.isSensitive, + localOnly: data.localOnly, + originalUrl: data.driveFile != null ? data.driveFile.url : undefined, + publicUrl: data.driveFile != null ? (data.driveFile.webpublicUrl ?? data.driveFile.url) : undefined, + type: data.driveFile != null ? (data.driveFile.webpublicType ?? data.driveFile.type) : undefined, + }); + + this.localEmojisCache.refresh(); + } + @bindThis public async addAliasesBulk(ids: MiEmoji['id'][], aliases: string[]) { const emojis = await this.emojisRepository.findBy({ @@ -267,6 +335,13 @@ export class CustomEmojiService implements OnApplicationShutdown { } } + @bindThis + public async deleteRequest(id: MiEmojiRequest['id']) { + const emoji = await this.emojiRequestsRepository.findOneByOrFail({ id: id }); + + await this.emojiRequestsRepository.delete(emoji.id); + } + @bindThis public async deleteBulk(ids: MiEmoji['id'][], moderator?: MiUser) { const emojis = await this.emojisRepository.findBy({ @@ -389,11 +464,21 @@ export class CustomEmojiService implements OnApplicationShutdown { return this.emojisRepository.exists({ where: { name, host: IsNull() } }); } + @bindThis + public checkRequestDuplicate(name: string): Promise { + return this.emojiRequestsRepository.exist({ where: { name } }); + } + @bindThis public getEmojiById(id: string): Promise { return this.emojisRepository.findOneBy({ id }); } + @bindThis + public getEmojiRequestById(id: string): Promise { + return this.emojiRequestsRepository.findOneBy({ id }); + } + @bindThis public getEmojiByName(name: string): Promise { return this.emojisRepository.findOneBy({ name, host: IsNull() }); diff --git a/packages/backend/src/core/EmailService.ts b/packages/backend/src/core/EmailService.ts index f7ba94f431..1ec94e92e2 100644 --- a/packages/backend/src/core/EmailService.ts +++ b/packages/backend/src/core/EmailService.ts @@ -213,6 +213,17 @@ export class EmailService { reason: validated.reason ? formatReason[validated.reason] ?? null : null, }; } + if (meta.enableActiveEmailValidation) { + const dispose = await this.httpRequestService.send('https://raw.githubusercontent.com/mattyatea/disposable-email-domains/master/disposable_email_blocklist.conf', { + method: 'GET', + }); + const disposableEmailDomains = (await dispose.text()).split('\n'); + const domain = emailAddress.split('@')[1]; + console.log(domain) + if (disposableEmailDomains.includes(domain)) { + validated = { valid: false, reason: 'disposable' }; + } + } const emailDomain: string = emailAddress.split('@')[1]; const isBanned = this.utilityService.isBlockedHost(meta.bannedEmailDomains, emailDomain); diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index 60b59c52d6..198b15fbc6 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -96,6 +96,7 @@ export class FanoutTimelineEndpointService { if (ps.me) { const me = ps.me; + const [ userIdsWhoMeMuting, userIdsWhoMeMutingRenotes, diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 7e8ecd8942..99d29fc97a 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -119,7 +119,7 @@ export interface NoteEventTypes { }; updated: { cw: string | null; - text: string; + text: string | null; }; reacted: { reaction: string; @@ -160,6 +160,8 @@ export interface AdminEventTypes { targetUserId: MiUser['id'], reporterId: MiUser['id'], comment: string; + notes: any[]; + noteIds: string[]; }; } diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts index 2ba17bb314..2b380a6fbf 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -6,7 +6,8 @@ import { URL } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; import * as parse5 from 'parse5'; -import { Window, XMLSerializer } from 'happy-dom'; +import { JSDOM } from 'jsdom'; +import serialize from 'w3c-xmlserializer'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { intersperse } from '@/misc/prelude/array.js'; @@ -243,7 +244,7 @@ export class MfmService { return null; } - const { window } = new Window(); + const { window } = new JSDOM() as unknown as { window: Window }; const doc = window.document; @@ -461,6 +462,6 @@ export class MfmService { appendChildren(nodes, body); - return new XMLSerializer().serializeToString(body); + return serialize(body); } } diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 577f733541..a666d27bf6 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -15,19 +15,16 @@ import { extractHashtags } from '@/misc/extract-hashtags.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js'; import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; -import type { MiDriveFile } from '@/models/DriveFile.js'; -import type { MiApp } from '@/models/App.js'; import { concat } from '@/misc/prelude/array.js'; import { IdService } from '@/core/IdService.js'; import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js'; -import type { IPoll } from '@/models/Poll.js'; import { MiPoll } from '@/models/Poll.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; import { checkWordMute } from '@/misc/check-word-mute.js'; -import type { MiChannel } from '@/models/Channel.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { MemorySingleCache } from '@/misc/cache.js'; import type { MiUserProfile } from '@/models/UserProfile.js'; +import type { MiNoteCreateOption as Option, MiMinimumUser as MinimumUser } from '@/types.js'; import { RelayService } from '@/core/RelayService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { DI } from '@/di-symbols.js'; @@ -128,14 +125,17 @@ type MinimumUser = { type Option = { createdAt?: Date | null; + updatedAt?: Date | null; name?: string | null; text?: string | null; reply?: MiNote | null; renote?: MiNote | null; files?: MiDriveFile[] | null; poll?: IPoll | null; + event?: IEvent | null; localOnly?: boolean | null; reactionAcceptance?: MiNote['reactionAcceptance']; + disableRightClick?: boolean | null; cw?: string | null; visibility?: string; visibleUsers?: MinimumUser[] | null; @@ -227,6 +227,7 @@ export class NoteCreateService implements OnApplicationShutdown { host: MiUser['host']; isBot: MiUser['isBot']; isCat: MiUser['isCat']; + isGorilla: MiUser['isGorilla']; }, data: Option, silent = false): Promise { // チャンネル外にリプライしたら対象のスコープに合わせる // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) @@ -262,13 +263,50 @@ export class NoteCreateService implements OnApplicationShutdown { } } - const hasProhibitedWords = await this.checkProhibitedWordsContain({ - cw: data.cw, - text: data.text, - pollChoices: data.poll?.choices, - }, meta.prohibitedWords); + if (this.utilityService.isKeyWordIncluded(data.cw ?? data.text ?? '', meta.prohibitedWords)) { + const { DiscordWebhookUrlWordBlock } = (await this.metaService.fetch()); + const regexpregexp = /^\/(.+)\/(.*)$/; + let matchedString = ''; + for (const filter of meta.prohibitedWords) { + // represents RegExp + const regexp = filter.match(regexpregexp); + // This should never happen due to input sanitisation. + if (!regexp) { + const words = filter.split(' '); + const foundWord = words.find(keyword => (data.cw ?? data.text ?? '').includes(keyword)); + if (foundWord) { + matchedString = foundWord; + break; + } + } else { + const match = new RE2(regexp[1], regexp[2]).exec(data.cw ?? data.text ?? ''); + if (match) { + matchedString = match[0]; + break; + } + } + } - if (hasProhibitedWords) { + if (DiscordWebhookUrlWordBlock) { + const data_disc = { 'username': 'ノートブロックお知らせ', + 'content': + 'ユーザー名 :' + user.username + '\n' + + 'url : ' + user.host + '\n' + + 'contents : ' + data.text + '\n' + + '引っかかったワード :' + matchedString, + 'allowed_mentions': { + 'parse': [], + }, + }; + + await fetch(DiscordWebhookUrlWordBlock, { + 'method': 'post', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data_disc), + }); + } throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Note contains prohibited words'); } @@ -364,6 +402,15 @@ export class NoteCreateService implements OnApplicationShutdown { mentionedUsers = data.apMentions ?? await this.extractMentionedUsers(user, combinedTokens); } + const willCauseNotification = mentionedUsers.filter(u => u.host === null).length > 0 || data.reply?.userHost === null || data.renote?.userHost === null; + + if (user.host !== null && willCauseNotification) { + const userEntity = await this.usersRepository.findOneBy({ id: user.id }); + if ((userEntity?.followersCount ?? 0) === 0) { + throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Note contains prohibited words'); + } + } + tags = tags.filter(tag => Array.from(tag).length <= 128).splice(0, 32); if (data.reply && (user.id !== data.reply.userId) && !mentionedUsers.some(u => u.id === data.reply!.userId)) { @@ -962,6 +1009,9 @@ export class NoteCreateService implements OnApplicationShutdown { this.fanoutTimelineService.push('localTimelineWithFiles', note.id, 500, r); } } + if (note.visibility === 'public' && note.userHost !== null) { + this.fanoutTimelineService.push(`remoteLocalTimeline:${note.userHost}`, note.id, 1000, r); + } } if (Math.random() < 0.1) { diff --git a/packages/backend/src/core/NoteUpdateService.ts b/packages/backend/src/core/NoteUpdateService.ts new file mode 100644 index 0000000000..db80036526 --- /dev/null +++ b/packages/backend/src/core/NoteUpdateService.ts @@ -0,0 +1,297 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey, cherrypick contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { setImmediate } from 'node:timers/promises'; +import util from 'util'; +import { In, DataSource } from 'typeorm'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; +import * as mfm from 'mfm-js'; +import type { IMentionedRemoteUsers } from '@/models/Note.js'; +import { MiNote } from '@/models/Note.js'; +import type { NotesRepository, UsersRepository } from '@/models/_.js'; +import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js'; +import { RelayService } from '@/core/RelayService.js'; +import { DI } from '@/di-symbols.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; +import { bindThis } from '@/decorators.js'; +import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; +import { SearchService } from '@/core/SearchService.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; +import { MiDriveFile } from '@/models/_.js'; +import { MiPoll, IPoll } from '@/models/Poll.js'; +import { concat } from '@/misc/prelude/array.js'; +import { extractHashtags } from '@/misc/extract-hashtags.js'; +import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; + +type MinimumUser = { + id: MiUser['id']; + host: MiUser['host']; + username: MiUser['username']; + uri: MiUser['uri']; +}; + +type Option = { + updatedAt?: Date | null; + files?: MiDriveFile[] | null; + name?: string | null; + text?: string | null; + cw?: string | null; + apHashtags?: string[] | null; + apEmojis?: string[] | null; + poll?: IPoll | null; +}; + +@Injectable() +export class NoteUpdateService implements OnApplicationShutdown { + #shutdownController = new AbortController(); + + constructor( + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private userEntityService: UserEntityService, + private globalEventService: GlobalEventService, + private relayService: RelayService, + private apDeliverManagerService: ApDeliverManagerService, + private apRendererService: ApRendererService, + private searchService: SearchService, + private activeUsersChart: ActiveUsersChart, + ) { } + + @bindThis + public async update(user: { + id: MiUser['id']; + username: MiUser['username']; + host: MiUser['host']; + isBot: MiUser['isBot']; + }, data: Option, note: MiNote, silent = false): Promise { + if (data.updatedAt == null) data.updatedAt = new Date(); + + if (data.text) { + if (data.text.length > DB_MAX_NOTE_TEXT_LENGTH) { + data.text = data.text.slice(0, DB_MAX_NOTE_TEXT_LENGTH); + } + data.text = data.text.trim(); + } else { + data.text = null; + } + + let tags = data.apHashtags; + let emojis = data.apEmojis; + + // Parse MFM if needed + if (!tags || !emojis) { + const tokens = data.text ? mfm.parse(data.text)! : []; + const cwTokens = data.cw ? mfm.parse(data.cw)! : []; + const choiceTokens = data.poll && data.poll.choices + ? concat(data.poll.choices.map(choice => mfm.parse(choice)!)) + : []; + + const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens); + + tags = data.apHashtags ?? extractHashtags(combinedTokens); + + emojis = data.apEmojis ?? extractCustomEmojisFromMfm(combinedTokens); + } + + tags = tags.filter(tag => Array.from(tag ?? '').length <= 128).splice(0, 32); + + const updatedNote = await this.updateNote(user, note, data, tags, emojis); + + if (updatedNote) { + setImmediate('post updated', { signal: this.#shutdownController.signal }).then( + () => this.postNoteUpdated(updatedNote, user, silent), + () => { /* aborted, ignore this */ }, + ); + } + + return updatedNote; + } + + @bindThis + private async updateNote(user: { + id: MiUser['id']; host: MiUser['host']; + }, note: MiNote, data: Option, tags: string[], emojis: string[]): Promise { + const updatedAtHistory = note.updatedAtHistory ? note.updatedAtHistory : []; + + const values = new MiNote({ + updatedAt: data.updatedAt!, + fileIds: data.files ? data.files.map(file => file.id) : [], + text: data.text, + hasPoll: data.poll != null, + cw: data.cw ?? null, + tags: tags.map(tag => normalizeForSearch(tag)), + emojis, + attachedFileTypes: data.files ? data.files.map(file => file.type) : [], + updatedAtHistory: [...updatedAtHistory, new Date()], + noteEditHistory: [...note.noteEditHistory, (note.cw ? note.cw + '\n' : '') + note.text!], + }); + + // 投稿を更新 + try { + if (note.hasPoll && values.hasPoll) { + // Start transaction + await this.db.transaction(async transactionalEntityManager => { + await transactionalEntityManager.update(MiNote, { id: note.id }, values); + + if (values.hasPoll) { + const old_poll = await transactionalEntityManager.findOneBy(MiPoll, { noteId: note.id }); + if (old_poll!.choices.toString() !== data.poll!.choices.toString() || old_poll!.multiple !== data.poll!.multiple) { + await transactionalEntityManager.delete(MiPoll, { noteId: note.id }); + const poll = new MiPoll({ + noteId: note.id, + choices: data.poll!.choices, + expiresAt: data.poll!.expiresAt, + multiple: data.poll!.multiple, + votes: new Array(data.poll!.choices.length).fill(0), + noteVisibility: note.visibility, + userId: user.id, + userHost: user.host, + }); + await transactionalEntityManager.insert(MiPoll, poll); + } + } + }); + } else if (!note.hasPoll && values.hasPoll) { + // Start transaction + await this.db.transaction(async transactionalEntityManager => { + await transactionalEntityManager.update(MiNote, { id: note.id }, values); + + if (values.hasPoll) { + const poll = new MiPoll({ + noteId: note.id, + choices: data.poll!.choices, + expiresAt: data.poll!.expiresAt, + multiple: data.poll!.multiple, + votes: new Array(data.poll!.choices.length).fill(0), + noteVisibility: note.visibility, + userId: user.id, + userHost: user.host, + }); + + await transactionalEntityManager.insert(MiPoll, poll); + } + }); + } else if (note.hasPoll && !values.hasPoll) { + // Start transaction + await this.db.transaction(async transactionalEntityManager => { + await transactionalEntityManager.update(MiNote, { id: note.id }, values); + + if (!values.hasPoll) { + await transactionalEntityManager.delete(MiPoll, { noteId: note.id }); + } + }); + } else { + await this.notesRepository.update({ id: note.id }, values); + } + + return await this.notesRepository.findOneBy({ id: note.id }); + } catch (e) { + console.error(e); + + throw e; + } + } + + @bindThis + private async postNoteUpdated(note: MiNote, user: { + id: MiUser['id']; + username: MiUser['username']; + host: MiUser['host']; + isBot: MiUser['isBot']; + }, silent: boolean) { + if (!silent) { + if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user); + + this.globalEventService.publishNoteStream(note.id, 'updated', { cw: note.cw, text: note.text }); + + //#region AP deliver + if (this.userEntityService.isLocalUser(user)) { + await (async () => { + // @ts-ignore + const noteActivity = await this.renderNoteActivity(note, user); + + await this.deliverToConcerned(user, note, noteActivity); + })(); + } + //#endregion + } + + // Register to search database + this.reIndex(note); + } + + @bindThis + private async renderNoteActivity(note: MiNote, user: MiUser) { + const content = this.apRendererService.renderUpdate(await this.apRendererService.renderNote(note, false), user); + + return this.apRendererService.addContext(content); + } + + @bindThis + private async getMentionedRemoteUsers(note: MiNote) { + const where = [] as any[]; + + // mention / reply / dm + const uris = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri); + if (uris.length > 0) { + where.push( + { uri: In(uris) }, + ); + } + + // renote / quote + if (note.renoteUserId) { + where.push({ + id: note.renoteUserId, + }); + } + + if (where.length === 0) return []; + + return await this.usersRepository.find({ + where, + }) as MiRemoteUser[]; + } + + @bindThis + private async deliverToConcerned(user: { id: MiLocalUser['id']; host: null; }, note: MiNote, content: any) { + console.log('deliverToConcerned', util.inspect(content, { depth: null })); + await this.apDeliverManagerService.deliverToFollowers(user, content); + await this.relayService.deliverToRelays(user, content); + const remoteUsers = await this.getMentionedRemoteUsers(note); + for (const remoteUser of remoteUsers) { + await this.apDeliverManagerService.deliverToUser(user, content, remoteUser); + } + } + + @bindThis + private reIndex(note: MiNote) { + if (note.text == null && note.cw == null) return; + + this.searchService.unindexNote(note); + this.searchService.indexNote(note); + } + + @bindThis + public dispose(): void { + this.#shutdownController.abort(); + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } +} diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts index 579335c322..54895c1170 100644 --- a/packages/backend/src/core/QueueModule.ts +++ b/packages/backend/src/core/QueueModule.ts @@ -21,6 +21,7 @@ import type { Provider } from '@nestjs/common'; export type SystemQueue = Bull.Queue>; export type EndedPollNotificationQueue = Bull.Queue; +export type ScheduleNotePostQueue = Bull.Queue; export type DeliverQueue = Bull.Queue; export type InboxQueue = Bull.Queue; export type DbQueue = Bull.Queue; @@ -41,6 +42,12 @@ const $endedPollNotification: Provider = { inject: [DI.config], }; +const $scheduleNotePost: Provider = { + provide: 'queue:scheduleNotePost', + useFactory: (config: Config) => new Bull.Queue(QUEUE.SCHEDULE_NOTE_POST, baseQueueOptions(config, QUEUE.SCHEDULE_NOTE_POST)), + inject: [DI.config], +}; + const $deliver: Provider = { provide: 'queue:deliver', useFactory: (config: Config) => new Bull.Queue(QUEUE.DELIVER, baseQueueOptions(config, QUEUE.DELIVER)), @@ -89,6 +96,7 @@ const $systemWebhookDeliver: Provider = { providers: [ $system, $endedPollNotification, + $scheduleNotePost, $deliver, $inbox, $db, @@ -100,6 +108,7 @@ const $systemWebhookDeliver: Provider = { exports: [ $system, $endedPollNotification, + $scheduleNotePost, $deliver, $inbox, $db, @@ -113,6 +122,7 @@ export class QueueModule implements OnApplicationShutdown { constructor( @Inject('queue:system') public systemQueue: SystemQueue, @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, + @Inject('queue:scheduleNotePost') public scheduleNotePostQueue: ScheduleNotePostQueue, @Inject('queue:deliver') public deliverQueue: DeliverQueue, @Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:db') public dbQueue: DbQueue, @@ -129,6 +139,7 @@ export class QueueModule implements OnApplicationShutdown { await Promise.all([ this.systemQueue.close(), this.endedPollNotificationQueue.close(), + this.scheduleNotePostQueue.close(), this.deliverQueue.close(), this.inboxQueue.close(), this.dbQueue.close(), diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index d90382d367..c52dc6565c 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -14,6 +14,7 @@ import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js'; import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js'; + import type { DbJobData, DeliverJobData, @@ -32,6 +33,7 @@ import type { SystemQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue, + ScheduleNotePostQueue } from './QueueModule.js'; import type httpSignature from '@peertube/http-signature'; import type * as Bull from 'bullmq'; @@ -44,6 +46,7 @@ export class QueueService { @Inject('queue:system') public systemQueue: SystemQueue, @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, + @Inject('queue:scheduleNotePost') public ScheduleNotePostQueue: ScheduleNotePostQueue, @Inject('queue:deliver') public deliverQueue: DeliverQueue, @Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:db') public dbQueue: DbQueue, diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index 39e0dab78f..34ee0f285d 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -166,29 +166,56 @@ export class ReactionService { userId: user.id, reaction, }; + if (user.host == null) { + const exists = await this.noteReactionsRepository.findOneBy({ + noteId: note.id, + userId: user.id, + reaction: record.reaction, + }); - // Create reaction - try { - await this.noteReactionsRepository.insert(record); - } catch (e) { - if (isDuplicateKeyValueError(e)) { - const exists = await this.noteReactionsRepository.findOneByOrFail({ - noteId: note.id, - userId: user.id, - }); + const count = await this.noteReactionsRepository.countBy({ + noteId: note.id, + userId: user.id, + }); - if (exists.reaction !== reaction) { - // 別のリアクションがすでにされていたら置き換える - await this.delete(user, note); + if (count > 3) { + throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298'); + } + + if (exists == null) { + if (user.host == null) { await this.noteReactionsRepository.insert(record); } else { - // 同じリアクションがすでにされていたらエラー throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298'); } } else { - throw e; + // 同じリアクションがすでにされていたらエラー + throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298'); + } + } else { + try { + await this.noteReactionsRepository.insert(record); + } catch (e) { + if (isDuplicateKeyValueError(e)) { + const exists = await this.noteReactionsRepository.findOneByOrFail({ + noteId: note.id, + userId: user.id, + }); + + if (exists.reaction !== reaction) { + // 別のリアクションがすでにされていたら置き換える + await this.delete(user, note); + await this.noteReactionsRepository.insert(record); + } else { + // 同じリアクションがすでにされていたらエラー + throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298'); + } + } else { + throw e; + } } } + // Create reaction // Increment reactions count const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`; @@ -281,17 +308,24 @@ export class ReactionService { } @bindThis - public async delete(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote) { + public async delete(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote, reaction?: string) { // if already unreacted - const exist = await this.noteReactionsRepository.findOneBy({ - noteId: note.id, - userId: user.id, - }); - + let exist; + if (reaction == null) { + exist = await this.noteReactionsRepository.findOneBy({ + noteId: note.id, + userId: user.id, + }); + } else { + exist = await this.noteReactionsRepository.findOneBy({ + noteId: note.id, + userId: user.id, + reaction: reaction.replace(/@./, ''), + }); + } if (exist == null) { throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted'); } - // Delete reaction const result = await this.noteReactionsRepository.delete(exist.id); diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index eda7c0e287..6a222cab6a 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -35,12 +35,15 @@ export type RolePolicies = { gtlAvailable: boolean; ltlAvailable: boolean; canPublicNote: boolean; + canEditNote: boolean; + canScheduleNote: boolean; mentionLimit: number; canInvite: boolean; inviteLimit: number; inviteLimitCycle: number; inviteExpirationTime: number; canManageCustomEmojis: boolean; + canRequestCustomEmojis: boolean; canManageAvatarDecorations: boolean; canSearchNotes: boolean; canUseTranslator: boolean; @@ -57,6 +60,9 @@ export type RolePolicies = { userEachUserListsLimit: number; rateLimitFactor: number; avatarDecorationLimit: number; + emojiPickerProfileLimit: number; + listPinnedLimit: number; + localTimelineAnyLimit: number; }; export const DEFAULT_POLICIES: RolePolicies = { @@ -64,11 +70,14 @@ export const DEFAULT_POLICIES: RolePolicies = { ltlAvailable: true, canPublicNote: true, mentionLimit: 20, + canEditNote: true, + canScheduleNote: true, canInvite: false, inviteLimit: 0, inviteLimitCycle: 60 * 24 * 7, inviteExpirationTime: 0, canManageCustomEmojis: false, + canRequestCustomEmojis: false, canManageAvatarDecorations: false, canSearchNotes: false, canUseTranslator: true, @@ -85,6 +94,9 @@ export const DEFAULT_POLICIES: RolePolicies = { userEachUserListsLimit: 50, rateLimitFactor: 1, avatarDecorationLimit: 1, + emojiPickerProfileLimit: 2, + listPinnedLimit: 2, + localTimelineAnyLimit: 3, }; @Injectable() @@ -364,13 +376,17 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)), ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)), canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)), + canScheduleNote: calc('canScheduleNote', vs => vs.some(v => v === true)), + canEditNote: calc('canEditNote', vs => vs.some(v => v === true)), mentionLimit: calc('mentionLimit', vs => Math.max(...vs)), canInvite: calc('canInvite', vs => vs.some(v => v === true)), inviteLimit: calc('inviteLimit', vs => Math.max(...vs)), inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)), inviteExpirationTime: calc('inviteExpirationTime', vs => Math.max(...vs)), canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)), + canRequestCustomEmojis: calc('canRequestCustomEmojis', vs => vs.some(v => v === true)), canManageAvatarDecorations: calc('canManageAvatarDecorations', vs => vs.some(v => v === true)), + canRequestCustomEmojis: calc('canRequestCustomEmojis', vs => vs.some(v => v === true)), canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)), canUseTranslator: calc('canUseTranslator', vs => vs.some(v => v === true)), canHideAds: calc('canHideAds', vs => vs.some(v => v === true)), @@ -386,6 +402,9 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { userEachUserListsLimit: calc('userEachUserListsLimit', vs => Math.max(...vs)), rateLimitFactor: calc('rateLimitFactor', vs => Math.max(...vs)), avatarDecorationLimit: calc('avatarDecorationLimit', vs => Math.max(...vs)), + emojiPickerProfileLimit: calc('emojiPickerProfileLimit', vs => Math.max(...vs)), + listPinnedLimit: calc('listPinnedLimit', vs => Math.max(...vs)), + localTimelineAnyLimit: calc('localTimelineAnyLimit', vs => Math.max(...vs)), }; } diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index db13111457..e447bccad4 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -14,6 +14,7 @@ import { NotePiningService } from '@/core/NotePiningService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; import { NoteDeleteService } from '@/core/NoteDeleteService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; +import { NoteUpdateService } from '@/core/NoteUpdateService.js'; import { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js'; import { AppLockService } from '@/core/AppLockService.js'; import type Logger from '@/logger.js'; @@ -73,6 +74,7 @@ export class ApInboxService { private notePiningService: NotePiningService, private userBlockingService: UserBlockingService, private noteCreateService: NoteCreateService, + private noteUpdateService: NoteUpdateService, private noteDeleteService: NoteDeleteService, private appLockService: AppLockService, private apResolverService: ApResolverService, @@ -751,11 +753,13 @@ export class ApInboxService { @bindThis private async update(actor: MiRemoteUser, activity: IUpdate): Promise { + const uri = getApId(activity); + if (actor.uri !== activity.actor) { return 'skip: invalid actor'; } - this.logger.debug('Update'); + this.logger.debug(`Update: ${uri}`); const resolver = this.apResolverService.createResolver(); @@ -767,14 +771,51 @@ export class ApInboxService { if (isActor(object)) { await this.apPersonService.updatePerson(actor.uri, resolver, object); return 'ok: Person updated'; - } else if (getApType(object) === 'Question') { + } /*else if (getApType(object) === 'Question') { await this.apQuestionService.updateQuestion(object, resolver).catch(err => console.error(err)); return 'ok: Question updated'; + }*/ else if (getApType(object) === 'Note' || getApType(object) === 'Question') { + await this.updateNote(resolver, actor, object, false, activity); + return 'ok: Note updated'; } else { return `skip: Unknown type: ${getApType(object)}`; } } + @bindThis + private async updateNote(resolver: Resolver, actor: MiRemoteUser, note: IObject, silent = false, activity?: IUpdate): Promise { + const uri = getApId(note); + + if (typeof note === 'object') { + if (actor.uri !== note.attributedTo) { + return 'skip: actor.uri !== note.attributedTo'; + } + + if (typeof note.id === 'string') { + if (this.utilityService.extractDbHost(actor.uri) !== this.utilityService.extractDbHost(note.id)) { + return 'skip: host in actor.uri !== note.id'; + } + } + } + + const unlock = await this.appLockService.getApLock(uri); + + try { + const target = await this.notesRepository.findOneBy({uri: uri}); + if (!target) return `skip: target note not located: ${uri}`; + await this.apNoteService.updateNote(note, target, resolver, silent); + return 'ok'; + } catch (err) { + if (err instanceof StatusError && err.isClientError) { + return `skip ${err.statusCode}`; + } else { + throw err; + } + } finally { + unlock(); + } + } + @bindThis private async move(actor: MiRemoteUser, activity: IMove): Promise { // fetch the new and old accounts diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 03aec93ee4..7f025a7e4d 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -108,6 +108,7 @@ export class ApRendererService { actor: this.userEntityService.genLocalUserUri(note.userId), type: 'Announce', published: this.idService.parse(note.id).date.toISOString(), + updated: note.updatedAt?.toISOString() ?? undefined, to, cc, object, @@ -438,6 +439,7 @@ export class ApRendererService { _misskey_quote: quote, quoteUrl: quote, published: this.idService.parse(note.id).date.toISOString(), + updated: note.updatedAt?.toISOString() ?? undefined, to, cc, inReplyTo, diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index d04a97d2c0..31f7cebed3 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -1,12 +1,13 @@ /* - * SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-project + * SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-project, cherrypick contributors * SPDX-License-Identifier: AGPL-3.0-only */ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { In } from 'typeorm'; +import promiseLimit from 'promise-limit'; import { DI } from '@/di-symbols.js'; -import type { PollsRepository, EmojisRepository } from '@/models/_.js'; +import type { PollsRepository, EmojisRepository, NotesRepository } from '@/models/_.js'; import type { Config } from '@/config.js'; import type { MiRemoteUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; @@ -24,7 +25,8 @@ import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { checkHttps } from '@/misc/check-https.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js'; +import { NoteUpdateService } from '@/core/NoteUpdateService.js'; +import { getApId, getApType, getOneApHrefNullable, getOneApId, isEmoji, validPost } from '../type.js'; import { ApLoggerService } from '../ApLoggerService.js'; import { ApMfmService } from '../ApMfmService.js'; import { ApDbResolverService } from '../ApDbResolverService.js'; @@ -52,6 +54,9 @@ export class ApNoteService { @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + private idService: IdService, private apMfmService: ApMfmService, private apResolverService: ApResolverService, @@ -69,6 +74,7 @@ export class ApNoteService { private appLockService: AppLockService, private pollService: PollService, private noteCreateService: NoteCreateService, + private noteUpdateService: NoteUpdateService, private apDbResolverService: ApDbResolverService, private apLoggerService: ApLoggerService, ) { @@ -295,6 +301,7 @@ export class ApNoteService { try { return await this.noteCreateService.create(actor, { createdAt: note.published ? new Date(note.published) : null, + updatedAt: note.updated ? new Date(note.updated) : null, files, reply, renote: quote, @@ -324,6 +331,85 @@ export class ApNoteService { } } + @bindThis + public async updateNote(value: string | IObject, target: MiNote, resolver?: Resolver, silent = false): Promise { + if (resolver == null) resolver = this.apResolverService.createResolver(); + + const object = await resolver.resolve(value); + const entryUri = getApId(value); + + const err = this.validateNote(object, entryUri); + if (err) { + this.logger.error(err.message, { + resolver: { history: resolver.getHistory() }, + value, + object, + }); + throw new Error('invalid note'); + } + + const note = object as IPost; + + // 投稿者をフェッチ + if (note.attributedTo == null) { + throw new Error('invalid note.attributedTo: ' + note.attributedTo); + } + + const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo), resolver) as MiRemoteUser; + + // 投稿者が凍結されていたらスキップ + if (actor.isSuspended) { + throw new Error('actor has been suspended'); + } + + const limit = promiseLimit(2); + const files = (await Promise.all(toArray(note.attachment).map(attach => ( + limit(() => this.apImageService.resolveImage(actor, { + ...attach, + sensitive: note.sensitive, // Noteがsensitiveなら添付もsensitiveにする + })) + )))); + + 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') { + text = this.apMfmService.htmlToMfm(note.content, note.tag); + } + + const apHashtags = extractApHashtags(note.tag); + + const emojis = await this.extractEmojis(note.tag ?? [], actor.host).catch(e => { + this.logger.info(`extractEmojis: ${e}`); + return []; + }); + + const apEmojis = emojis.map(emoji => emoji.name); + + const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined); + + try { + return await this.noteUpdateService.update(actor, { + updatedAt: note.updated ? new Date(note.updated) : null, + files, + name: note.name, + cw, + text, + apHashtags, + apEmojis, + poll, + }, target, silent); + } catch (err: any) { + this.logger.warn(`note update failed: ${err}`); + return err; + } + } + /** * Noteを解決します。 * diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 97361697ad..0d40829570 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -14,6 +14,7 @@ export interface IObject { summary?: string; _misskey_summary?: string; published?: string; + updated?: string; cc?: ApObject; to?: ApObject; attributedTo?: ApObject; diff --git a/packages/backend/src/core/chart/charts/federation.ts b/packages/backend/src/core/chart/charts/federation.ts index 35ab381c0b..bc0fae1259 100644 --- a/packages/backend/src/core/chart/charts/federation.ts +++ b/packages/backend/src/core/chart/charts/federation.ts @@ -65,21 +65,21 @@ export default class FederationChart extends Chart { // eslint-di this.followingsRepository.createQueryBuilder('following') .select('COUNT(DISTINCT following.followeeHost)') .where('following.followeeHost IS NOT NULL') - .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) + .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT IN (:...blocked)', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) .andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) .getRawOne() .then(x => parseInt(x.count, 10)), this.followingsRepository.createQueryBuilder('following') .select('COUNT(DISTINCT following.followerHost)') .where('following.followerHost IS NOT NULL') - .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followerHost NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) + .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followerHost NOT IN (:...blocked)', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) .andWhere(`following.followerHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) .getRawOne() .then(x => parseInt(x.count, 10)), this.followingsRepository.createQueryBuilder('following') .select('COUNT(DISTINCT following.followeeHost)') .where('following.followeeHost IS NOT NULL') - .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) + .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT IN (:...blocked)', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) .andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) .andWhere(`following.followeeHost IN (${ pubsubSubQuery.getQuery() })`) .setParameters(pubsubSubQuery.getParameters()) @@ -88,7 +88,7 @@ export default class FederationChart extends Chart { // eslint-di this.instancesRepository.createQueryBuilder('instance') .select('COUNT(instance.id)') .where(`instance.host IN (${ subInstancesQuery.getQuery() })`) - .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) + .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT IN (:...blocked)', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) .andWhere('instance.suspensionState = \'none\'') .andWhere('instance.isNotResponding = false') .getRawOne() @@ -96,7 +96,7 @@ export default class FederationChart extends Chart { // eslint-di this.instancesRepository.createQueryBuilder('instance') .select('COUNT(instance.id)') .where(`instance.host IN (${ pubInstancesQuery.getQuery() })`) - .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) + .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT IN (:...blocked)', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) .andWhere('instance.suspensionState = \'none\'') .andWhere('instance.isNotResponding = false') .getRawOne() diff --git a/packages/backend/src/core/entities/AbuseUserReportEntityService.ts b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts index 9658fa6334..115dad6f3e 100644 --- a/packages/backend/src/core/entities/AbuseUserReportEntityService.ts +++ b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts @@ -4,12 +4,15 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { AbuseUserReportsRepository } from '@/models/_.js'; +import type { AbuseUserReportsRepository, NotesRepository } from '@/models/_.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { isNotNull } from '@/misc/is-not-null.js'; import type { Packed } from '@/misc/json-schema.js'; import { UserEntityService } from './UserEntityService.js'; @@ -19,7 +22,11 @@ export class AbuseUserReportEntityService { @Inject(DI.abuseUserReportsRepository) private abuseUserReportsRepository: AbuseUserReportsRepository, + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + private userEntityService: UserEntityService, + private noteEntityService: NoteEntityService, private idService: IdService, ) { } @@ -34,11 +41,27 @@ export class AbuseUserReportEntityService { }, ) { const report = typeof src === 'object' ? src : await this.abuseUserReportsRepository.findOneByOrFail({ id: src }); + const notes = []; + if (report.noteIds && report.noteIds.length > 0) { + for (const x of report.noteIds) { + const exists = await this.notesRepository.countBy({ id: x }); + if (exists === 0) { + notes.push('deleted'); + continue; + } + notes.push(await this.noteEntityService.pack(x)); + } + } else if (report.notes.length > 0) { + notes.push(...(report.notes)); + } + + console.log(report.notes.length, null, notes); return await awaitAll({ id: report.id, createdAt: this.idService.parse(report.id).date.toISOString(), comment: report.comment, + notes, resolved: report.resolved, reporterId: report.reporterId, targetUserId: report.targetUserId, diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts index e729263172..9f4903cc25 100644 --- a/packages/backend/src/core/entities/DriveFileEntityService.ts +++ b/packages/backend/src/core/entities/DriveFileEntityService.ts @@ -132,7 +132,10 @@ export class DriveFileEntityService { } return url; } - + @bindThis + public async getFromUrl(url: string): Promise { + return this.driveFilesRepository.findOneBy({ url: url }); + } @bindThis public async calcDriveUsageOf(user: MiUser['id'] | { id: MiUser['id'] }): Promise { const id = typeof user === 'object' ? user.id : user; diff --git a/packages/backend/src/core/entities/EmojiEntityService.ts b/packages/backend/src/core/entities/EmojiEntityService.ts index 3187f5c82a..c9c8de9b79 100644 --- a/packages/backend/src/core/entities/EmojiEntityService.ts +++ b/packages/backend/src/core/entities/EmojiEntityService.ts @@ -34,6 +34,7 @@ export class EmojiEntityService { localOnly: emoji.localOnly ? true : undefined, isSensitive: emoji.isSensitive ? true : undefined, roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0 ? emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : undefined, + draft: emoji.draft, }; } @@ -62,6 +63,7 @@ export class EmojiEntityService { isSensitive: emoji.isSensitive, localOnly: emoji.localOnly, roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction, + draft: emoji.draft, }; } diff --git a/packages/backend/src/core/entities/EmojiRequestsEntityService.ts b/packages/backend/src/core/entities/EmojiRequestsEntityService.ts new file mode 100644 index 0000000000..46308e654c --- /dev/null +++ b/packages/backend/src/core/entities/EmojiRequestsEntityService.ts @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { EmojiRequestsRepository } from '@/models/_.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { bindThis } from '@/decorators.js'; +import { MiEmojiRequest } from '@/models/EmojiRequest.js'; + +@Injectable() +export class EmojiRequestsEntityService { + constructor( + @Inject(DI.emojiRequestsRepository) + private emojiRequestsRepository: EmojiRequestsRepository, + ) { + } + + @bindThis + public async packSimple( + src: MiEmojiRequest['id'] | MiEmojiRequest, + ): Promise> { + const emoji = typeof src === 'object' ? src : await this.emojiRequestsRepository.findOneByOrFail({ id: src }); + + return { + aliases: emoji.aliases, + name: emoji.name, + category: emoji.category, + // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) + url: emoji.publicUrl, + isSensitive: emoji.isSensitive ? true : undefined, + }; + } + + @bindThis + public packSimpleMany( + emojis: any[], + ) { + return Promise.all(emojis.map(x => this.packSimple(x))); + } + + @bindThis + public async packDetailed( + src: MiEmojiRequest['id'] | MiEmojiRequest, + ): Promise> { + const emoji = typeof src === 'object' ? src : await this.emojiRequestsRepository.findOneByOrFail({ id: src }); + + return { + id: emoji.id, + aliases: emoji.aliases, + name: emoji.name, + category: emoji.category, + url: emoji.publicUrl, + license: emoji.license, + isSensitive: emoji.isSensitive, + localOnly: emoji.localOnly, + fileId: emoji.fileId, + }; + } + + @bindThis + public packDetailedMany( + emojis: any[], + ) { + return Promise.all(emojis.map(x => this.packDetailed(x))); + } +} + diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index 2a5f304096..9148d2d5c9 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -70,6 +70,11 @@ export class MetaEntityService { inquiryUrl: instance.inquiryUrl, disableRegistration: instance.disableRegistration, emailRequiredForSignup: instance.emailRequiredForSignup, + bannerDark: instance.bannerDark, + bannerLight: instance.bannerLight, + iconDark: instance.iconDark, + iconLight: instance.iconLight, + googleAnalyticsId: instance.googleAnalyticsId, enableHcaptcha: instance.enableHcaptcha, hcaptchaSiteKey: instance.hcaptchaSiteKey, enableMcaptcha: instance.enableMcaptcha, @@ -85,6 +90,7 @@ export class MetaEntityService { bannerUrl: instance.bannerUrl, infoImageUrl: instance.infoImageUrl, serverErrorImageUrl: instance.serverErrorImageUrl, + googleAnalyticsId: instance.googleAnalyticsId, notFoundImageUrl: instance.notFoundImageUrl, iconUrl: instance.iconUrl, backgroundImageUrl: instance.backgroundImageUrl, diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 5f4bad70f9..85091e76f0 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -208,6 +208,30 @@ export class NoteEntityService implements OnModuleInit { return undefined; } + @bindThis + public async populateMyReactions(note: { id: MiNote['id']; reactions: MiNote['reactions']; reactionAndUserPairCache?: MiNote['reactionAndUserPairCache']; }, meId: MiUser['id'], _hint_?: { + myReactions: Map; + }) { + const reactionsCount = Object.values(note.reactions).reduce((a, b) => a + b, 0); + + if (reactionsCount === 0) return undefined; + + // パフォーマンスのためノートが作成されてから2秒以上経っていない場合はリアクションを取得しない + if (this.idService.parse(note.id).date.getTime() + 2000 > Date.now()) { + return undefined; + } + + const reactions = await this.noteReactionsRepository.findBy({ + userId: meId, + noteId: note.id, + }); + + if (reactions.length > 0) { + return reactions.map(reaction => this.reactionService.convertLegacyReaction(reaction.reaction)); + } + + return undefined; + } @bindThis public async isVisibleForMe(note: MiNote, meId: MiUser['id'] | null): Promise { @@ -324,6 +348,9 @@ export class NoteEntityService implements OnModuleInit { const packed: Packed<'Note'> = await awaitAll({ id: note.id, createdAt: this.idService.parse(note.id).date.toISOString(), + updatedAt: note.updatedAt ? note.updatedAt.toISOString() : undefined, + updatedAtHistory: note.updatedAtHistory ? note.updatedAtHistory.map(x => x.toISOString()) : undefined, + noteEditHistory: note.noteEditHistory.length ? note.noteEditHistory : undefined, userId: note.userId, user: packedUsers?.get(note.userId) ?? this.userEntityService.pack(note.user ?? note.userId, me), text: text, @@ -378,6 +405,7 @@ export class NoteEntityService implements OnModuleInit { ...(meId && Object.keys(note.reactions).length > 0 ? { myReaction: this.populateMyReaction(note, meId, options?._hint_), + myReactions: this.populateMyReactions(note, meId, options?._hint_), } : {}), } : {}), }); diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index a6b6dd3336..ef0d273619 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -162,6 +162,9 @@ export class NotificationEntityService implements OnModuleInit { ...(notification.type === 'achievementEarned' ? { achievement: notification.achievement, } : {}), + ...(notification.type === 'loginbonus' ? { + loginbonus: notification.loginbonus, + } : {}), ...(notification.type === 'app' ? { body: notification.customBody, header: notification.customHeader, diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index aec79c940e..3c5d71a7db 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -470,9 +470,8 @@ export class UserEntityService implements OnModuleInit { createdAt: this.idService.parse(announcement.id).date.toISOString(), ...announcement, })) : null; - + console.log(user.getPoints); const notificationsInfo = isMe && isDetailed ? await this.getNotificationsInfo(user.id) : null; - const packed = { id: user.id, name: user.name, @@ -506,7 +505,7 @@ export class UserEntityService implements OnModuleInit { iconUrl: r.iconUrl, displayOrder: r.displayOrder, }))) : undefined, - + ...(user.host == null ? { getPoints: user.getPoints } : {}), ...(isDetailed ? { url: profile!.url, uri: user.uri, diff --git a/packages/backend/src/daemons/ServerStatsService.ts b/packages/backend/src/daemons/ServerStatsService.ts index 35df7e1ee8..22221575b2 100644 --- a/packages/backend/src/daemons/ServerStatsService.ts +++ b/packages/backend/src/daemons/ServerStatsService.ts @@ -66,7 +66,7 @@ export class ServerStatsService implements OnApplicationShutdown { if (log.length > 200) log.pop(); }; - tick(); + await tick(); this.intervalId = setInterval(tick, interval); } diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 3535f4b8b2..671b140043 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -15,6 +15,7 @@ export const DI = { //#region Repositories usersRepository: Symbol('usersRepository'), notesRepository: Symbol('notesRepository'), + scheduledNotesRepository: Symbol('scheduledNotesRepository'), announcementsRepository: Symbol('announcementsRepository'), announcementReadsRepository: Symbol('announcementReadsRepository'), appsRepository: Symbol('appsRepository'), @@ -40,6 +41,7 @@ export const DI = { followRequestsRepository: Symbol('followRequestsRepository'), instancesRepository: Symbol('instancesRepository'), emojisRepository: Symbol('emojisRepository'), + emojiRequestsRepository: Symbol('emojiRequestsRepository'), driveFilesRepository: Symbol('driveFilesRepository'), driveFoldersRepository: Symbol('driveFoldersRepository'), metasRepository: Symbol('metasRepository'), diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index bb6a9da739..a90ba4c431 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -33,7 +33,7 @@ import { packedClipSchema } from '@/models/json-schema/clip.js'; import { packedFederationInstanceSchema } from '@/models/json-schema/federation-instance.js'; import { packedQueueCountSchema } from '@/models/json-schema/queue.js'; import { packedGalleryPostSchema } from '@/models/json-schema/gallery-post.js'; -import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/json-schema/emoji.js'; +import { packedEmojiDetailedSchema, packedEmojiRequestSimpleSchema, packedEmojiSimpleSchema, packedEmojiRequestDetailedSchema } from '@/models/json-schema/emoji.js'; import { packedFlashSchema } from '@/models/json-schema/flash.js'; import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js'; import { packedSigninSchema } from '@/models/json-schema/signin.js'; @@ -94,7 +94,9 @@ export const refs = { FederationInstance: packedFederationInstanceSchema, GalleryPost: packedGalleryPostSchema, EmojiSimple: packedEmojiSimpleSchema, + EmojiRequestSimple: packedEmojiRequestSimpleSchema, EmojiDetailed: packedEmojiDetailedSchema, + EmojiRequestDetailed: packedEmojiRequestDetailedSchema, Flash: packedFlashSchema, Signin: packedSigninSchema, RoleCondFormulaLogics: packedRoleCondFormulaLogicsSchema, diff --git a/packages/backend/src/models/AbuseUserReport.ts b/packages/backend/src/models/AbuseUserReport.ts index fe56c1464b..92cb7bb7a2 100644 --- a/packages/backend/src/models/AbuseUserReport.ts +++ b/packages/backend/src/models/AbuseUserReport.ts @@ -60,6 +60,16 @@ export class MiAbuseUserReport { }) public comment: string; + @Column('jsonb', { + default: [], + }) + public notes: any[]; + + @Column('jsonb', { + default: [], + }) + public noteIds: string[] | null; + //#region Denormalized fields @Index() @Column('varchar', { diff --git a/packages/backend/src/models/AvatarDecoration.ts b/packages/backend/src/models/AvatarDecoration.ts index 21bf7c69b2..d129986c3b 100644 --- a/packages/backend/src/models/AvatarDecoration.ts +++ b/packages/backend/src/models/AvatarDecoration.ts @@ -31,6 +31,12 @@ export class MiAvatarDecoration { }) public description: string; + @Column('varchar', { + length: 256, + default: '', + }) + public category: string; + // TODO: 定期ジョブで存在しなくなったロールIDを除去するようにする @Column('varchar', { array: true, length: 128, default: '{}', diff --git a/packages/backend/src/models/Emoji.ts b/packages/backend/src/models/Emoji.ts index 5fc93d33f3..85e9a962a6 100644 --- a/packages/backend/src/models/Emoji.ts +++ b/packages/backend/src/models/Emoji.ts @@ -81,4 +81,10 @@ export class MiEmoji { array: true, length: 128, default: '{}', }) public roleIdsThatCanBeUsedThisEmojiAsReaction: string[]; + + @Column('boolean', { + default: false, + nullable: false, + }) + public draft: boolean; } diff --git a/packages/backend/src/models/EmojiRequest.ts b/packages/backend/src/models/EmojiRequest.ts new file mode 100644 index 0000000000..53a5563f7e --- /dev/null +++ b/packages/backend/src/models/EmojiRequest.ts @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { PrimaryColumn, Entity, Index, Column } from 'typeorm'; +import { id } from './util/id.js'; + +@Entity('emoji_request') +@Index(['name'], { unique: true }) +export class MiEmojiRequest { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + nullable: true, + }) + public updatedAt: Date | null; + + @Column('varchar', { + length: 128, + }) + public name: string; + + @Column('varchar', { + length: 128, nullable: true, + }) + public category: string | null; + + @Column('varchar', { + length: 512, + }) + public originalUrl: string; + + @Column('varchar', { + length: 512, + default: '', + }) + public publicUrl: string; + + // publicUrlの方のtypeが入る + @Column('varchar', { + length: 64, nullable: true, + }) + public type: string | null; + + @Column('varchar', { + array: true, length: 128, default: '{}', + }) + public aliases: string[]; + + @Column('varchar', { + length: 1024, nullable: true, + }) + public license: string | null; + + @Column('varchar', { + length: 1024, nullable: false, + }) + public fileId: string; + + @Column('boolean', { + default: false, + }) + public localOnly: boolean; + + @Column('boolean', { + default: false, + }) + public isSensitive: boolean; +} diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 1166f1ea2d..10fabf9a46 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -139,6 +139,11 @@ export class MiMeta { nullable: true, }) public serverErrorImageUrl: string | null; + @Column('varchar', { + length: 1024, + nullable: true, + }) + public googleAnalyticsId: string | null; @Column('varchar', { length: 1024, @@ -224,11 +229,33 @@ export class MiMeta { }) public enableRecaptcha: boolean; + @Column('varchar', { + length: 1024, + nullable: true, + }) + public DiscordWebhookUrl: string | null; + + @Column('varchar', { + length: 1024, + nullable: true, + }) + public DiscordWebhookUrlWordBlock: string | null; + + @Column('varchar', { + length: 1024, + nullable: true, + }) + public EmojiBotToken: string | null; @Column('varchar', { length: 1024, nullable: true, }) public recaptchaSiteKey: string | null; + @Column('varchar', { + length: 1024, + nullable: true, + }) + public ApiBase: string | null; @Column('varchar', { length: 1024, @@ -417,6 +444,30 @@ export class MiMeta { }) public objectStorageBaseUrl: string | null; + @Column('varchar', { + length: 1024, + nullable: true, + }) + public bannerDark: string | null; + + @Column('varchar', { + length: 1024, + nullable: true, + }) + public bannerLight: string | null; + + @Column('varchar', { + length: 1024, + nullable: true, + }) + public iconDark: string | null; + + @Column('varchar', { + length: 1024, + nullable: true, + }) + public iconLight: string | null; + @Column('varchar', { length: 1024, nullable: true, @@ -589,6 +640,27 @@ export class MiMeta { }) public notesPerOneAd: number; + @Column('boolean', { + default: false, + }) + public requestEmojiAllOk: boolean; + + @Column('boolean', { + default: false, + }) + public enableGDPRMode: boolean; + + @Column('boolean', { + default: false, + }) + public enableProxyCheckio: boolean; + + @Column('varchar', { + length: 32, + nullable: true, + }) + public proxyCheckioApiKey: string; + @Column('boolean', { default: true, }) diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index 80425aa24e..35d4d3af26 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -14,6 +14,23 @@ import type { MiDriveFile } from './DriveFile.js'; export class MiNote { @PrimaryColumn(id()) public id: string; + @Column('timestamp with time zone', { + default: null, + }) + public updatedAt: Date | null; + + @Column('timestamp with time zone', { + array: true, + default: null, + }) + public updatedAtHistory: Date[] | null; + + @Column('varchar', { + length: 3000, + array: true, + default: '{}', + }) + public noteEditHistory: string[]; @Index() @Column({ diff --git a/packages/backend/src/models/NoteReaction.ts b/packages/backend/src/models/NoteReaction.ts index 11839a0737..edfb81c142 100644 --- a/packages/backend/src/models/NoteReaction.ts +++ b/packages/backend/src/models/NoteReaction.ts @@ -9,7 +9,8 @@ import { MiUser } from './User.js'; import { MiNote } from './Note.js'; @Entity('note_reaction') -@Index(['userId', 'noteId'], { unique: true }) +@Index(['userId', 'noteId', 'reaction'], { unique: true }) +@Index(['userId', 'noteId']) export class MiNoteReaction { @PrimaryColumn(id()) public id: string; diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index eb12b29eb3..1626259fad 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -3,11 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { Provider } from '@nestjs/common'; import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import { MiAbuseReportNotificationRecipient, + MiAbuseUserReport, MiAccessToken, MiAd, @@ -18,7 +18,6 @@ import { MiAuthSession, MiAvatarDecoration, MiBlocking, - MiBubbleGameRecord, MiChannel, MiChannelFavorite, MiChannelFollowing, @@ -28,10 +27,11 @@ import { MiDriveFile, MiDriveFolder, MiEmoji, + MiEmojiRequest, MiFlash, MiFlashLike, - MiFollowing, MiFollowRequest, + MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, @@ -51,19 +51,19 @@ import { MiPollVote, MiPromoNote, MiPromoRead, - MiRegistrationTicket, - MiRegistryItem, - MiRelay, - MiRenoteMuting, MiRepository, miRepository, - MiRetentionAggregation, + MiRegistrationTicket, + MiRegistryItem, MiReversiGame, + MiSystemWebhook, + MiRelay, + MiRenoteMuting, + MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, - MiSystemWebhook, MiUsedUsername, MiUser, MiUserIp, @@ -77,8 +77,10 @@ import { MiUserProfile, MiUserPublickey, MiUserSecurityKey, - MiWebhook -} from './_.js'; + MiWebhook, + MiScheduledNote, + MiBubbleGameRecord } from './_.js'; +import type { Provider } from '@nestjs/common'; import type { DataSource } from 'typeorm'; const $usersRepository: Provider = { @@ -93,6 +95,12 @@ const $notesRepository: Provider = { inject: [DI.db], }; +const $scheduledNotesRepository: Provider = { + provide: DI.scheduledNotesRepository, + useFactory: (db: DataSource) => db.getRepository(MiScheduledNote), + inject: [DI.db], +}; + const $announcementsRepository: Provider = { provide: DI.announcementsRepository, useFactory: (db: DataSource) => db.getRepository(MiAnnouncement).extend(miRepository as MiRepository), @@ -243,6 +251,12 @@ const $emojisRepository: Provider = { inject: [DI.db], }; +const $emojiRequestsRepository: Provider = { + provide: DI.emojiRequestsRepository, + useFactory: (db: DataSource) => db.getRepository(MiEmojiRequest), + inject: [DI.db], +}; + const $driveFilesRepository: Provider = { provide: DI.driveFilesRepository, useFactory: (db: DataSource) => db.getRepository(MiDriveFile).extend(miRepository as MiRepository), @@ -500,6 +514,7 @@ const $reversiGamesRepository: Provider = { providers: [ $usersRepository, $notesRepository, + $scheduledNotesRepository, $announcementsRepository, $announcementReadsRepository, $appsRepository, @@ -525,6 +540,7 @@ const $reversiGamesRepository: Provider = { $followRequestsRepository, $instancesRepository, $emojisRepository, + $emojiRequestsRepository, $driveFilesRepository, $driveFoldersRepository, $metasRepository, @@ -571,6 +587,7 @@ const $reversiGamesRepository: Provider = { exports: [ $usersRepository, $notesRepository, + $scheduledNotesRepository, $announcementsRepository, $announcementReadsRepository, $appsRepository, @@ -596,6 +613,7 @@ const $reversiGamesRepository: Provider = { $followRequestsRepository, $instancesRepository, $emojisRepository, + $emojiRequestsRepository, $driveFilesRepository, $driveFoldersRepository, $metasRepository, diff --git a/packages/backend/src/models/ScheduledNote.ts b/packages/backend/src/models/ScheduledNote.ts new file mode 100644 index 0000000000..513ad2f845 --- /dev/null +++ b/packages/backend/src/models/ScheduledNote.ts @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; +import type { MiNoteCreateOption } from '@/types.js'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; + +@Entity('note_schedule') +export class MiScheduledNote { + @PrimaryColumn(id()) + public id: string; + + @Column('jsonb') + public note: MiNoteCreateOption; + + @Index() + @Column('varchar', { + length: 260, + }) + public userId: MiUser['id']; + + @Column('timestamp with time zone') + public scheduledAt: Date; +} diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index c29ea15c85..5436a7d546 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -25,6 +25,11 @@ export class MiUser { }) public lastFetchedAt: Date | null; + @Column('integer', { + default: '0', + }) + public getPoints: number; + @Index() @Column('timestamp with time zone', { nullable: true, @@ -179,6 +184,12 @@ export class MiUser { }) public isCat: boolean; + @Column('boolean', { + default: false, + comment: 'Whether the User is a gorilla.', + }) + public isGorilla: boolean; + @Column('boolean', { default: false, comment: 'Whether the User is the root.', diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index 3378ed245c..cba3a2fab3 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -29,6 +29,7 @@ import { MiClipFavorite } from '@/models/ClipFavorite.js'; import { MiDriveFile } from '@/models/DriveFile.js'; import { MiDriveFolder } from '@/models/DriveFolder.js'; import { MiEmoji } from '@/models/Emoji.js'; +import { MiEmojiRequest } from '@/models/EmojiRequest.js'; import { MiFollowing } from '@/models/Following.js'; import { MiFollowRequest } from '@/models/FollowRequest.js'; import { MiGalleryLike } from '@/models/GalleryLike.js'; @@ -80,6 +81,7 @@ import { MiUserListFavorite } from '@/models/UserListFavorite.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiReversiGame } from '@/models/ReversiGame.js'; import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js'; +import { MiScheduledNote } from './ScheduledNote.js'; export interface MiRepository { createTableColumnNames(this: Repository & MiRepository): string[]; @@ -144,6 +146,7 @@ export { MiDriveFile, MiDriveFolder, MiEmoji, + MiEmojiRequest, MiFollowing, MiFollowRequest, MiGalleryLike, @@ -159,6 +162,7 @@ export { MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, + MiScheduledNote, MiPage, MiPageLike, MiPasswordResetRequest, @@ -215,6 +219,7 @@ export type ClipFavoritesRepository = Repository & MiRepository< export type DriveFilesRepository = Repository & MiRepository; export type DriveFoldersRepository = Repository & MiRepository; export type EmojisRepository = Repository & MiRepository; +export type EmojiRequestsRepository = Repository; export type FollowingsRepository = Repository & MiRepository; export type FollowRequestsRepository = Repository & MiRepository; export type GalleryLikesRepository = Repository & MiRepository; @@ -230,6 +235,7 @@ export type NoteFavoritesRepository = Repository & MiRepository< export type NoteReactionsRepository = Repository & MiRepository; export type NoteThreadMutingsRepository = Repository & MiRepository; export type NoteUnreadsRepository = Repository & MiRepository; +export type ScheduledNotesRepository = Repository; export type PagesRepository = Repository & MiRepository; export type PageLikesRepository = Repository & MiRepository; export type PasswordResetRequestsRepository = Repository & MiRepository; diff --git a/packages/backend/src/models/json-schema/emoji.ts b/packages/backend/src/models/json-schema/emoji.ts index b1ed6c2ed6..1612aeb83c 100644 --- a/packages/backend/src/models/json-schema/emoji.ts +++ b/packages/backend/src/models/json-schema/emoji.ts @@ -44,6 +44,40 @@ export const packedEmojiSimpleSchema = { format: 'id', }, }, + draft: { + type: 'boolean', + optional: false, nullable: true, + }, + }, +} as const; +export const packedEmojiRequestSimpleSchema = { + type: 'object', + properties: { + aliases: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + category: { + type: 'string', + optional: false, nullable: true, + }, + url: { + type: 'string', + optional: false, nullable: false, + }, + isSensitive: { + type: 'boolean', + optional: true, nullable: false, + }, }, } as const; @@ -85,6 +119,10 @@ export const packedEmojiDetailedSchema = { type: 'string', optional: false, nullable: true, }, + draft: { + type: 'boolean', + optional: false, nullable: true, + }, isSensitive: { type: 'boolean', optional: false, nullable: false, @@ -104,3 +142,51 @@ export const packedEmojiDetailedSchema = { }, }, } as const; + +export const packedEmojiRequestDetailedSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + aliases: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + category: { + type: 'string', + optional: false, nullable: true, + }, + url: { + type: 'string', + optional: false, nullable: false, + }, + license: { + type: 'string', + optional: false, nullable: true, + }, + isSensitive: { + type: 'boolean', + optional: false, nullable: false, + }, + localOnly: { + type: 'boolean', + optional: false, nullable: false, + }, + fileId: { + type: 'string', + optional: false, nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/note.ts b/packages/backend/src/models/json-schema/note.ts index c920497edc..84102a4536 100644 --- a/packages/backend/src/models/json-schema/note.ts +++ b/packages/backend/src/models/json-schema/note.ts @@ -17,6 +17,24 @@ export const packedNoteSchema = { optional: false, nullable: false, format: 'date-time', }, + updatedAt: { + type: 'string', + optional: true, nullable: true, + format: 'date-time', + }, + updatedAtHistory: { + type: 'array', + optional: true, nullable: true, + items: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + }, + noteEditHistory: { + type: 'array', + optional: true, nullable: false, + }, deletedAt: { type: 'string', optional: true, nullable: true, diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 52341d5b71..5c49040d89 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -155,6 +155,14 @@ export const packedUserLiteSchema = { onlineStatus: { type: 'string', nullable: false, optional: false, + isGorilla: { + type: 'boolean', + nullable: false, optional: true, + }, + onlineStatus: { + type: 'string', + format: 'url', + nullable: true, optional: false, enum: ['unknown', 'online', 'active', 'offline'], }, badgeRoles: { @@ -180,7 +188,8 @@ export const packedUserLiteSchema = { }, }, }, -} as const; +} +} as const export const packedUserDetailedNotMeOnlySchema = { type: 'object', diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index ef12dcacdc..a74c3c6963 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -28,6 +28,7 @@ import { MiClipFavorite } from '@/models/ClipFavorite.js'; import { MiDriveFile } from '@/models/DriveFile.js'; import { MiDriveFolder } from '@/models/DriveFolder.js'; import { MiEmoji } from '@/models/Emoji.js'; +import { MiEmojiRequest } from '@/models/EmojiRequest.js'; import { MiFollowing } from '@/models/Following.js'; import { MiFollowRequest } from '@/models/FollowRequest.js'; import { MiGalleryLike } from '@/models/GalleryLike.js'; @@ -76,6 +77,7 @@ import { MiRoleAssignment } from '@/models/RoleAssignment.js'; import { MiFlash } from '@/models/Flash.js'; import { MiFlashLike } from '@/models/FlashLike.js'; import { MiUserMemo } from '@/models/UserMemo.js'; +import { MiScheduledNote } from '@/models/ScheduledNote.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiReversiGame } from '@/models/ReversiGame.js'; @@ -99,7 +101,7 @@ class MyCustomLogger implements Logger { @bindThis public logQuery(query: string, parameters?: any[]) { - sqlLogger.info(this.highlight(query).substring(0, 100)); + sqlLogger.info(this.highlight(query)); } @bindThis @@ -153,6 +155,7 @@ export const entities = [ MiRenoteMuting, MiBlocking, MiNote, + MiScheduledNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, @@ -166,6 +169,7 @@ export const entities = [ MiPoll, MiPollVote, MiEmoji, + MiEmojiRequest, MiHashtag, MiSwSubscription, MiAbuseUserReport, diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts index 7f00865845..b7c1e6fd70 100644 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -10,6 +10,7 @@ import { QueueLoggerService } from './QueueLoggerService.js'; import { QueueProcessorService } from './QueueProcessorService.js'; import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; +import { ScheduleNotePostProcessorService } from './processors/ScheduleNotePostProcessorService.js'; import { InboxProcessorService } from './processors/InboxProcessorService.js'; import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js'; import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js'; @@ -75,6 +76,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor UserWebhookDeliverProcessorService, SystemWebhookDeliverProcessorService, EndedPollNotificationProcessorService, + ScheduleNotePostProcessorService, DeliverProcessorService, InboxProcessorService, AggregateRetentionProcessorService, diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 5d78150103..5af91d2955 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -13,6 +13,7 @@ import { bindThis } from '@/decorators.js'; import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js'; import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; +import { ScheduleNotePostProcessorService } from './processors/ScheduleNotePostProcessorService.js'; import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; import { InboxProcessorService } from './processors/InboxProcessorService.js'; import { DeleteDriveFilesProcessorService } from './processors/DeleteDriveFilesProcessorService.js'; @@ -82,6 +83,7 @@ export class QueueProcessorService implements OnApplicationShutdown { private relationshipQueueWorker: Bull.Worker; private objectStorageQueueWorker: Bull.Worker; private endedPollNotificationQueueWorker: Bull.Worker; + private schedulerNotePostQueueWorker: Bull.Worker; constructor( @Inject(DI.config) @@ -91,6 +93,7 @@ export class QueueProcessorService implements OnApplicationShutdown { private userWebhookDeliverProcessorService: UserWebhookDeliverProcessorService, private systemWebhookDeliverProcessorService: SystemWebhookDeliverProcessorService, private endedPollNotificationProcessorService: EndedPollNotificationProcessorService, + private scheduleNotePostProcessorService: ScheduleNotePostProcessorService, private deliverProcessorService: DeliverProcessorService, private inboxProcessorService: InboxProcessorService, private deleteDriveFilesProcessorService: DeleteDriveFilesProcessorService, @@ -487,6 +490,13 @@ export class QueueProcessorService implements OnApplicationShutdown { } //#endregion + //#region schedule note post + this.schedulerNotePostQueueWorker = new Bull.Worker(QUEUE.SCHEDULE_NOTE_POST, (job) => this.scheduleNotePostProcessorService.process(job), { + ...baseQueueOptions(this.config, QUEUE.SCHEDULE_NOTE_POST), + autorun: false, + }); + //#endregion + //#region ended poll notification { this.endedPollNotificationQueueWorker = new Bull.Worker(QUEUE.ENDED_POLL_NOTIFICATION, (job) => { @@ -515,6 +525,7 @@ export class QueueProcessorService implements OnApplicationShutdown { this.relationshipQueueWorker.run(), this.objectStorageQueueWorker.run(), this.endedPollNotificationQueueWorker.run(), + this.schedulerNotePostQueueWorker.run(), ]); } @@ -530,6 +541,7 @@ export class QueueProcessorService implements OnApplicationShutdown { this.relationshipQueueWorker.close(), this.objectStorageQueueWorker.close(), this.endedPollNotificationQueueWorker.close(), + this.schedulerNotePostQueueWorker.close(), ]); } diff --git a/packages/backend/src/queue/const.ts b/packages/backend/src/queue/const.ts index 15f01303bb..6466a03ee4 100644 --- a/packages/backend/src/queue/const.ts +++ b/packages/backend/src/queue/const.ts @@ -11,6 +11,7 @@ export const QUEUE = { INBOX: 'inbox', SYSTEM: 'system', ENDED_POLL_NOTIFICATION: 'endedPollNotification', + SCHEDULE_NOTE_POST: 'scheduleNotePost', DB: 'db', RELATIONSHIP: 'relationship', OBJECT_STORAGE: 'objectStorage', diff --git a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts index e25d3913a7..17fae0fe97 100644 --- a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts @@ -103,6 +103,7 @@ export class ImportCustomEmojisProcessorService { isSensitive: emojiInfo.isSensitive, localOnly: emojiInfo.localOnly, roleIdsThatCanBeUsedThisEmojiAsReaction: [], + draft: false, }); } diff --git a/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts new file mode 100644 index 0000000000..40fc3f3e54 --- /dev/null +++ b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type Logger from '@/logger.js'; +import { bindThis } from '@/decorators.js'; +import { NoteCreateService } from '@/core/NoteCreateService.js'; +import type { ScheduledNotesRepository, UsersRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type * as Bull from 'bullmq'; +import type { ScheduleNotePostJobData } from '../types.js'; + +@Injectable() +export class ScheduleNotePostProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.scheduledNotesRepository) + private scheduledNotesRepository: ScheduledNotesRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private noteCreateService: NoteCreateService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('schedule-note-post'); + } + + @bindThis + public async process(job: Bull.Job): Promise { + this.scheduledNotesRepository.findOneBy({ id: job.data.scheduledNoteId }).then(async (data) => { + if (!data) { + this.logger.warn(`Schedule note ${job.data.scheduledNoteId} not found`); + } else { + data.note.createdAt = new Date(); + const me = await this.usersRepository.findOneByOrFail({ id: data.userId }); + await this.noteCreateService.create(me, data.note); + await this.scheduledNotesRepository.remove(data); + } + }); + } +} diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index 1ca829a87a..d953c59b28 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -6,9 +6,13 @@ import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiNote } from '@/models/Note.js'; -import type { MiUser } from '@/models/User.js'; +import type { MiLocalUser, MiUser } from '@/models/User.js'; import type { MiWebhook } from '@/models/Webhook.js'; import type { IActivity } from '@/core/activitypub/type.js'; +import { IPoll } from '@/models/Poll.js'; +import { MiScheduledNote } from '@/models/ScheduledNote.js'; +import { MiChannel } from '@/models/Channel.js'; +import { MiApp } from '@/models/App.js'; import type httpSignature from '@peertube/http-signature'; export type DeliverJobData = { @@ -106,6 +110,17 @@ export type EndedPollNotificationJobData = { noteId: MiNote['id']; }; +export type ScheduleNotePostJobData = { + scheduledNoteId: MiNote['id']; +} + +type MinimumUser = { + id: MiUser['id']; + host: MiUser['host']; + username: MiUser['username']; + uri: MiUser['uri']; +}; + export type SystemWebhookDeliverJobData = { type: string; content: unknown; diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index 77eb2c5a02..677997127c 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -342,7 +342,8 @@ export class FileServerService { 'avatar' in request.query || 'static' in request.query || 'preview' in request.query || - 'badge' in request.query + 'badge' in request.query || + 'datasaver' in request.query ) { if (!isConvertibleImage) { // 画像でないなら404でお茶を濁す @@ -361,7 +362,7 @@ export class FileServerService { } else { const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in request.query) })) .resize({ - height: 'emoji' in request.query ? 128 : 320, + height: 'emoji' in request.query ? 64 : 128, withoutEnlargement: true, }) .webp(webpDefault); @@ -407,7 +408,28 @@ export class FileServerService { ext: 'png', type: 'image/png', }; - } else if (file.mime === 'image/svg+xml') { + } else if ('datasaver' in request.query){ + if (!isAnimationConvertibleImage && !('static' in request.query)) { + image = { + data: fs.createReadStream(file.path), + ext: file.ext, + type: file.mime, + }; + } else { + const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in request.query) })) + .resize({ + height: 32, + withoutEnlargement: true, + }) + .webp(webpDefault); + + image = { + data, + ext: 'webp', + type: 'image/webp', + }; + } + }else if (file.mime === 'image/svg+xml') { image = this.imageProcessingService.convertToWebpStream(file.path, 2048, 2048); } else if (!file.mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(file.mime)) { throw new StatusError('Rejected type', 403, 'Rejected type'); diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index a20a1f24eb..3ccf9d6ffc 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -6,6 +6,7 @@ import { Module } from '@nestjs/common'; import { CoreModule } from '@/core/CoreModule.js'; +import * as ep___users_lists_list_favorite from '@/server/api/endpoints/users/lists/list-favorite.js'; import * as ep___admin_abuseReport_notificationRecipient_list from '@/server/api/endpoints/admin/abuse-report/notification-recipient/list.js'; import * as ep___admin_abuseReport_notificationRecipient_show from '@/server/api/endpoints/admin/abuse-report/notification-recipient/show.js'; import * as ep___admin_abuseReport_notificationRecipient_create from '@/server/api/endpoints/admin/abuse-report/notification-recipient/create.js'; @@ -36,18 +37,23 @@ import * as ep___admin_drive_cleanup from './endpoints/admin/drive/cleanup.js'; import * as ep___admin_drive_files from './endpoints/admin/drive/files.js'; import * as ep___admin_drive_showFile from './endpoints/admin/drive/show-file.js'; import * as ep___admin_emoji_addAliasesBulk from './endpoints/admin/emoji/add-aliases-bulk.js'; +import * as ep___admin_emoji_setlocalOnlyBulk from './endpoints/admin/emoji/set-localonly-bulk.js'; +import * as ep___admin_emoji_setisSensitiveBulk from './endpoints/admin/emoji/set-issensitive-bulk.js'; import * as ep___admin_emoji_add from './endpoints/admin/emoji/add.js'; +import * as ep___admin_emoji_addRequest from './endpoints/admin/emoji/add-request.js'; import * as ep___admin_emoji_copy from './endpoints/admin/emoji/copy.js'; import * as ep___admin_emoji_deleteBulk from './endpoints/admin/emoji/delete-bulk.js'; import * as ep___admin_emoji_delete from './endpoints/admin/emoji/delete.js'; import * as ep___admin_emoji_importZip from './endpoints/admin/emoji/import-zip.js'; import * as ep___admin_emoji_listRemote from './endpoints/admin/emoji/list-remote.js'; import * as ep___admin_emoji_list from './endpoints/admin/emoji/list.js'; +import * as ep___admin_emoji_listRequest from './endpoints/admin/emoji/list-request.js'; import * as ep___admin_emoji_removeAliasesBulk from './endpoints/admin/emoji/remove-aliases-bulk.js'; import * as ep___admin_emoji_setAliasesBulk from './endpoints/admin/emoji/set-aliases-bulk.js'; import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-category-bulk.js'; import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js'; import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js'; +import * as ep___admin_emoji_updateRequest from './endpoints/admin/emoji/update-request.js'; import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js'; import * as ep___admin_federation_refreshRemoteInstanceMetadata from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; import * as ep___admin_federation_removeAllFollowing from './endpoints/admin/federation/remove-all-following.js'; @@ -264,6 +270,7 @@ import * as ep___invite_list from './endpoints/invite/list.js'; import * as ep___invite_limit from './endpoints/invite/limit.js'; import * as ep___meta from './endpoints/meta.js'; import * as ep___emojis from './endpoints/emojis.js'; +import * as ep___emojiRequests from './endpoints/emoji-requests.js'; import * as ep___emoji from './endpoints/emoji.js'; import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js'; import * as ep___mute_create from './endpoints/mute/create.js'; @@ -278,13 +285,17 @@ import * as ep___notes_children from './endpoints/notes/children.js'; import * as ep___notes_clips from './endpoints/notes/clips.js'; import * as ep___notes_conversation from './endpoints/notes/conversation.js'; import * as ep___notes_create from './endpoints/notes/create.js'; +import * as ep___notes_schedule_delete from './endpoints/notes/schedule/delete.js'; +import * as ep___notes_schedule_list from './endpoints/notes/schedule/list.js'; import * as ep___notes_delete from './endpoints/notes/delete.js'; +import * as ep___notes_update from './endpoints/notes/update.js'; import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js'; import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js'; import * as ep___notes_featured from './endpoints/notes/featured.js'; import * as ep___notes_globalTimeline from './endpoints/notes/global-timeline.js'; import * as ep___notes_hybridTimeline from './endpoints/notes/hybrid-timeline.js'; import * as ep___notes_localTimeline from './endpoints/notes/local-timeline.js'; +import * as ep___notes_anyLocalTimeline from './endpoints/notes/any-local-timeline.js'; import * as ep___notes_mentions from './endpoints/notes/mentions.js'; import * as ep___notes_polls_recommendation from './endpoints/notes/polls/recommendation.js'; import * as ep___notes_polls_vote from './endpoints/notes/polls/vote.js'; @@ -349,6 +360,7 @@ import * as ep___users_following from './endpoints/users/following.js'; import * as ep___users_gallery_posts from './endpoints/users/gallery/posts.js'; import * as ep___users_getFrequentlyRepliedUsers from './endpoints/users/get-frequently-replied-users.js'; import * as ep___users_featuredNotes from './endpoints/users/featured-notes.js'; +import * as ep___users_user_stats from './endpoints/users/stats.js'; import * as ep___users_lists_create from './endpoints/users/lists/create.js'; import * as ep___users_lists_delete from './endpoints/users/lists/delete.js'; import * as ep___users_lists_list from './endpoints/users/lists/list.js'; @@ -387,8 +399,9 @@ import * as ep___reversi_surrender from './endpoints/reversi/surrender.js'; import * as ep___reversi_verify from './endpoints/reversi/verify.js'; import { GetterService } from './GetterService.js'; import { ApiLoggerService } from './ApiLoggerService.js'; +import * as ep___admin_accounts_present_points from './endpoints/admin/accounts/present-points.js'; import type { Provider } from '@nestjs/common'; - +import * as ep___emoji_speedtest from './endpoints/admin/emoji/speedtest.js'; const $admin_meta: Provider = { provide: 'ep:admin/meta', useClass: ep___admin_meta.default }; const $admin_abuseUserReports: Provider = { provide: 'ep:admin/abuse-user-reports', useClass: ep___admin_abuseUserReports.default }; const $admin_abuseReport_notificationRecipient_list: Provider = { provide: 'ep:admin/abuse-report/notification-recipient/list', useClass: ep___admin_abuseReport_notificationRecipient_list.default }; @@ -399,6 +412,7 @@ const $admin_abuseReport_notificationRecipient_delete: Provider = { provide: 'ep const $admin_accounts_create: Provider = { provide: 'ep:admin/accounts/create', useClass: ep___admin_accounts_create.default }; const $admin_accounts_delete: Provider = { provide: 'ep:admin/accounts/delete', useClass: ep___admin_accounts_delete.default }; const $admin_accounts_findByEmail: Provider = { provide: 'ep:admin/accounts/find-by-email', useClass: ep___admin_accounts_findByEmail.default }; +const $admin_accounts_present_points: Provider = { provide: 'ep:admin/accounts/present-points', useClass: ep___admin_accounts_present_points.default }; const $admin_ad_create: Provider = { provide: 'ep:admin/ad/create', useClass: ep___admin_ad_create.default }; const $admin_ad_delete: Provider = { provide: 'ep:admin/ad/delete', useClass: ep___admin_ad_delete.default }; const $admin_ad_list: Provider = { provide: 'ep:admin/ad/list', useClass: ep___admin_ad_list.default }; @@ -414,23 +428,29 @@ const $admin_avatarDecorations_update: Provider = { provide: 'ep:admin/avatar-de const $admin_deleteAllFilesOfAUser: Provider = { provide: 'ep:admin/delete-all-files-of-a-user', useClass: ep___admin_deleteAllFilesOfAUser.default }; const $admin_unsetUserAvatar: Provider = { provide: 'ep:admin/unset-user-avatar', useClass: ep___admin_unsetUserAvatar.default }; const $admin_unsetUserBanner: Provider = { provide: 'ep:admin/unset-user-banner', useClass: ep___admin_unsetUserBanner.default }; +const $emoji_speedtest: Provider = { provide: 'ep:emoji/speedtest', useClass: ep___emoji_speedtest.default }; const $admin_drive_cleanRemoteFiles: Provider = { provide: 'ep:admin/drive/clean-remote-files', useClass: ep___admin_drive_cleanRemoteFiles.default }; const $admin_drive_cleanup: Provider = { provide: 'ep:admin/drive/cleanup', useClass: ep___admin_drive_cleanup.default }; const $admin_drive_files: Provider = { provide: 'ep:admin/drive/files', useClass: ep___admin_drive_files.default }; const $admin_drive_showFile: Provider = { provide: 'ep:admin/drive/show-file', useClass: ep___admin_drive_showFile.default }; const $admin_emoji_addAliasesBulk: Provider = { provide: 'ep:admin/emoji/add-aliases-bulk', useClass: ep___admin_emoji_addAliasesBulk.default }; +const $admin_emoji_setlocalOnlyBulk: Provider = { provide: 'ep:admin/emoji/set-localonly-bulk', useClass: ep___admin_emoji_setlocalOnlyBulk.default }; +const $admin_emoji_setisSensitiveBulk: Provider = { provide: 'ep:admin/emoji/set-issensitive-bulk', useClass: ep___admin_emoji_setisSensitiveBulk.default }; const $admin_emoji_add: Provider = { provide: 'ep:admin/emoji/add', useClass: ep___admin_emoji_add.default }; +const $admin_emoji_addRequest: Provider = { provide: 'ep:admin/emoji/add-request', useClass: ep___admin_emoji_addRequest.default }; const $admin_emoji_copy: Provider = { provide: 'ep:admin/emoji/copy', useClass: ep___admin_emoji_copy.default }; const $admin_emoji_deleteBulk: Provider = { provide: 'ep:admin/emoji/delete-bulk', useClass: ep___admin_emoji_deleteBulk.default }; const $admin_emoji_delete: Provider = { provide: 'ep:admin/emoji/delete', useClass: ep___admin_emoji_delete.default }; const $admin_emoji_importZip: Provider = { provide: 'ep:admin/emoji/import-zip', useClass: ep___admin_emoji_importZip.default }; const $admin_emoji_listRemote: Provider = { provide: 'ep:admin/emoji/list-remote', useClass: ep___admin_emoji_listRemote.default }; const $admin_emoji_list: Provider = { provide: 'ep:admin/emoji/list', useClass: ep___admin_emoji_list.default }; +const $admin_emoji_listRequest: Provider = { provide: 'ep:admin/emoji/list-request', useClass: ep___admin_emoji_listRequest.default }; const $admin_emoji_removeAliasesBulk: Provider = { provide: 'ep:admin/emoji/remove-aliases-bulk', useClass: ep___admin_emoji_removeAliasesBulk.default }; const $admin_emoji_setAliasesBulk: Provider = { provide: 'ep:admin/emoji/set-aliases-bulk', useClass: ep___admin_emoji_setAliasesBulk.default }; const $admin_emoji_setCategoryBulk: Provider = { provide: 'ep:admin/emoji/set-category-bulk', useClass: ep___admin_emoji_setCategoryBulk.default }; const $admin_emoji_setLicenseBulk: Provider = { provide: 'ep:admin/emoji/set-license-bulk', useClass: ep___admin_emoji_setLicenseBulk.default }; const $admin_emoji_update: Provider = { provide: 'ep:admin/emoji/update', useClass: ep___admin_emoji_update.default }; +const $admin_emoji_updateRequest: Provider = { provide: 'ep:admin/emoji/update-request', useClass: ep___admin_emoji_updateRequest.default }; const $admin_federation_deleteAllFiles: Provider = { provide: 'ep:admin/federation/delete-all-files', useClass: ep___admin_federation_deleteAllFiles.default }; const $admin_federation_refreshRemoteInstanceMetadata: Provider = { provide: 'ep:admin/federation/refresh-remote-instance-metadata', useClass: ep___admin_federation_refreshRemoteInstanceMetadata.default }; const $admin_federation_removeAllFollowing: Provider = { provide: 'ep:admin/federation/remove-all-following', useClass: ep___admin_federation_removeAllFollowing.default }; @@ -615,6 +635,7 @@ const $i_importMuting: Provider = { provide: 'ep:i/import-muting', useClass: ep_ const $i_importUserLists: Provider = { provide: 'ep:i/import-user-lists', useClass: ep___i_importUserLists.default }; const $i_importAntennas: Provider = { provide: 'ep:i/import-antennas', useClass: ep___i_importAntennas.default }; const $i_notifications: Provider = { provide: 'ep:i/notifications', useClass: ep___i_notifications.default }; +const $i_userstats: Provider = { provide: 'ep:i/stats', useClass: ep___users_user_stats.default }; const $i_notificationsGrouped: Provider = { provide: 'ep:i/notifications-grouped', useClass: ep___i_notificationsGrouped.default }; const $i_pageLikes: Provider = { provide: 'ep:i/page-likes', useClass: ep___i_pageLikes.default }; const $i_pages: Provider = { provide: 'ep:i/pages', useClass: ep___i_pages.default }; @@ -647,6 +668,7 @@ const $invite_list: Provider = { provide: 'ep:invite/list', useClass: ep___invit const $invite_limit: Provider = { provide: 'ep:invite/limit', useClass: ep___invite_limit.default }; const $meta: Provider = { provide: 'ep:meta', useClass: ep___meta.default }; const $emojis: Provider = { provide: 'ep:emojis', useClass: ep___emojis.default }; +const $emoji_requests: Provider = { provide: 'ep:emoji-requests', useClass: ep___emojiRequests.default }; const $emoji: Provider = { provide: 'ep:emoji', useClass: ep___emoji.default }; const $miauth_genToken: Provider = { provide: 'ep:miauth/gen-token', useClass: ep___miauth_genToken.default }; const $mute_create: Provider = { provide: 'ep:mute/create', useClass: ep___mute_create.default }; @@ -661,13 +683,17 @@ const $notes_children: Provider = { provide: 'ep:notes/children', useClass: ep__ const $notes_clips: Provider = { provide: 'ep:notes/clips', useClass: ep___notes_clips.default }; const $notes_conversation: Provider = { provide: 'ep:notes/conversation', useClass: ep___notes_conversation.default }; const $notes_create: Provider = { provide: 'ep:notes/create', useClass: ep___notes_create.default }; +const $notes_schedule_delete: Provider = { provide: 'ep:notes/schedule/delete', useClass: ep___notes_schedule_delete.default }; +const $notes_schedule_list: Provider = { provide: 'ep:notes/schedule/list', useClass: ep___notes_schedule_list.default }; const $notes_delete: Provider = { provide: 'ep:notes/delete', useClass: ep___notes_delete.default }; +const $notes_update: Provider = { provide: 'ep:notes/update', useClass: ep___notes_update.default }; const $notes_favorites_create: Provider = { provide: 'ep:notes/favorites/create', useClass: ep___notes_favorites_create.default }; const $notes_favorites_delete: Provider = { provide: 'ep:notes/favorites/delete', useClass: ep___notes_favorites_delete.default }; const $notes_featured: Provider = { provide: 'ep:notes/featured', useClass: ep___notes_featured.default }; const $notes_globalTimeline: Provider = { provide: 'ep:notes/global-timeline', useClass: ep___notes_globalTimeline.default }; const $notes_hybridTimeline: Provider = { provide: 'ep:notes/hybrid-timeline', useClass: ep___notes_hybridTimeline.default }; const $notes_localTimeline: Provider = { provide: 'ep:notes/local-timeline', useClass: ep___notes_localTimeline.default }; +const $notes_anyLocalTimeline: Provider = { provide: 'ep:notes/any-local-timeline', useClass: ep___notes_anyLocalTimeline.default }; const $notes_mentions: Provider = { provide: 'ep:notes/mentions', useClass: ep___notes_mentions.default }; const $notes_polls_recommendation: Provider = { provide: 'ep:notes/polls/recommendation', useClass: ep___notes_polls_recommendation.default }; const $notes_polls_vote: Provider = { provide: 'ep:notes/polls/vote', useClass: ep___notes_polls_vote.default }; @@ -735,6 +761,7 @@ const $users_featuredNotes: Provider = { provide: 'ep:users/featured-notes', use const $users_lists_create: Provider = { provide: 'ep:users/lists/create', useClass: ep___users_lists_create.default }; const $users_lists_delete: Provider = { provide: 'ep:users/lists/delete', useClass: ep___users_lists_delete.default }; const $users_lists_list: Provider = { provide: 'ep:users/lists/list', useClass: ep___users_lists_list.default }; +const $users_lists_list_favorite: Provider = { provide: 'ep:users/lists/list-favorite', useClass: ep___users_lists_list_favorite.default }; const $users_lists_pull: Provider = { provide: 'ep:users/lists/pull', useClass: ep___users_lists_pull.default }; const $users_lists_push: Provider = { provide: 'ep:users/lists/push', useClass: ep___users_lists_push.default }; const $users_lists_show: Provider = { provide: 'ep:users/lists/show', useClass: ep___users_lists_show.default }; @@ -786,6 +813,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_accounts_create, $admin_accounts_delete, $admin_accounts_findByEmail, + $admin_accounts_present_points, $admin_ad_create, $admin_ad_delete, $admin_ad_list, @@ -800,24 +828,30 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_avatarDecorations_update, $admin_deleteAllFilesOfAUser, $admin_unsetUserAvatar, + $emoji_speedtest, $admin_unsetUserBanner, $admin_drive_cleanRemoteFiles, $admin_drive_cleanup, $admin_drive_files, $admin_drive_showFile, $admin_emoji_addAliasesBulk, + $admin_emoji_setlocalOnlyBulk, + $admin_emoji_setisSensitiveBulk, $admin_emoji_add, + $admin_emoji_addRequest, $admin_emoji_copy, $admin_emoji_deleteBulk, $admin_emoji_delete, $admin_emoji_importZip, $admin_emoji_listRemote, $admin_emoji_list, + $admin_emoji_listRequest, $admin_emoji_removeAliasesBulk, $admin_emoji_setAliasesBulk, $admin_emoji_setCategoryBulk, $admin_emoji_setLicenseBulk, $admin_emoji_update, + $admin_emoji_updateRequest, $admin_federation_deleteAllFiles, $admin_federation_refreshRemoteInstanceMetadata, $admin_federation_removeAllFollowing, @@ -890,6 +924,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $channels_timeline, $channels_unfollow, $channels_update, + $i_userstats, $channels_favorite, $channels_unfavorite, $channels_myFavorites, @@ -1034,6 +1069,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $invite_limit, $meta, $emojis, + $emoji_requests, $emoji, $miauth_genToken, $mute_create, @@ -1048,13 +1084,17 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $notes_clips, $notes_conversation, $notes_create, + $notes_schedule_delete, + $notes_schedule_list, $notes_delete, + $notes_update, $notes_favorites_create, $notes_favorites_delete, $notes_featured, $notes_globalTimeline, $notes_hybridTimeline, $notes_localTimeline, + $notes_anyLocalTimeline, $notes_mentions, $notes_polls_recommendation, $notes_polls_vote, @@ -1122,6 +1162,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $users_lists_create, $users_lists_delete, $users_lists_list, + $users_lists_list_favorite, $users_lists_pull, $users_lists_push, $users_lists_show, @@ -1167,6 +1208,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_accounts_create, $admin_accounts_delete, $admin_accounts_findByEmail, + $admin_accounts_present_points, $admin_ad_create, $admin_ad_delete, $admin_ad_list, @@ -1182,24 +1224,31 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_deleteAllFilesOfAUser, $admin_unsetUserAvatar, $admin_unsetUserBanner, + $emoji_speedtest, $admin_drive_cleanRemoteFiles, $admin_drive_cleanup, $admin_drive_files, $admin_drive_showFile, $admin_emoji_addAliasesBulk, $admin_emoji_add, + $admin_emoji_addRequest, $admin_emoji_copy, $admin_emoji_deleteBulk, $admin_emoji_delete, $admin_emoji_importZip, $admin_emoji_listRemote, $admin_emoji_list, + $admin_emoji_listRequest, $admin_emoji_removeAliasesBulk, $admin_emoji_setAliasesBulk, $admin_emoji_setCategoryBulk, $admin_emoji_setLicenseBulk, + $admin_emoji_setlocalOnlyBulk, + $admin_emoji_setisSensitiveBulk, $admin_emoji_update, + $admin_emoji_updateRequest, $admin_federation_deleteAllFiles, + $i_userstats, $admin_federation_refreshRemoteInstanceMetadata, $admin_federation_removeAllFollowing, $admin_federation_updateInstance, @@ -1415,6 +1464,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $invite_limit, $meta, $emojis, + $emoji_requests, $emoji, $miauth_genToken, $mute_create, @@ -1429,13 +1479,17 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $notes_clips, $notes_conversation, $notes_create, + $notes_schedule_delete, + $notes_schedule_list, $notes_delete, + $notes_update, $notes_favorites_create, $notes_favorites_delete, $notes_featured, $notes_globalTimeline, $notes_hybridTimeline, $notes_localTimeline, + $notes_anyLocalTimeline, $notes_mentions, $notes_polls_recommendation, $notes_polls_vote, @@ -1501,6 +1555,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $users_lists_create, $users_lists_delete, $users_lists_list, + $users_lists_list_favorite, $users_lists_pull, $users_lists_push, $users_lists_show, diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index c74e7f93f6..d27aad0b0c 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -6,6 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import bcrypt from 'bcryptjs'; import { IsNull } from 'typeorm'; +import ProxyCheck from 'proxycheck-ts'; import { DI } from '@/di-symbols.js'; import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository, MiRegistrationTicket } from '@/models/_.js'; import type { Config } from '@/config.js'; @@ -108,6 +109,24 @@ export class SignupApiService { const invitationCode = body['invitationCode']; const emailAddress = body['emailAddress']; + const { DiscordWebhookUrl } = (await this.metaService.fetch()); + if (DiscordWebhookUrl) { + const data_disc = { 'username': 'ユーザー登録お知らせ', + 'content': + 'ユーザー名 :' + username + '\n' + + 'メールアドレス : ' + emailAddress + '\n' + + 'IPアドレス : ' + request.headers['x-real-ip'] ?? request.ip, + }; + + await fetch(DiscordWebhookUrl, { + 'method': 'post', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data_disc), + }); + } + if (instance.emailRequiredForSignup) { if (emailAddress == null || typeof emailAddress !== 'string') { reply.code(400); diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 7604162d79..3bf1242ce8 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -5,7 +5,9 @@ import { permissions } from 'misskey-js'; import type { KeyOf, Schema } from '@/misc/json-schema.js'; - +import { RolePolicies } from '@/core/RoleService.js'; +import * as ep___admin_emoji_setlocalOnlyBulk from './endpoints/admin/emoji/set-localonly-bulk.js'; +import * as ep___admin_emoji_setisSensitiveBulk from './endpoints/admin/emoji/set-issensitive-bulk.js'; import * as ep___admin_abuseReport_notificationRecipient_list from '@/server/api/endpoints/admin/abuse-report/notification-recipient/list.js'; import * as ep___admin_abuseReport_notificationRecipient_show @@ -21,6 +23,8 @@ import * as ep___admin_meta from './endpoints/admin/meta.js'; import * as ep___admin_accounts_create from './endpoints/admin/accounts/create.js'; import * as ep___admin_accounts_delete from './endpoints/admin/accounts/delete.js'; import * as ep___admin_accounts_findByEmail from './endpoints/admin/accounts/find-by-email.js'; +import * as ep___admin_accounts_present_points from './endpoints/admin/accounts/present-points.js'; + import * as ep___admin_ad_create from './endpoints/admin/ad/create.js'; import * as ep___admin_ad_delete from './endpoints/admin/ad/delete.js'; import * as ep___admin_ad_list from './endpoints/admin/ad/list.js'; @@ -42,17 +46,21 @@ import * as ep___admin_drive_files from './endpoints/admin/drive/files.js'; import * as ep___admin_drive_showFile from './endpoints/admin/drive/show-file.js'; import * as ep___admin_emoji_addAliasesBulk from './endpoints/admin/emoji/add-aliases-bulk.js'; import * as ep___admin_emoji_add from './endpoints/admin/emoji/add.js'; +import * as ep___admin_emoji_addRequest from './endpoints/admin/emoji/add-request.js'; import * as ep___admin_emoji_copy from './endpoints/admin/emoji/copy.js'; import * as ep___admin_emoji_deleteBulk from './endpoints/admin/emoji/delete-bulk.js'; import * as ep___admin_emoji_delete from './endpoints/admin/emoji/delete.js'; import * as ep___admin_emoji_importZip from './endpoints/admin/emoji/import-zip.js'; import * as ep___admin_emoji_listRemote from './endpoints/admin/emoji/list-remote.js'; import * as ep___admin_emoji_list from './endpoints/admin/emoji/list.js'; +import * as ep___admin_emoji_listRequest from './endpoints/admin/emoji/list-request.js'; import * as ep___admin_emoji_removeAliasesBulk from './endpoints/admin/emoji/remove-aliases-bulk.js'; import * as ep___admin_emoji_setAliasesBulk from './endpoints/admin/emoji/set-aliases-bulk.js'; import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-category-bulk.js'; import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js'; import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js'; +import * as ep___admin_emoji_updateRequest from './endpoints/admin/emoji/update-request.js'; +import * as ep___emoji_speedtest from './endpoints/admin/emoji/speedtest.js'; import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js'; import * as ep___admin_federation_refreshRemoteInstanceMetadata from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; @@ -264,12 +272,14 @@ import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js'; import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js'; import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js'; import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js'; +import * as ep___i_user_stats from './endpoints/users/stats.js'; import * as ep___invite_create from './endpoints/invite/create.js'; import * as ep___invite_delete from './endpoints/invite/delete.js'; import * as ep___invite_list from './endpoints/invite/list.js'; import * as ep___invite_limit from './endpoints/invite/limit.js'; import * as ep___meta from './endpoints/meta.js'; import * as ep___emojis from './endpoints/emojis.js'; +import * as ep___emojiRequests from './endpoints/emoji-requests.js'; import * as ep___emoji from './endpoints/emoji.js'; import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js'; import * as ep___mute_create from './endpoints/mute/create.js'; @@ -284,13 +294,17 @@ import * as ep___notes_children from './endpoints/notes/children.js'; import * as ep___notes_clips from './endpoints/notes/clips.js'; import * as ep___notes_conversation from './endpoints/notes/conversation.js'; import * as ep___notes_create from './endpoints/notes/create.js'; +import * as ep___notes_schedule_delete from './endpoints/notes/schedule/delete.js'; +import * as ep___notes_schedule_list from './endpoints/notes/schedule/list.js'; import * as ep___notes_delete from './endpoints/notes/delete.js'; +import * as ep___notes_update from './endpoints/notes/update.js'; import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js'; import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js'; import * as ep___notes_featured from './endpoints/notes/featured.js'; import * as ep___notes_globalTimeline from './endpoints/notes/global-timeline.js'; import * as ep___notes_hybridTimeline from './endpoints/notes/hybrid-timeline.js'; import * as ep___notes_localTimeline from './endpoints/notes/local-timeline.js'; +import * as ep___notes_anyLocalTimeline from './endpoints/notes/any-local-timeline.js'; import * as ep___notes_mentions from './endpoints/notes/mentions.js'; import * as ep___notes_polls_recommendation from './endpoints/notes/polls/recommendation.js'; import * as ep___notes_polls_vote from './endpoints/notes/polls/vote.js'; @@ -358,6 +372,7 @@ import * as ep___users_featuredNotes from './endpoints/users/featured-notes.js'; import * as ep___users_lists_create from './endpoints/users/lists/create.js'; import * as ep___users_lists_delete from './endpoints/users/lists/delete.js'; import * as ep___users_lists_list from './endpoints/users/lists/list.js'; +import * as ep___users_lists_list_favorite from './endpoints/users/lists/list-favorite.js'; import * as ep___users_lists_pull from './endpoints/users/lists/pull.js'; import * as ep___users_lists_push from './endpoints/users/lists/push.js'; import * as ep___users_lists_show from './endpoints/users/lists/show.js'; @@ -391,7 +406,6 @@ import * as ep___reversi_invitations from './endpoints/reversi/invitations.js'; import * as ep___reversi_showGame from './endpoints/reversi/show-game.js'; import * as ep___reversi_surrender from './endpoints/reversi/surrender.js'; import * as ep___reversi_verify from './endpoints/reversi/verify.js'; - const eps = [ ['admin/meta', ep___admin_meta], ['admin/abuse-user-reports', ep___admin_abuseUserReports], @@ -403,6 +417,7 @@ const eps = [ ['admin/accounts/create', ep___admin_accounts_create], ['admin/accounts/delete', ep___admin_accounts_delete], ['admin/accounts/find-by-email', ep___admin_accounts_findByEmail], + ['admin/accounts/present-points', ep___admin_accounts_present_points], ['admin/ad/create', ep___admin_ad_create], ['admin/ad/delete', ep___admin_ad_delete], ['admin/ad/list', ep___admin_ad_list], @@ -424,17 +439,23 @@ const eps = [ ['admin/drive/show-file', ep___admin_drive_showFile], ['admin/emoji/add-aliases-bulk', ep___admin_emoji_addAliasesBulk], ['admin/emoji/add', ep___admin_emoji_add], + ['admin/emoji/add-request', ep___admin_emoji_addRequest], ['admin/emoji/copy', ep___admin_emoji_copy], ['admin/emoji/delete-bulk', ep___admin_emoji_deleteBulk], ['admin/emoji/delete', ep___admin_emoji_delete], ['admin/emoji/import-zip', ep___admin_emoji_importZip], ['admin/emoji/list-remote', ep___admin_emoji_listRemote], ['admin/emoji/list', ep___admin_emoji_list], + ['admin/emoji/list-request', ep___admin_emoji_listRequest], ['admin/emoji/remove-aliases-bulk', ep___admin_emoji_removeAliasesBulk], ['admin/emoji/set-aliases-bulk', ep___admin_emoji_setAliasesBulk], ['admin/emoji/set-category-bulk', ep___admin_emoji_setCategoryBulk], + ['admin/emoji/set-localonly-bulk', ep___admin_emoji_setlocalOnlyBulk], + ['admin/emoji/set-issensitive-bulk', ep___admin_emoji_setisSensitiveBulk], ['admin/emoji/set-license-bulk', ep___admin_emoji_setLicenseBulk], ['admin/emoji/update', ep___admin_emoji_update], + ['admin/emoji/update-request', ep___admin_emoji_updateRequest], + ['emoji/speedtest', ep___emoji_speedtest], ['admin/federation/delete-all-files', ep___admin_federation_deleteAllFiles], ['admin/federation/refresh-remote-instance-metadata', ep___admin_federation_refreshRemoteInstanceMetadata], ['admin/federation/remove-all-following', ep___admin_federation_removeAllFollowing], @@ -645,12 +666,14 @@ const eps = [ ['i/webhooks/show', ep___i_webhooks_show], ['i/webhooks/update', ep___i_webhooks_update], ['i/webhooks/delete', ep___i_webhooks_delete], + ['i/stats', ep___i_user_stats], ['invite/create', ep___invite_create], ['invite/delete', ep___invite_delete], ['invite/list', ep___invite_list], ['invite/limit', ep___invite_limit], ['meta', ep___meta], ['emojis', ep___emojis], + ['emoji-requests', ep___emojiRequests], ['emoji', ep___emoji], ['miauth/gen-token', ep___miauth_genToken], ['mute/create', ep___mute_create], @@ -665,13 +688,17 @@ const eps = [ ['notes/clips', ep___notes_clips], ['notes/conversation', ep___notes_conversation], ['notes/create', ep___notes_create], + ['notes/schedule/delete', ep___notes_schedule_delete], + ['notes/schedule/list', ep___notes_schedule_list], ['notes/delete', ep___notes_delete], + ['notes/update', ep___notes_update], ['notes/favorites/create', ep___notes_favorites_create], ['notes/favorites/delete', ep___notes_favorites_delete], ['notes/featured', ep___notes_featured], ['notes/global-timeline', ep___notes_globalTimeline], ['notes/hybrid-timeline', ep___notes_hybridTimeline], ['notes/local-timeline', ep___notes_localTimeline], + ['notes/any-local-timeline', ep___notes_anyLocalTimeline], ['notes/mentions', ep___notes_mentions], ['notes/polls/recommendation', ep___notes_polls_recommendation], ['notes/polls/vote', ep___notes_polls_vote], @@ -739,6 +766,7 @@ const eps = [ ['users/lists/create', ep___users_lists_create], ['users/lists/delete', ep___users_lists_delete], ['users/lists/list', ep___users_lists_list], + ['users/lists/list-favorite', ep___users_lists_list_favorite], ['users/lists/pull', ep___users_lists_pull], ['users/lists/push', ep___users_lists_push], ['users/lists/show', ep___users_lists_show], diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/present-points.ts b/packages/backend/src/server/api/endpoints/admin/accounts/present-points.ts new file mode 100644 index 0000000000..d69c6b83c7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/accounts/present-points.ts @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { UsersRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { NotificationService } from '@/core/NotificationService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireAdmin: true, + kind: 'write:admin:account', +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + points: { type: 'number' }, + }, + required: ['userId', 'points'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + private notificationService: NotificationService, + ) { + super(meta, paramDef, async (ps, me) => { + const user = await this.usersRepository.findOneBy({ id: ps.userId }); + + if (user == null) { + throw new Error('user not found'); + } + this.usersRepository.update( user.id, { + getPoints: user.getPoints + ps.points, + }); + this.notificationService.createNotification(user.id, 'loginbonus', { + loginbonus: ps.points, + }); + + return {}; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts index 3adb6c38fc..931474eab5 100644 --- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts @@ -24,6 +24,7 @@ export const paramDef = { roleIdsThatCanBeUsedThisDecoration: { type: 'array', items: { type: 'string', } }, + category: { type: 'string', nullable: true }, }, required: ['name', 'description', 'url'], } as const; @@ -39,6 +40,7 @@ export default class extends Endpoint { // eslint- description: ps.description, url: ps.url, roleIdsThatCanBeUsedThisDecoration: ps.roleIdsThatCanBeUsedThisDecoration, + category: ps.category ?? '', }, me); }); } diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts index d78c92de38..d0831c7ec0 100644 --- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts @@ -95,6 +95,7 @@ export default class extends Endpoint { // eslint- name: avatarDecoration.name, description: avatarDecoration.description, url: avatarDecoration.url, + category: avatarDecoration.category, roleIdsThatCanBeUsedThisDecoration: avatarDecoration.roleIdsThatCanBeUsedThisDecoration, })); }); diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts index d33efa5faa..8b68049c3b 100644 --- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts @@ -30,6 +30,7 @@ export const paramDef = { roleIdsThatCanBeUsedThisDecoration: { type: 'array', items: { type: 'string', } }, + category: { type: 'string', nullable: true }, }, required: ['id'], } as const; @@ -45,6 +46,7 @@ export default class extends Endpoint { // eslint- description: ps.description, url: ps.url, roleIdsThatCanBeUsedThisDecoration: ps.roleIdsThatCanBeUsedThisDecoration, + category: ps.category ?? '', }, me); }); } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add-request.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add-request.ts new file mode 100644 index 0000000000..147073c2b4 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add-request.ts @@ -0,0 +1,154 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { DriveFilesRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { ApiError } from '../../../error.js'; +import {MetaService} from "@/core/MetaService.js"; +import {DriveService} from "@/core/DriveService.js"; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireRolePolicy: 'canRequestCustomEmojis', + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'fc46b5a4-6b92-4c33-ac66-b806659bb5cf', + }, + duplicateName: { + message: 'Duplicate name.', + code: 'DUPLICATE_NAME', + id: 'f7a3462c-4e6e-4069-8421-b9bd4f4c3975', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' }, + category: { + type: 'string', + nullable: true, + description: 'Use `null` to reset the category.', + }, + aliases: { type: 'array', items: { + type: 'string', + } }, + license: { type: 'string', nullable: true }, + isSensitive: { type: 'boolean', nullable: true }, + localOnly: { type: 'boolean', nullable: true }, + fileId: { type: 'string', format: 'misskey:id' }, + isNotifyIsHome: { type: 'boolean', nullable: true }, + }, + required: ['name', 'fileId'], +} as const; + +// TODO: ロジックをサービスに切り出す + +@Injectable() +// eslint-disable-next-line import/no-default-export +export default class extends Endpoint { + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + private metaService: MetaService, + private customEmojiService: CustomEmojiService, + private driveService: DriveService, + private moderationLogService: ModerationLogService, + ) { + super(meta, paramDef, async (ps, me) => { + const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name); + const isRequestDuplicate = await this.customEmojiService.checkRequestDuplicate(ps.name); + + if (isDuplicate || isRequestDuplicate) throw new ApiError(meta.errors.duplicateName); + let driveFile; + let tmp = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); + if (tmp == null) throw new ApiError(meta.errors.noSuchFile); + + try { + driveFile = await this.driveService.uploadFromUrl({ url: tmp.url , user: null, force: true }); + } catch (e) { + throw new ApiError(); + } + if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); + const {ApiBase,EmojiBotToken,DiscordWebhookUrl,requestEmojiAllOk} = (await this.metaService.fetch()) + let emoji; + if (requestEmojiAllOk){ + emoji = await this.customEmojiService.add({ + driveFile, + name: ps.name, + category: ps.category ?? null, + aliases: ps.aliases ?? [], + license: ps.license ?? null, + host: null, + isSensitive: ps.isSensitive ?? false, + localOnly: ps.localOnly ?? false, + roleIdsThatCanBeUsedThisEmojiAsReaction: [], + }); + }else{ + emoji = await this.customEmojiService.request({ + driveFile, + name: ps.name, + category: ps.category ?? null, + aliases: ps.aliases ?? [], + license: ps.license ?? null, + isSensitive: ps.isSensitive ?? false, + localOnly: ps.localOnly ?? false, + }); + } + + + await this.moderationLogService.log(me, 'addCustomEmoji', { + emojiId: emoji.id, + emoji: emoji, + }); + + if (EmojiBotToken){ + const data_Miss = { + 'i': EmojiBotToken, + 'visibility': ps.isNotifyIsHome ? 'home' : 'public', + 'text': + '絵文字名 : :' + ps.name + ':\n' + + 'カテゴリ : ' + ps.category + '\n' + + 'ライセンス : ' + ps.license + '\n' + + 'タグ : ' + ps.aliases + '\n' + + '追加したユーザー : ' + '@' + me.username + '\n' + }; + await fetch(ApiBase+'/notes/create', { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body:JSON.stringify( data_Miss) + }) + } + + if (DiscordWebhookUrl){ + const data_disc = {"username": "絵文字追加通知ちゃん", + 'content': + '絵文字名 : :'+ ps.name +':\n' + + 'カテゴリ : ' + ps.category + '\n'+ + 'ライセンス : '+ ps.license + '\n'+ + 'タグ : '+ps.aliases+ '\n'+ + '追加したユーザー : ' + '@'+me.username + '\n' + } + await fetch(DiscordWebhookUrl, { + 'method':'post', + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data_disc), + }) + } + return { + id: emoji.id, + }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts index 628b4efedc..a5533b6ec2 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts @@ -47,15 +47,19 @@ export const paramDef = { nullable: true, description: 'Use `null` to reset the category.', }, - aliases: { type: 'array', items: { - type: 'string', - } }, + aliases: { + type: 'array', items: { + type: 'string', + }, + }, license: { type: 'string', nullable: true }, isSensitive: { type: 'boolean' }, localOnly: { type: 'boolean' }, - roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: { - type: 'string', - } }, + roleIdsThatCanBeUsedThisEmojiAsReaction: { + type: 'array', items: { + type: 'string', + }, + }, }, required: ['name', 'fileId'], } as const; @@ -67,13 +71,12 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - private customEmojiService: CustomEmojiService, - private emojiEntityService: EmojiEntityService, ) { super(meta, paramDef, async (ps, me) => { const driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); + if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name); if (isDuplicate) throw new ApiError(meta.errors.duplicateName); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts b/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts index a4de16437b..3d3be444ba 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts @@ -37,7 +37,14 @@ export default class extends Endpoint { // eslint- private customEmojiService: CustomEmojiService, ) { super(meta, paramDef, async (ps, me) => { - await this.customEmojiService.delete(ps.id, me); + const emoji = await this.customEmojiService.getEmojiById(ps.id); + const RequestEmoji = await this.customEmojiService.getEmojiRequestById(ps.id); + if (emoji != null) { + await this.customEmojiService.delete(ps.id, me); + } + if (RequestEmoji != null) { + await this.customEmojiService.deleteRequest(ps.id); + } }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list-request.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list-request.ts new file mode 100644 index 0000000000..f5faca972f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list-request.ts @@ -0,0 +1,108 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { EmojiRequestsRepository } from '@/models/_.js'; +import type { MiEmojiRequest } from '@/models/EmojiRequest.js'; +import { QueryService } from '@/core/QueryService.js'; +import { DI } from '@/di-symbols.js'; +import { EmojiRequestsEntityService } from '@/core/entities/EmojiRequestsEntityService.js'; +//import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireRolePolicy: 'canManageCustomEmojis', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + aliases: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + category: { + type: 'string', + optional: false, nullable: true, + }, + url: { + type: 'string', + optional: false, nullable: false, + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + query: { type: 'string', nullable: true, default: null }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.emojiRequestsRepository) + private emojiRequestsRepository: EmojiRequestsRepository, + + private emojiRequestsEntityService: EmojiRequestsEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const q = this.queryService.makePaginationQuery(this.emojiRequestsRepository.createQueryBuilder('emoji'), ps.sinceId, ps.untilId); + + let emojis: MiEmojiRequest[]; + + if (ps.query) { + //q.andWhere('emoji.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }); + //const emojis = await q.limit(ps.limit).getMany(); + + emojis = await q.getMany(); + const queryarry = ps.query.match(/\:([a-z0-9_]*)\:/g); + + if (queryarry) { + emojis = emojis.filter(emoji => + queryarry.includes(`:${emoji.name}:`), + ); + } else { + emojis = emojis.filter(emoji => + emoji.name.includes(ps.query!) || + emoji.aliases.some(a => a.includes(ps.query!)) || + emoji.category?.includes(ps.query!)); + } + emojis.splice(ps.limit + 1); + } else { + emojis = await q.limit(ps.limit).getMany(); + } + + return this.emojiRequestsEntityService.packDetailedMany(emojis); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts index 8ea3bb6546..3ce9881524 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts @@ -65,6 +65,7 @@ export const paramDef = { type: 'object', properties: { query: { type: 'string', nullable: true, default: null }, + draft: { type: 'boolean', nullable: true, default: null }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, @@ -87,6 +88,14 @@ export default class extends Endpoint { // eslint- let emojis: MiEmoji[]; + if (ps.draft !== null) { + if (ps.draft) { + q.andWhere('emoji.draft = TRUE'); + } else { + q.andWhere('emoji.draft = FALSE'); + } + } + if (ps.query) { //q.andWhere('emoji.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }); //const emojis = await q.limit(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-issensitive-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-issensitive-bulk.ts new file mode 100644 index 0000000000..9206e00e21 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-issensitive-bulk.ts @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireRolePolicy: 'canManageCustomEmojis', +} as const; + +export const paramDef = { + type: 'object', + properties: { + ids: { type: 'array', items: { + type: 'string', format: 'misskey:id', + } }, + isSensitive: { + type: 'boolean', + nullable: false, + description: 'Use `null` to reset the licence.', + }, + }, + required: ['ids'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private customEmojiService: CustomEmojiService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.customEmojiService.setisSensitiveBulk(ps.ids, ps.isSensitive ?? false); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-localonly-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-localonly-bulk.ts new file mode 100644 index 0000000000..662bf24534 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-localonly-bulk.ts @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireRolePolicy: 'canManageCustomEmojis', +} as const; + +export const paramDef = { + type: 'object', + properties: { + ids: { type: 'array', items: { + type: 'string', format: 'misskey:id', + } }, + localOnly: { + type: 'boolean', + nullable: false, + description: 'Use `null` to reset the licence.', + }, + }, + required: ['ids'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private customEmojiService: CustomEmojiService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.customEmojiService.setLocalOnlyBulk(ps.ids, ps.localOnly ?? false); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/speedtest.ts b/packages/backend/src/server/api/endpoints/admin/emoji/speedtest.ts new file mode 100644 index 0000000000..ca2a4599db --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/speedtest.ts @@ -0,0 +1,78 @@ +import { Injectable } from '@nestjs/common'; +import sharp from 'sharp'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; + +export const meta = { + tags: ['admin'], + requireCredential: true, + requireRolePolicy: 'canManageCustomEmojis', + kind: 'write:admin:emoji', +} as const; + +export const paramDef = { + type: 'object', + properties: { + url: { + type: 'string', + }, + }, + required: ['url'], +} as const; + +@Injectable() +export default class extends Endpoint { + constructor( + private customEmojiService: CustomEmojiService, + ) { + super(meta, paramDef, async (ps, me) => { + const response = await fetch(ps.url, { + 'headers': { + 'accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', + 'cache-control': 'no-cache', + 'pragma': 'no-cache', + 'priority': 'u=1, i', + }, + 'method': 'GET', + }); + + if (!response.ok) { + throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`); + } + const buffer = await response.arrayBuffer(); + const metadata = await sharp(buffer).metadata(); + + if (!metadata.pages) { + throw new Error('Invalid image format or no animation frames found.'); + } + + const frameRate = metadata.delay && metadata.delay.length > 0 + ? 1000 / metadata.delay[0] + : 30; // Fallback to 30 FPS if no delay information is present + + const colorsPerFrame: number[] = []; + for (let i = 0; i < metadata.pages; i++) { + const { data, info } = await sharp(buffer, { page: i }).raw().toBuffer({ resolveWithObject: true }); + const uniqueColors = new Set(); + for (let y = 0; y < info.height; y++) { + for (let x = 0; x < info.width; x++) { + const offset = (y * info.width + x) * info.channels; + const color = `${data[offset]}-${data[offset + 1]}-${data[offset + 2]}`; + uniqueColors.add(color); + } + } + colorsPerFrame.push(uniqueColors.size); + } + + const colorChanges = colorsPerFrame.map((colorCount, index, arr) => { + if (index === 0) return 0; + return Math.abs(colorCount - arr[index - 1]); + }); + + const averageColorChangePerSecond = colorChanges.reduce((sum, change) => sum + change, 0) / colorsPerFrame.length; + console.log('Average color change per second:', 10 < averageColorChangePerSecond); + return Boolean(10 < averageColorChangePerSecond); + // You can store or use this information as needed + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update-request.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update-request.ts new file mode 100644 index 0000000000..4be16bb780 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update-request.ts @@ -0,0 +1,117 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import type { DriveFilesRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireRolePolicy: 'canManageCustomEmojis', + + errors: { + noSuchEmoji: { + message: 'No such emoji.', + code: 'NO_SUCH_EMOJI', + id: '684dec9d-a8c2-4364-9aa8-456c49cb1dc8', + }, + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: '14fb9fd9-0731-4e2f-aeb9-f09e4740333d', + }, + sameNameEmojiExists: { + message: 'Emoji that have same name already exists.', + code: 'SAME_NAME_EMOJI_EXISTS', + id: '7180fe9d-1ee3-bff9-647d-fe9896d2ffb8', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + id: { type: 'string', format: 'misskey:id' }, + name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' }, + fileId: { type: 'string', format: 'misskey:id' }, + category: { + type: 'string', + nullable: true, + description: 'Use `null` to reset the category.', + }, + aliases: { type: 'array', items: { + type: 'string', + } }, + license: { type: 'string', nullable: true }, + isSensitive: { type: 'boolean' }, + localOnly: { type: 'boolean' }, + roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: { + type: 'string', + } }, + Request: { type: 'boolean' }, + }, + required: ['id', 'name', 'aliases'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private customEmojiService: CustomEmojiService, + private driveFileEntityService: DriveFileEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + let driveFile; + const isRequest = !!ps.Request; + if (ps.fileId) { + driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); + if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); + } + + const emoji = await this.customEmojiService.getEmojiRequestById(ps.id); + if (emoji != null) { + if (ps.name !== emoji.name) { + const isDuplicate = await this.customEmojiService.checkRequestDuplicate(ps.name); + if (isDuplicate) throw new ApiError(meta.errors.sameNameEmojiExists); + } + } else { + throw new ApiError(meta.errors.noSuchEmoji); + } + if (!isRequest) { + const file = await this.driveFileEntityService.getFromUrl(emoji.originalUrl); + if (file === null) throw new ApiError(meta.errors.noSuchFile); + await this.customEmojiService.add({ + driveFile: file, + name: ps.name, + category: ps.category ?? null, + aliases: ps.aliases ?? [], + host: null, + license: ps.license ?? null, + isSensitive: ps.isSensitive ?? false, + localOnly: ps.localOnly ?? false, + roleIdsThatCanBeUsedThisEmojiAsReaction: [], + }, me); + await this.customEmojiService.deleteRequest(ps.id); + } else { + await this.customEmojiService.updateRequest(ps.id, { + name: ps.name, + category: ps.category ?? null, + aliases: ps.aliases ?? [], + license: ps.license ?? null, + isSensitive: ps.isSensitive ?? false, + localOnly: ps.localOnly ?? false, + }, me); + } + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts index 90d258cad2..07b293654a 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -5,8 +5,9 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; -import type { DriveFilesRepository } from '@/models/_.js'; +import type { DriveFilesRepository , EmojisRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; @@ -33,6 +34,11 @@ export const meta = { code: 'SAME_NAME_EMOJI_EXISTS', id: '7180fe9d-1ee3-bff9-647d-fe9896d2ffb8', }, + duplicationEmojiAdd: { + message: 'This emoji is already added.', + code: 'DUPLICATION_EMOJI_ADD', + id: 'mattyaski_emoji_duplication_error', + } }, } as const; @@ -56,6 +62,7 @@ export const paramDef = { roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: { type: 'string', } }, + Request: { type: 'boolean' }, }, anyOf: [ { required: ['id'] }, @@ -68,11 +75,12 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - private customEmojiService: CustomEmojiService, + private driveFileEntityService: DriveFileEntityService, ) { super(meta, paramDef, async (ps, me) => { let driveFile; + const isRequest = !!ps.Request; if (ps.fileId) { driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); @@ -94,7 +102,7 @@ export default class extends Endpoint { // eslint- emojiId = emoji.id; } - await this.customEmojiService.update(emojiId, { + if (!isRequest) {await this.customEmojiService.update(emojiId, { driveFile, name: ps.name, category: ps.category, @@ -103,7 +111,21 @@ export default class extends Endpoint { // eslint- isSensitive: ps.isSensitive, localOnly: ps.localOnly, roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction, - }, me); + draft: false, + }, me);} else { + const file = await this.driveFileEntityService.getFromUrl(emoji.originalUrl); + if (file === null) throw new ApiError(meta.errors.noSuchFile); + await this.customEmojiService.request({ + driveFile: file, + name: ps.name, + category: ps.category ?? null, + aliases: ps.aliases ?? [], + license: ps.license ?? null, + isSensitive: ps.isSensitive ?? false, + localOnly: ps.localOnly ?? false, + }, me); + await this.customEmojiService.delete(ps.id); + } }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 81c1e38604..66ee9572f7 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -457,6 +457,25 @@ export const meta = { type: 'string', optional: false, nullable: false, }, + enableGDPRMode: { + type: 'boolean', + optional: false, nullable: false, + }, + DiscordWebhookUrl: { + type: 'string', + optional: false, nullable: true, + }, DiscordWebhookUrlWordBlock: { + type: 'string', + optional: false, nullable: true, + }, + enableProxyCheckio: { + type: 'boolean', + optional: false, nullable: false, + }, + proxyCheckioApiKey: { + type: 'string', + optional: false, nullable: true, + }, urlPreviewEnabled: { type: 'boolean', optional: false, nullable: false, @@ -481,6 +500,10 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + iconLight: { type: 'string', nullable: true }, + iconDark: { type: 'string', nullable: true }, + bannerLight: { type: 'string', nullable: true }, + bannerDark: { type: 'string', nullable: true }, }, }, } as const; @@ -531,6 +554,7 @@ export default class extends Endpoint { // eslint- turnstileSiteKey: instance.turnstileSiteKey, swPublickey: instance.swPublicKey, themeColor: instance.themeColor, + requestEmojiAllOk: instance.requestEmojiAllOk, mascotImageUrl: instance.mascotImageUrl, bannerUrl: instance.bannerUrl, serverErrorImageUrl: instance.serverErrorImageUrl, @@ -607,6 +631,13 @@ export default class extends Endpoint { // eslint- perUserHomeTimelineCacheMax: instance.perUserHomeTimelineCacheMax, perUserListTimelineCacheMax: instance.perUserListTimelineCacheMax, notesPerOneAd: instance.notesPerOneAd, + DiscordWebhookUrl: instance.DiscordWebhookUrl, + DiscordWebhookUrlWordBlock: instance.DiscordWebhookUrlWordBlock, + EmojiBotToken: instance.EmojiBotToken, + ApiBase: instance.ApiBase, + enableGDPRMode: instance.enableGDPRMode, + enableProxyCheckio: instance.enableProxyCheckio, + proxyCheckioApiKey: instance.proxyCheckioApiKey, summalyProxy: instance.urlPreviewSummaryProxyUrl, urlPreviewEnabled: instance.urlPreviewEnabled, urlPreviewTimeout: instance.urlPreviewTimeout, @@ -614,6 +645,10 @@ export default class extends Endpoint { // eslint- urlPreviewRequireContentLength: instance.urlPreviewRequireContentLength, urlPreviewUserAgent: instance.urlPreviewUserAgent, urlPreviewSummaryProxyUrl: instance.urlPreviewSummaryProxyUrl, + iconLight: instance.iconLight, + iconDark: instance.iconDark, + bannerLight: instance.bannerLight, + bannerDark: instance.bannerDark, }; }); } diff --git a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts index e2036f8c2d..bb66ae3583 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue } from '@/core/QueueModule.js'; +import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue, UserWebhookDeliverQueue } from '@/core/QueueModule.js'; export const meta = { tags: ['admin'], @@ -49,6 +49,7 @@ export default class extends Endpoint { // eslint- constructor( @Inject('queue:system') public systemQueue: SystemQueue, @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, + @Inject('queue:scheduleNotePost') public scheduleNotePostQueue: ScheduleNotePostQueue, @Inject('queue:deliver') public deliverQueue: DeliverQueue, @Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:db') public dbQueue: DbQueue, diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 7a9662677e..d28ec641e8 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -50,6 +50,7 @@ export const paramDef = { mascotImageUrl: { type: 'string', nullable: true }, bannerUrl: { type: 'string', nullable: true }, serverErrorImageUrl: { type: 'string', nullable: true }, + googleAnalyticsId: { type: 'string', nullable: true }, infoImageUrl: { type: 'string', nullable: true }, notFoundImageUrl: { type: 'string', nullable: true }, iconUrl: { type: 'string', nullable: true }, @@ -90,6 +91,9 @@ export const paramDef = { type: 'string', }, }, + summalyProxy: { type: 'string', nullable: true }, + DiscordWebhookUrl: { type: 'string', nullable: true }, + DiscordWebhookUrlWordBlock: { type: 'string', nullable: true }, deeplAuthKey: { type: 'string', nullable: true }, deeplIsPro: { type: 'boolean' }, enableEmail: { type: 'boolean' }, @@ -110,6 +114,7 @@ export const paramDef = { inquiryUrl: { type: 'string', nullable: true }, useObjectStorage: { type: 'boolean' }, objectStorageBaseUrl: { type: 'string', nullable: true }, + requestEmojiAllOk: { type: 'boolean', nullable: true }, objectStorageBucket: { type: 'string', nullable: true }, objectStoragePrefix: { type: 'string', nullable: true }, objectStorageEndpoint: { type: 'string', nullable: true }, @@ -160,6 +165,19 @@ export const paramDef = { urlPreviewRequireContentLength: { type: 'boolean' }, urlPreviewUserAgent: { type: 'string', nullable: true }, urlPreviewSummaryProxyUrl: { type: 'string', nullable: true }, + EmojiBotToken: { type: 'string', nullable: true }, + ApiBase: { type: 'string', nullable: true }, + enableGDPRMode: { type: 'boolean' }, + enableProxyCheckio: { + type: 'boolean', nullable: true, + }, + proxyCheckioApiKey: { + type: 'string', nullable: true, + }, + iconLight: { type: 'string', nullable: true }, + iconDark: { type: 'string', nullable: true }, + bannerLight: { type: 'string', nullable: true }, + bannerDark: { type: 'string', nullable: true }, }, required: [], } as const; @@ -206,7 +224,18 @@ export default class extends Endpoint { // eslint- if (ps.themeColor !== undefined) { set.themeColor = ps.themeColor; } - + if (ps.DiscordWebhookUrl !== undefined) { + set.DiscordWebhookUrl = ps.DiscordWebhookUrl; + } + if (ps.DiscordWebhookUrlWordBlock !== undefined) { + set.DiscordWebhookUrlWordBlock = ps.DiscordWebhookUrlWordBlock; + } + if (ps.EmojiBotToken !== undefined) { + set.EmojiBotToken = ps.EmojiBotToken; + } + if (ps.ApiBase !== undefined) { + set.ApiBase = ps.ApiBase; + } if (ps.mascotImageUrl !== undefined) { set.mascotImageUrl = ps.mascotImageUrl; } @@ -230,11 +259,28 @@ export default class extends Endpoint { // eslint- if (ps.serverErrorImageUrl !== undefined) { set.serverErrorImageUrl = ps.serverErrorImageUrl; } + if (ps.googleAnalyticsId !== undefined) { + set.googleAnalyticsId = ps.googleAnalyticsId; + } + if (ps.enableProxyCheckio !== undefined) { + set.enableProxyCheckio = ps.enableProxyCheckio; + } + + if (ps.proxyCheckioApiKey !== undefined) { + set.proxyCheckioApiKey = ps.proxyCheckioApiKey; + } if (ps.infoImageUrl !== undefined) { set.infoImageUrl = ps.infoImageUrl; } + if (ps.enableGDPRMode !== undefined) { + set.enableGDPRMode = ps.enableGDPRMode; + } + + if (ps.requestEmojiAllOk !== undefined) { + set.requestEmojiAllOk = ps.requestEmojiAllOk; + } if (ps.notFoundImageUrl !== undefined) { set.notFoundImageUrl = ps.notFoundImageUrl; } @@ -616,7 +662,18 @@ export default class extends Endpoint { // eslint- const value = ((ps.urlPreviewSummaryProxyUrl ?? ps.summalyProxy) ?? '').trim(); set.urlPreviewSummaryProxyUrl = value === '' ? null : value; } - + if (ps.bannerDark !== undefined) { + set.bannerDark = ps.bannerDark; + } + if (ps.bannerLight !== undefined) { + set.bannerLight = ps.bannerLight; + } + if (ps.iconDark !== undefined) { + set.iconDark = ps.iconDark; + } + if (ps.iconLight !== undefined) { + set.iconLight = ps.iconLight; + } const before = await this.metaService.fetch(true); await this.metaService.update(set); diff --git a/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts b/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts index 8f65edd951..a05a055412 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts @@ -75,7 +75,6 @@ export default class extends Endpoint { // eslint- const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId); query.andWhere(':file <@ note.fileIds', { file: [file.id] }); - const notes = await query.limit(ps.limit).getMany(); return await this.noteEntityService.packMany(notes, me, { diff --git a/packages/backend/src/server/api/endpoints/emoji-requests.ts b/packages/backend/src/server/api/endpoints/emoji-requests.ts new file mode 100644 index 0000000000..63ca88e93e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/emoji-requests.ts @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type { EmojiRequestsRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { EmojiRequestsEntityService } from '@/core/entities/EmojiRequestsEntityService.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['meta'], + + requireCredential: false, + allowGet: true, + cacheSec: 3600, + + res: { + type: 'object', + optional: false, nullable: false, + properties: { + emojis: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'EmojiRequestSimple', + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.emojiRequestsRepository) + private emojiRequestsRepository: EmojiRequestsRepository, + + private emojiRequestsEntityService: EmojiRequestsEntityService, + ) { + super(meta, paramDef, async () => { + const emojis = await this.emojiRequestsRepository.find({ + order: { + category: 'ASC', + name: 'ASC', + }, + }); + + return { + emojis: await this.emojiRequestsEntityService.packSimpleMany(emojis), + }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/get-avatar-decorations.ts b/packages/backend/src/server/api/endpoints/get-avatar-decorations.ts index 9c560aa2a2..085fcf1891 100644 --- a/packages/backend/src/server/api/endpoints/get-avatar-decorations.ts +++ b/packages/backend/src/server/api/endpoints/get-avatar-decorations.ts @@ -75,6 +75,7 @@ export default class extends Endpoint { // eslint- name: decoration.name, description: decoration.description, url: decoration.url, + category: decoration.category, roleIdsThatCanBeUsedThisDecoration: decoration.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(role => role.id === roleId)), })); }); diff --git a/packages/backend/src/server/api/endpoints/i.ts b/packages/backend/src/server/api/endpoints/i.ts index 25d9a859cb..88d666d96a 100644 --- a/packages/backend/src/server/api/endpoints/i.ts +++ b/packages/backend/src/server/api/endpoints/i.ts @@ -4,17 +4,18 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { UserProfilesRepository } from '@/models/_.js'; +import type { UserProfilesRepository, UsersRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DI } from '@/di-symbols.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { ApiError } from '../error.js'; export const meta = { tags: ['account'], requireCredential: true, - kind: "read:account", + kind: 'read:account', res: { type: 'object', @@ -43,7 +44,10 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + private notificationService: NotificationService, private userEntityService: UserEntityService, ) { super(meta, paramDef, async (ps, user, token) => { @@ -52,6 +56,7 @@ export default class extends Endpoint { // eslint- const now = new Date(); const today = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}`; + let todayGetPoints = 0; // 渡ってきている user はキャッシュされていて古い可能性があるので改めて取得 const userProfile = await this.userProfilesRepository.findOne({ where: { @@ -64,10 +69,33 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.userIsDeleted); } + function generateSecureRandomNumber(min, max) { + const range = max - min + 1; + const randomBuffer = new Uint32Array(1); + crypto.getRandomValues(randomBuffer); + const randomNumber = randomBuffer[0] / (0xFFFFFFFF + 1); // 0から1未満の浮動小数点数 + return Math.floor(randomNumber * range) + min; + } + if (!userProfile.loggedInDates.includes(today)) { + todayGetPoints = generateSecureRandomNumber(1, 5); this.userProfilesRepository.update({ userId: user.id }, { loggedInDates: [...userProfile.loggedInDates, today], }); + const user_ = await this.usersRepository.findOne({ + where: { + id: user.id, + }, + }); + if (user_ == null) { + throw new ApiError(meta.errors.userIsDeleted); + } + this.usersRepository.update( user.id, { + getPoints: user_.getPoints + todayGetPoints, + }); + this.notificationService.createNotification(user.id, 'loginbonus', { + loginbonus: todayGetPoints, + }); userProfile.loggedInDates = [...userProfile.loggedInDates, today]; } diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 74d0fb9952..6f17343d3b 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -173,6 +173,7 @@ export const paramDef = { preventAiLearning: { type: 'boolean' }, isBot: { type: 'boolean' }, isCat: { type: 'boolean' }, + isGorilla: { type: 'boolean' }, injectFeaturedNote: { type: 'boolean' }, receiveAnnouncementEmail: { type: 'boolean' }, alwaysMarkNsfw: { type: 'boolean' }, @@ -311,7 +312,12 @@ export default class extends Endpoint { // eslint- if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed; if (typeof ps.noCrawle === 'boolean') profileUpdates.noCrawle = ps.noCrawle; if (typeof ps.preventAiLearning === 'boolean') profileUpdates.preventAiLearning = ps.preventAiLearning; - if (typeof ps.isCat === 'boolean') updates.isCat = ps.isCat; + if (typeof ps.isCat === 'boolean' && !ps.isGorilla) { + updates.isCat = ps.isCat; + } + if (typeof ps.isGorilla === 'boolean' && !ps.isCat) { + updates.isGorilla = ps.isGorilla; + } if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote; if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail; if (typeof ps.alwaysMarkNsfw === 'boolean') { @@ -356,11 +362,10 @@ export default class extends Endpoint { // eslint- const [myRoles, myPolicies] = await Promise.all([this.roleService.getUserRoles(user.id), this.roleService.getUserPolicies(user.id)]); const allRoles = await this.roleService.getRoles(); const decorationIds = decorations - .filter(d => d.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(r => r.id === roleId)).length === 0 || myRoles.some(r => d.roleIdsThatCanBeUsedThisDecoration.includes(r.id))) + .filter(d => (d.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(r => r.id === roleId)).length === 0 || myRoles.some(r => d.roleIdsThatCanBeUsedThisDecoration.includes(r.id)))) .map(d => d.id); if (ps.avatarDecorations.length > myPolicies.avatarDecorationLimit) throw new ApiError(meta.errors.restrictedByRole); - updates.avatarDecorations = ps.avatarDecorations.filter(d => decorationIds.includes(d.id)).map(d => ({ id: d.id, angle: d.angle ?? 0, @@ -427,7 +432,7 @@ export default class extends Endpoint { // eslint- const newName = updates.name === undefined ? user.name : updates.name; const newDescription = profileUpdates.description === undefined ? profile.description : profileUpdates.description; - const newFields = profileUpdates.fields === undefined ? profile.fields : profileUpdates.fields; + const newFields = profileUpdates.fields ?? profile.fields; if (newName != null) { const tokens = mfm.parseSimple(newName); @@ -455,10 +460,9 @@ export default class extends Endpoint { // eslint- // ハッシュタグ更新 this.hashtagService.updateUsertags(user, tags); //#endregion - if (Object.keys(updates).length > 0) { await this.usersRepository.update(user.id, updates); - this.globalEventService.publishInternalEvent('localUserUpdated', { id: user.id }); + //this.globalEventService.publishInternalEvent('localUserUpdated', { id: user.id }); } await this.userProfilesRepository.update(user.id, { @@ -473,24 +477,23 @@ export default class extends Endpoint { // eslint- const updatedProfile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - this.cacheService.userProfileCache.set(user.id, updatedProfile); + await this.cacheService.userProfileCache.set(user.id, updatedProfile); // Publish meUpdated event this.globalEventService.publishMainStream(user.id, 'meUpdated', iObj); // 鍵垢を解除したとき、溜まっていたフォローリクエストがあるならすべて承認 if (user.isLocked && ps.isLocked === false) { - this.userFollowingService.acceptAllFollowRequests(user); + await this.userFollowingService.acceptAllFollowRequests(user); } // フォロワーにUpdateを配信 - this.accountUpdateService.publishToFollowers(user.id); + await this.accountUpdateService.publishToFollowers(user.id); const urls = updatedProfile.fields.filter(x => x.value.startsWith('https://')); for (const url of urls) { - this.verifyLink(url.value, user); + await this.verifyLink(url.value, user); } - return iObj; }); } diff --git a/packages/backend/src/server/api/endpoints/notes/any-local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/any-local-timeline.ts new file mode 100644 index 0000000000..077252da43 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/any-local-timeline.ts @@ -0,0 +1,162 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Brackets, In } from 'typeorm'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { IdService } from '@/core/IdService.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; +import { ApResolverService } from '@/core/activitypub/ApResolverService.js'; +import { getApId, isActor, isPost } from '@/core/activitypub/type.js'; +import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { DI } from '@/di-symbols.js'; +import type { NotesRepository } from '@/models/_.js'; +import { MiNote, MiUser } from '@/models/_.js'; +import { bindThis } from '@/decorators.js'; +import { MiLocalUser } from '@/models/User.js'; +import { SchemaType } from '@/misc/json-schema.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { MetaService } from '@/core/MetaService.js'; +import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; +import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; +import { QueryService } from '@/core/QueryService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['notes'], + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Note', + }, + }, + + errors: { + hostIsNull: { + message: 'Host is null', + code: 'HOST_NULL', + id: 'PRSMSK-ANY-LTL-0001', + }, + + bothWithRepliesAndWithFiles: { + message: 'Specifying both withReplies and withFiles is not supported', + code: 'BOTH_WITH_REPLIES_AND_WITH_FILES', + id: 'dd9c8400-1cb5-4eef-8a31-200c5f933793', + }, + remoteTokenIsNull: { + message: 'remoteToken is null', + code: 'REMOTE_TOKEN_NULL', + id: 'PRSMSK-ANY-LTL-0002', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + withFiles: { type: 'boolean', default: false }, + withRenotes: { type: 'boolean', default: true }, + withReplies: { type: 'boolean', default: false }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, + host: { type: 'string' }, + remoteToken: { type: 'string' }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private idService: IdService, + private fanoutTimelineEndpointService: FanoutTimelineEndpointService, + private queryService: QueryService, + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); + const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); + + if (ps.withReplies && ps.withFiles) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles); + + const timeline = await this.fanoutTimelineEndpointService.timeline({ + untilId, + sinceId, + limit: ps.limit, + allowPartial: ps.allowPartial, + me, + useDbFallback: true, + redisTimelines: [`remoteLocalTimeline:${ps.host}`], + alwaysIncludeMyNotes: true, + excludePureRenotes: !ps.withRenotes, + dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({ + untilId, + sinceId, + limit, + withFiles: ps.withFiles, + withReplies: ps.withReplies, + host: ps.host, + }, me), + }); + + return timeline; + }, + + ); + } + private async getFromDb(ps: { + sinceId: string | null, + untilId: string | null, + limit: number, + withFiles: boolean, + withReplies: boolean, + host: string, + }, me: MiLocalUser | null) { + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), + ps.sinceId, ps.untilId) + .andWhere(`(note.visibility = \'public\') AND (note.userHost = \'${ps.host}\') AND (note.channelId IS NULL)`) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + this.queryService.generateVisibilityQuery(query, me); + if (me) this.queryService.generateMutedUserQuery(query, me); + if (me) this.queryService.generateBlockedUserQuery(query, me); + if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + + if (!ps.withReplies) { + query.andWhere(new Brackets(qb => { + qb + .where('note.replyId IS NULL') // 返信ではない + .orWhere(new Brackets(qb => { + qb // 返信だけど投稿者自身への返信 + .where('note.replyId IS NOT NULL') + .andWhere('note.replyUserId = note.userId'); + })); + })); + } + + return await query.limit(ps.limit).getMany(); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index e3e0685eed..a48ff87cf9 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -7,7 +7,8 @@ import ms from 'ms'; import { In } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; import type { MiUser } from '@/models/User.js'; -import type { UsersRepository, NotesRepository, BlockingsRepository, DriveFilesRepository, ChannelsRepository } from '@/models/_.js'; +import type { UsersRepository, NotesRepository, ScheduledNotesRepository, BlockingsRepository, DriveFilesRepository, ChannelsRepository } from '@/models/_.js'; +import type { MiNoteCreateOption } from '@/types.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiNote } from '@/models/Note.js'; import type { MiChannel } from '@/models/Channel.js'; @@ -15,6 +16,9 @@ import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; +import { QueueService } from '@/core/QueueService.js'; +import { IdService } from '@/core/IdService.js'; +import { RoleService } from '@/core/RoleService.js'; import { DI } from '@/di-symbols.js'; import { isQuote, isRenote } from '@/misc/is-renote.js'; import { MetaService } from '@/core/MetaService.js'; @@ -42,9 +46,17 @@ export const meta = { properties: { createdNote: { type: 'object', - optional: false, nullable: false, + optional: false, nullable: true, ref: 'Note', }, + scheduledNoteId: { + type: 'string', + optional: true, nullable: true, + }, + scheduledNote: { + type: 'object', + optional: true, nullable: true, + }, }, }, @@ -121,6 +133,27 @@ export const meta = { id: '33510210-8452-094c-6227-4a6c05d99f00', }, + cannotCreateAlreadyExpiredSchedule: { + message: 'Schedule is already expired.', + code: 'CANNOT_CREATE_ALREADY_EXPIRED_SCHEDULE', + id: '8a9bfb90-fc7e-4878-a3e8-d97faaf5fb07', + }, + specifyScheduleDate: { + message: 'Please specify schedule date.', + code: 'PLEASE_SPECIFY_SCHEDULE_DATE', + id: 'c93a6ad6-f7e2-4156-a0c2-3d03529e5e0f', + }, + noSuchSchedule: { + message: 'No such schedule.', + code: 'NO_SUCH_SCHEDULE', + id: '44dee229-8da1-4a61-856d-e3a4bbc12032', + }, + rolePermissionDenied: { + message: 'You are not assigned to a required role.', + code: 'ROLE_PERMISSION_DENIED', + kind: 'permission', + id: '7f86f06f-7e15-4057-8561-f4b6d4ac755a', + }, containsProhibitedWords: { message: 'Cannot post because it contains prohibited words.', code: 'CONTAINS_PROHIBITED_WORDS', @@ -191,6 +224,13 @@ export const paramDef = { }, required: ['choices'], }, + schedule: { + type: 'object', + nullable: true, + properties: { + scheduledAt: { type: 'string', nullable: false }, + }, + }, }, // (re)note with text, files and poll are optional if: { @@ -207,6 +247,9 @@ export const paramDef = { poll: { type: 'null', }, + schedule:{ + type: 'null' + } }, }, then: { @@ -231,6 +274,9 @@ export default class extends Endpoint { // eslint- @Inject(DI.notesRepository) private notesRepository: NotesRepository, + @Inject(DI.scheduledNotesRepository) + private scheduledNotesRepository: ScheduledNotesRepository, + @Inject(DI.blockingsRepository) private blockingsRepository: BlockingsRepository, @@ -242,6 +288,10 @@ export default class extends Endpoint { // eslint- private noteEntityService: NoteEntityService, private noteCreateService: NoteCreateService, + + private roleService: RoleService, + private queueService: QueueService, + private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { let visibleUsers: MiUser[] = []; @@ -313,7 +363,7 @@ export default class extends Endpoint { // eslint- } } } - + let visibility = ps.visibility; let reply: MiNote | null = null; if (ps.replyId != null) { // Fetch reply @@ -364,7 +414,7 @@ export default class extends Endpoint { // eslint- // 投稿を作成 try { - const note = await this.noteCreateService.create(me, { + const note : MiNoteCreateOption = { createdAt: new Date(), files: files, poll: ps.poll ? { @@ -384,11 +434,51 @@ export default class extends Endpoint { // eslint- apMentions: ps.noExtractMentions ? [] : undefined, apHashtags: ps.noExtractHashtags ? [] : undefined, apEmojis: ps.noExtractEmojis ? [] : undefined, - }); - - return { - createdNote: await this.noteEntityService.pack(note, me), }; + + if (ps.schedule) { + // 予約投稿 + const canCreateScheduledNote = (await this.roleService.getUserPolicies(me.id)).canScheduleNote; + if (!canCreateScheduledNote) { + throw new ApiError(meta.errors.rolePermissionDenied); + } + + if (!ps.schedule.scheduledAt) { + throw new ApiError(meta.errors.specifyScheduleDate); + } + + me.token = null; + const scheduledNoteId = this.idService.gen(new Date().getTime()); + await this.scheduledNotesRepository.insert({ + id: scheduledNoteId, + note: note, + userId: me.id, + scheduledAt: new Date(ps.schedule.scheduledAt), + }); + + const delay = new Date(ps.schedule.scheduledAt).getTime() - Date.now(); + await this.queueService.ScheduleNotePostQueue.add(delay.toString(), { + scheduledNoteId, + }, { + jobId: scheduledNoteId, + delay, + removeOnComplete: true, + }); + + return { + scheduledNoteId, + scheduledNote: note, + + // ↓互換性のため(微妙) + createdNote: null, + }; + } else { + // 投稿を作成 + const createdNoteRaw = await this.noteCreateService.create(me, note); + return { + createdNote: await this.noteEntityService.pack(createdNoteRaw, me), + }; + } } catch (e) { // TODO: 他のErrorもここでキャッチしてエラーメッセージを当てるようにしたい if (e instanceof IdentifiableError) { diff --git a/packages/backend/src/server/api/endpoints/notes/featured.ts b/packages/backend/src/server/api/endpoints/notes/featured.ts index 1f6a9c73dc..fab2d6db7a 100644 --- a/packages/backend/src/server/api/endpoints/notes/featured.ts +++ b/packages/backend/src/server/api/endpoints/notes/featured.ts @@ -102,6 +102,8 @@ export default class extends Endpoint { // eslint- }); notes.sort((a, b) => a.id > b.id ? -1 : 1); + // TODO: ミュート等考慮 + return await this.noteEntityService.packMany(notes, me); }); diff --git a/packages/backend/src/server/api/endpoints/notes/reactions/delete.ts b/packages/backend/src/server/api/endpoints/notes/reactions/delete.ts index 9d1815de5a..1e14e811f4 100644 --- a/packages/backend/src/server/api/endpoints/notes/reactions/delete.ts +++ b/packages/backend/src/server/api/endpoints/notes/reactions/delete.ts @@ -42,6 +42,7 @@ export const paramDef = { type: 'object', properties: { noteId: { type: 'string', format: 'misskey:id' }, + reaction: { type: 'string' }, }, required: ['noteId'], } as const; @@ -57,7 +58,7 @@ export default class extends Endpoint { // eslint- if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); throw err; }); - await this.reactionService.delete(me, note).catch(err => { + await this.reactionService.delete(me, note, ps.reaction ?? undefined).catch(err => { if (err.id === '60527ec9-b4cb-4a88-a6bd-32d3ad26817d') throw new ApiError(meta.errors.notReacted); throw err; }); diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts b/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts new file mode 100644 index 0000000000..e108016e80 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import ms from 'ms'; +import { Inject, Injectable } from '@nestjs/common'; +import type { ScheduledNotesRepository } from '@/models/_.js'; +import { QueueService } from '@/core/QueueService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + + limit: { + duration: ms('1hour'), + max: 300, + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '490be23f-8c1f-4796-819f-94cb4f9d1630', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + scheduledNoteId: { type: 'string', format: 'misskey:id' }, + }, + required: ['scheduledNoteId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.scheduledNotesRepository) + private scheduledNotesRepository: ScheduledNotesRepository, + + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.scheduledNotesRepository.delete({ id: ps.scheduledNoteId }); + if (ps.scheduledNoteId) { + await this.queueService.ScheduleNotePostQueue.remove(ps.scheduledNoteId); + } + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/list.ts b/packages/backend/src/server/api/endpoints/notes/schedule/list.ts new file mode 100644 index 0000000000..9f89cf2a7f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/schedule/list.ts @@ -0,0 +1,75 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import type { ScheduledNotesRepository } from '@/models/_.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { QueryService } from '@/core/QueryService.js'; +import { IdService } from '@/core/IdService.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + requireRolePolicy: 'canScheduleNote', + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + }, + }, + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + }, +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.scheduledNotesRepository) + private scheduledNotesRepository: ScheduledNotesRepository, + + private idService: IdService, + private userEntityService: UserEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.scheduledNotesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere('note.userId = :userId', { userId: me.id }); + + const scheduleNotes = await query.limit(ps.limit).getMany(); + const user = await this.userEntityService.pack(me, me); + const scheduleNotesPack = scheduleNotes.map((item) => { + return { + ...item, + scheduledAt: new Date(item.scheduledAt).toISOString(), + note: { + ...item.note, + user: user, + createdAt: new Date(item.scheduledAt).toISOString(), + isSchedule: true, + id: null, + scheduledNoteId: item.id, + }, + }; + }); + + return scheduleNotesPack; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/show.ts b/packages/backend/src/server/api/endpoints/notes/show.ts index 7ec7411db9..0ba7478c9f 100644 --- a/packages/backend/src/server/api/endpoints/notes/show.ts +++ b/packages/backend/src/server/api/endpoints/notes/show.ts @@ -48,7 +48,6 @@ export default class extends Endpoint { // eslint- if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); throw err; }); - return await this.noteEntityService.pack(note, me, { detail: true, }); diff --git a/packages/backend/src/server/api/endpoints/notes/update.ts b/packages/backend/src/server/api/endpoints/notes/update.ts new file mode 100644 index 0000000000..e05a70c0e3 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/update.ts @@ -0,0 +1,177 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors, cherrypick contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import ms from 'ms'; +import { Inject, Injectable } from '@nestjs/common'; +import type { UsersRepository, NotesRepository, DriveFilesRepository, MiDriveFile } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { NoteUpdateService } from '@/core/NoteUpdateService.js'; +import { DI } from '@/di-symbols.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + requireRolePolicy: 'canEditNote', + + kind: 'write:notes', + + limit: { + duration: ms('1hour'), + max: 10, + minInterval: ms('1sec'), + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'a6584e14-6e01-4ad3-b566-851e7bf0d474', + }, + noSuchFile: { + message: 'Some files are not found.', + code: 'NO_SUCH_FILE', + id: 'b6992544-63e7-67f0-fa7f-32444b1b5306', + }, + cannotCreateAlreadyExpiredPoll: { + message: 'Poll is already expired.', + code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL', + id: '04da457d-b083-4055-9082-955525eda5a5', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + noteId: { type: 'string', format: 'misskey:id' }, + visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'], default: 'public' }, + visibleUserIds: { type: 'array', uniqueItems: true, items: { + type: 'string', format: 'misskey:id', + } }, + cw: { type: 'string', nullable: true, maxLength: 100 }, + localOnly: { type: 'boolean', default: false }, + reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null }, + noExtractMentions: { type: 'boolean', default: false }, + noExtractHashtags: { type: 'boolean', default: false }, + noExtractEmojis: { type: 'boolean', default: false }, + replyId: { type: 'string', format: 'misskey:id', nullable: true }, + renoteId: { type: 'string', format: 'misskey:id', nullable: true }, + channelId: { type: 'string', format: 'misskey:id', nullable: true }, + + // anyOf内にバリデーションを書いても最初の一つしかチェックされない + // See https://github.com/misskey-dev/misskey/pull/10082 + text: { + type: 'string', + minLength: 1, + maxLength: MAX_NOTE_TEXT_LENGTH, + nullable: false, + }, + fileIds: { + type: 'array', + uniqueItems: true, + minItems: 1, + maxItems: 16, + items: { type: 'string', format: 'misskey:id' }, + }, + mediaIds: { + type: 'array', + uniqueItems: true, + minItems: 1, + maxItems: 16, + items: { type: 'string', format: 'misskey:id' }, + }, + poll: { + type: 'object', + nullable: true, + properties: { + choices: { + type: 'array', + uniqueItems: true, + minItems: 2, + maxItems: 10, + items: { type: 'string', minLength: 1, maxLength: 50 }, + }, + multiple: { type: 'boolean' }, + expiresAt: { type: 'integer', nullable: true }, + expiredAfter: { type: 'integer', nullable: true, minimum: 1 }, + }, + required: ['choices'], + }, + disableRightClick: { type: 'boolean', default: false }, + }, + required: ['noteId', 'text', 'cw'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private getterService: GetterService, + private noteEntityService: NoteEntityService, + private noteUpdateService: NoteUpdateService, + ) { + super(meta, paramDef, async (ps, me) => { + const note = await this.getterService.getNote(ps.noteId).catch(err => { + if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw err; + }); + + if (note.userId !== me.id) { + throw new ApiError(meta.errors.noSuchNote); + } + + let files: MiDriveFile[] = []; + const fileIds = ps.fileIds ?? ps.mediaIds ?? null; + if (fileIds != null) { + files = await this.driveFilesRepository.createQueryBuilder('file') + .where('file.userId = :userId AND file.id IN (:...fileIds)', { + userId: me.id, + fileIds, + }) + .orderBy('array_position(ARRAY[:...fileIds], "id"::text)') + .setParameters({ fileIds }) + .getMany(); + + if (files.length !== fileIds.length) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + if (ps.poll) { + if (typeof ps.poll.expiresAt === 'number') { + if (ps.poll.expiresAt < Date.now()) { + throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); + } + } else if (typeof ps.poll.expiredAfter === 'number') { + ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter; + } + } + + const data = { + text: ps.text, + files: files, + cw: ps.cw, + poll: ps.poll ? { + choices: ps.poll.choices, + multiple: ps.poll.multiple ?? false, + expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, + } : undefined, + }; + + const updatedNote = await this.noteUpdateService.update(me, data, note, false); + + return { + updatedNote: await this.noteEntityService.pack(updatedNote!, me), + }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts index ad748d8071..a8be7d474e 100644 --- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts @@ -91,6 +91,9 @@ export default class extends Endpoint { // eslint- const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); const list = await this.userListsRepository.findOneBy({ + id: ps.listId, + isPublic: true, + }) ?? await this.userListsRepository.findOneBy({ id: ps.listId, userId: me.id, }); diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts index e6a52b5e2f..ff74e9d526 100644 --- a/packages/backend/src/server/api/endpoints/roles/notes.ts +++ b/packages/backend/src/server/api/endpoints/roles/notes.ts @@ -13,6 +13,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { IdService } from '@/core/IdService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { ApiError } from '../../error.js'; +import {RoleService} from "@/core/RoleService.js"; export const meta = { tags: ['role', 'notes'], @@ -63,7 +64,7 @@ export default class extends Endpoint { // eslint- @Inject(DI.rolesRepository) private rolesRepository: RolesRepository, - + private roleService: RoleService, private idService: IdService, private noteEntityService: NoteEntityService, private queryService: QueryService, @@ -72,7 +73,7 @@ export default class extends Endpoint { // eslint- super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); - + const isModerator = await this.roleService.isModerator(me); const role = await this.rolesRepository.findOneBy({ id: ps.roleId, isPublic: true, @@ -91,15 +92,21 @@ export default class extends Endpoint { // eslint- if (noteIds.length === 0) { return []; } - - const query = this.notesRepository.createQueryBuilder('note') + const query = isModerator ? this.notesRepository.createQueryBuilder('note') .where('note.id IN (:...noteIds)', { noteIds: noteIds }) - .andWhere('(note.visibility = \'public\')') .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + .leftJoinAndSelect('renote.user', 'renoteUser') : + this.notesRepository.createQueryBuilder('note') + .where('note.id IN (:...noteIds)', { noteIds: noteIds }) + .andWhere('(note.visibility = \'public\')' ) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); this.queryService.generateMutedUserQuery(query, me); @@ -107,7 +114,6 @@ export default class extends Endpoint { // eslint- const notes = await query.getMany(); notes.sort((a, b) => a.id > b.id ? -1 : 1); - return await this.noteEntityService.packMany(notes, me); }); } diff --git a/packages/backend/src/server/api/endpoints/users/lists/list-favorite.ts b/packages/backend/src/server/api/endpoints/users/lists/list-favorite.ts new file mode 100644 index 0000000000..a7d01afc86 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/list-favorite.ts @@ -0,0 +1,85 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; +import { DI } from '@/di-symbols.js'; +import type { UserListsRepository, UserListFavoritesRepository } from '@/models/_.js'; +import { UserListEntityService } from '@/core/entities/UserListEntityService.js'; +import { QueryService } from '@/core/QueryService.js'; +export const meta = { + tags: ['lists', 'account'], + + requireCredential: false, + + kind: 'read:account', + + description: 'Show all lists that the authenticated user has created.', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'UserList', + }, + }, + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'a8af4a82-0980-4cc4-a6af-8b0ffd54465e', + }, + remoteUser: { + message: 'Not allowed to load the remote user\'s list', + code: 'REMOTE_USER_NOT_ALLOWED', + id: '53858f1b-3315-4a01-81b7-db9b48d4b79a', + }, + invalidParam: { + message: 'Invalid param.', + code: 'INVALID_PARAM', + id: 'ab36de0e-29e9-48cb-9732-d82f1281620d', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, + required: [], +} as const; + +@Injectable() // eslint-disable-next-line import/no-default-export +export default class extends Endpoint { + constructor( + @Inject(DI.userListFavoritesRepository) + private userListFavoritesRepository: UserListFavoritesRepository, + @Inject(DI.userListsRepository) + private userListsRepository: UserListsRepository, + private userListEntityService: UserListEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + if (!me) { + throw new ApiError(meta.errors.noSuchUser); + } + const favorites = await this.userListFavoritesRepository.findBy({ userId: me.id }); + + if (favorites == null) { + return []; + } + const listIds = favorites.map(favorite => favorite.userListId); + const lists = await this.userListsRepository.findBy({ id: In(listIds) }); + return await Promise.all(lists.map(async list => await this.userListEntityService.pack(list))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/lists/list.ts b/packages/backend/src/server/api/endpoints/users/lists/list.ts index 36caf98077..e0a8b2d675 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/list.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/list.ts @@ -51,6 +51,7 @@ export const paramDef = { type: 'object', properties: { userId: { type: 'string', format: 'misskey:id' }, + publicAll: { type: 'boolean', nullable: false }, }, required: [], } as const; @@ -67,22 +68,29 @@ export default class extends Endpoint { private userListEntityService: UserListEntityService, ) { super(meta, paramDef, async (ps, me) => { - if (typeof ps.userId !== 'undefined') { - const user = await this.usersRepository.findOneBy({ id: ps.userId }); - if (user === null) throw new ApiError(meta.errors.noSuchUser); - if (user.host !== null) throw new ApiError(meta.errors.remoteUser); - } else if (me === null) { - throw new ApiError(meta.errors.invalidParam); + if (!ps.publicAll ) { + if (typeof ps.userId !== 'undefined') { + const user = await this.usersRepository.findOneBy({ id: ps.userId }); + if (user === null) throw new ApiError(meta.errors.noSuchUser); + if (user.host !== null) throw new ApiError(meta.errors.remoteUser); + } else if (me === null) { + throw new ApiError(meta.errors.invalidParam); + } + + const userLists = await this.userListsRepository.findBy(typeof ps.userId === 'undefined' && me !== null ? { + userId: me.id, + } : { + userId: ps.userId, + isPublic: true, + }); + + return await Promise.all(userLists.map(x => this.userListEntityService.pack(x))); + } else { + const userLists = await this.userListsRepository.findBy({ + isPublic: true, + }); + return await Promise.all(userLists.map(x => this.userListEntityService.pack(x))); } - - const userLists = await this.userListsRepository.findBy(typeof ps.userId === 'undefined' && me !== null ? { - userId: me.id, - } : { - userId: ps.userId, - isPublic: true, - }); - - return await Promise.all(userLists.map(x => this.userListEntityService.pack(x))); }); } } diff --git a/packages/backend/src/server/api/endpoints/users/relation.ts b/packages/backend/src/server/api/endpoints/users/relation.ts index f6fafd3895..86568f0910 100644 --- a/packages/backend/src/server/api/endpoints/users/relation.ts +++ b/packages/backend/src/server/api/endpoints/users/relation.ts @@ -132,9 +132,11 @@ export default class extends Endpoint { // eslint- private userEntityService: UserEntityService, ) { super(meta, paramDef, async (ps, me) => { - return Array.isArray(ps.userId) - ? await this.userEntityService.getRelations(me.id, ps.userId).then(it => [...it.values()]) - : await this.userEntityService.getRelation(me.id, ps.userId).then(it => [it]); + const ids = Array.isArray(ps.userId) ? ps.userId : [ps.userId]; + + const relations = await Promise.all(ids.map(id => this.userEntityService.getRelation(me.id, id))); + + return Array.isArray(ps.userId) ? relations : relations[0]; }); } } diff --git a/packages/backend/src/server/api/endpoints/users/report-abuse.ts b/packages/backend/src/server/api/endpoints/users/report-abuse.ts index 053e8d7b76..6b267fa85c 100644 --- a/packages/backend/src/server/api/endpoints/users/report-abuse.ts +++ b/packages/backend/src/server/api/endpoints/users/report-abuse.ts @@ -2,9 +2,18 @@ * SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-project * SPDX-License-Identifier: AGPL-3.0-only */ - -import { Injectable } from '@nestjs/common'; +import { setImmediate } from 'node:timers/promises'; +import sanitizeHtml from 'sanitize-html'; +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import type { AbuseUserReportsRepository, NotesRepository } from '@/models/_.js'; +import { IdService } from '@/core/IdService.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { MetaService } from '@/core/MetaService.js'; +import { EmailService } from '@/core/EmailService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { DI } from '@/di-symbols.js'; import { GetterService } from '@/server/api/GetterService.js'; import { RoleService } from '@/core/RoleService.js'; import { AbuseReportService } from '@/core/AbuseReportService.js'; @@ -44,6 +53,7 @@ export const paramDef = { properties: { userId: { type: 'string', format: 'misskey:id' }, comment: { type: 'string', minLength: 1, maxLength: 2048 }, + noteIds: { type: 'array', items: { type: 'string', format: 'misskey:id', maxLength: 16 } }, }, required: ['userId', 'comment'], } as const; @@ -51,8 +61,19 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.abuseUserReportsRepository) + private abuseUserReportsRepository: AbuseUserReportsRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private idService: IdService, + private metaService: MetaService, + private emailService: EmailService, private getterService: GetterService, private roleService: RoleService, + private noteEntityService: NoteEntityService, + private globalEventService: GlobalEventService, private abuseReportService: AbuseReportService, ) { super(meta, paramDef, async (ps, me) => { @@ -70,13 +91,59 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.cannotReportAdmin); } - await this.abuseReportService.report([{ + const notes = ps.noteIds ? await this.notesRepository.find({ + where: { id: In(ps.noteIds), userId: targetUser.id }, + }) : []; + + const report = await this.abuseUserReportsRepository.insert({ + id: this.idService.gen(), targetUserId: targetUser.id, targetUserHost: targetUser.host, reporterId: me.id, reporterHost: null, comment: ps.comment, - }]); + notes: (ps.noteIds && !((await this.metaService.fetch()).enableGDPRMode)) ? await this.noteEntityService.packMany(notes) : [], + noteIds: (ps.noteIds && (await this.metaService.fetch()).enableGDPRMode) ? ps.noteIds : [], + }).then(x => this.abuseUserReportsRepository.findOneByOrFail(x.identifiers[0])); + + // Publish event to moderators + setImmediate(async () => { + const moderators = await this.roleService.getModerators(); + + for (const moderator of moderators) { + this.globalEventService.publishAdminStream(moderator.id, 'newAbuseUserReport', { + id: report.id, + targetUserId: report.targetUserId, + reporterId: report.reporterId, + comment: report.comment, + notes: report.notes, + noteIds: report.noteIds ?? [], + }); + } + const meta = await this.metaService.fetch(); + if (meta.DiscordWebhookUrl) { + const data_disc = { 'username': '絵文字追加通知ちゃん', + 'content': + + '通報' + '\n' + + '通報' + report.comment + '\n' + + '通報したユーザー : ' + '@' + me.username + '\n' + + '通報されたユーザー : ' + report.targetUserId + '\n', + }; + await fetch(meta.DiscordWebhookUrl, { + 'method': 'post', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data_disc), + }); + } + if (meta.email) { + this.emailService.sendEmail(meta.email, 'New abuse report', + sanitizeHtml(ps.comment), + sanitizeHtml(ps.comment)); + } + }); }); } } diff --git a/packages/backend/src/server/api/endpoints/users/stats.ts b/packages/backend/src/server/api/endpoints/users/stats.ts new file mode 100644 index 0000000000..6ee9eba839 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/stats.ts @@ -0,0 +1,228 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { DI } from '@/di-symbols.js'; +import type { UsersRepository, NotesRepository, FollowingsRepository, DriveFilesRepository, NoteReactionsRepository, PageLikesRepository, NoteFavoritesRepository, PollVotesRepository } from '@/models/_.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['users'], + + requireCredential: false, + + description: 'Show statistics about a user.', + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '9e638e45-3b25-4ef7-8f95-07e8498f1819', + }, + }, + + res: { + type: 'object', + optional: false, nullable: false, + properties: { + notesCount: { + type: 'integer', + optional: false, nullable: false, + }, + repliesCount: { + type: 'integer', + optional: false, nullable: false, + }, + renotesCount: { + type: 'integer', + optional: false, nullable: false, + }, + repliedCount: { + type: 'integer', + optional: false, nullable: false, + }, + renotedCount: { + type: 'integer', + optional: false, nullable: false, + }, + pollVotesCount: { + type: 'integer', + optional: false, nullable: false, + }, + pollVotedCount: { + type: 'integer', + optional: false, nullable: false, + }, + localFollowingCount: { + type: 'integer', + optional: false, nullable: false, + }, + remoteFollowingCount: { + type: 'integer', + optional: false, nullable: false, + }, + localFollowersCount: { + type: 'integer', + optional: false, nullable: false, + }, + remoteFollowersCount: { + type: 'integer', + optional: false, nullable: false, + }, + followingCount: { + type: 'integer', + optional: false, nullable: false, + }, + followersCount: { + type: 'integer', + optional: false, nullable: false, + }, + sentReactionsCount: { + type: 'integer', + optional: false, nullable: false, + }, + receivedReactionsCount: { + type: 'integer', + optional: false, nullable: false, + }, + noteFavoritesCount: { + type: 'integer', + optional: false, nullable: false, + }, + pageLikesCount: { + type: 'integer', + optional: false, nullable: false, + }, + pageLikedCount: { + type: 'integer', + optional: false, nullable: false, + }, + driveFilesCount: { + type: 'integer', + optional: false, nullable: false, + }, + driveUsage: { + type: 'integer', + optional: false, nullable: false, + description: 'Drive usage in bytes', + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + @Inject(DI.noteReactionsRepository) + private noteReactionsRepository: NoteReactionsRepository, + + @Inject(DI.pageLikesRepository) + private pageLikesRepository: PageLikesRepository, + + @Inject(DI.noteFavoritesRepository) + private noteFavoritesRepository: NoteFavoritesRepository, + + @Inject(DI.pollVotesRepository) + private pollVotesRepository: PollVotesRepository, + + private driveFileEntityService: DriveFileEntityService, + ) { + super(meta, paramDef, async (ps) => { + const user = await this.usersRepository.findOneBy({ id: ps.userId }); + if (user == null) { + throw new ApiError(meta.errors.noSuchUser); + } + + const result = await awaitAll({ + notesCount: this.notesRepository.createQueryBuilder('note') + .where('note.userId = :userId', { userId: user.id }) + .getCount(), + repliesCount: this.notesRepository.createQueryBuilder('note') + .where('note.userId = :userId', { userId: user.id }) + .andWhere('note.replyId IS NOT NULL') + .getCount(), + renotesCount: this.notesRepository.createQueryBuilder('note') + .where('note.userId = :userId', { userId: user.id }) + .andWhere('note.renoteId IS NOT NULL') + .getCount(), + repliedCount: this.notesRepository.createQueryBuilder('note') + .where('note.replyUserId = :userId', { userId: user.id }) + .getCount(), + renotedCount: this.notesRepository.createQueryBuilder('note') + .where('note.renoteUserId = :userId', { userId: user.id }) + .getCount(), + pollVotesCount: this.pollVotesRepository.createQueryBuilder('vote') + .where('vote.userId = :userId', { userId: user.id }) + .getCount(), + pollVotedCount: this.pollVotesRepository.createQueryBuilder('vote') + .innerJoin('vote.note', 'note') + .where('note.userId = :userId', { userId: user.id }) + .getCount(), + localFollowingCount: this.followingsRepository.createQueryBuilder('following') + .where('following.followerId = :userId', { userId: user.id }) + .andWhere('following.followeeHost IS NULL') + .getCount(), + remoteFollowingCount: this.followingsRepository.createQueryBuilder('following') + .where('following.followerId = :userId', { userId: user.id }) + .andWhere('following.followeeHost IS NOT NULL') + .getCount(), + localFollowersCount: this.followingsRepository.createQueryBuilder('following') + .where('following.followeeId = :userId', { userId: user.id }) + .andWhere('following.followerHost IS NULL') + .getCount(), + remoteFollowersCount: this.followingsRepository.createQueryBuilder('following') + .where('following.followeeId = :userId', { userId: user.id }) + .andWhere('following.followerHost IS NOT NULL') + .getCount(), + sentReactionsCount: this.noteReactionsRepository.createQueryBuilder('reaction') + .where('reaction.userId = :userId', { userId: user.id }) + .getCount(), + receivedReactionsCount: this.noteReactionsRepository.createQueryBuilder('reaction') + .innerJoin('reaction.note', 'note') + .where('note.userId = :userId', { userId: user.id }) + .getCount(), + noteFavoritesCount: this.noteFavoritesRepository.createQueryBuilder('favorite') + .where('favorite.userId = :userId', { userId: user.id }) + .getCount(), + pageLikesCount: this.pageLikesRepository.createQueryBuilder('like') + .where('like.userId = :userId', { userId: user.id }) + .getCount(), + pageLikedCount: this.pageLikesRepository.createQueryBuilder('like') + .innerJoin('like.page', 'page') + .where('page.userId = :userId', { userId: user.id }) + .getCount(), + driveFilesCount: this.driveFilesRepository.createQueryBuilder('file') + .where('file.userId = :userId', { userId: user.id }) + .getCount(), + driveUsage: this.driveFileEntityService.calcDriveUsageOf(user), + }); + + return { + ...result, + followingCount: result.localFollowingCount + result.remoteFollowingCount, + followersCount: result.localFollowersCount + result.remoteFollowersCount, + }; + }); + } +} diff --git a/packages/backend/src/server/api/stream/channels/hybrid-all-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-all-timeline.ts new file mode 100644 index 0000000000..bca26eb36a --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/hybrid-all-timeline.ts @@ -0,0 +1,119 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { checkWordMute } from '@/misc/check-word-mute.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { MetaService } from '@/core/MetaService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { bindThis } from '@/decorators.js'; +import { RoleService } from '@/core/RoleService.js'; +import Channel from '../channel.js'; + +class HybridAllTimelineChannel extends Channel { + public readonly chName = 'hybridAllTimeline'; + public static shouldShare = true; + public static requireCredential = false; + private withReplies: boolean; + + constructor( + private metaService: MetaService, + private roleService: RoleService, + private noteEntityService: NoteEntityService, + + id: string, + connection: Channel['connection'], + ) { + super(id, connection); + //this.onNote = this.onNote.bind(this); + } + + @bindThis + public async init(params: any) { + const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); + if (!policies.ltlAvailable) return; + + this.withReplies = params.withReplies as boolean; + + // Subscribe events + this.subscriber.on('notesStream', this.onNote); + } + + @bindThis + private async onNote(note: Packed<'Note'>) { + if (note.user.host !== null) return; + if (note.visibility === "public") return; + if (note.channelId != null && !this.followingChannels.has(note.channelId)) return; + + // リプライなら再pack + if (note.replyId != null) { + note.reply = await this.noteEntityService.pack(note.replyId, this.user, { + detail: true, + }); + } + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await this.noteEntityService.pack(note.renoteId, this.user, { + detail: true, + }); + } + + // 関係ない返信は除外 + if (note.reply && this.user && !this.withReplies) { + const reply = note.reply; + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return; + } + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; + + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる + if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; + + this.connection.cacheNote(note); + + this.send('note', note); + } + + @bindThis + public dispose() { + // Unsubscribe events + this.subscriber.off('notesStream', this.onNote); + } +} + +@Injectable() +export class HybridAllTimelineChannelService { + public readonly shouldShare = HybridAllTimelineChannel.shouldShare; + public readonly requireCredential = HybridAllTimelineChannel.requireCredential; + + constructor( + private metaService: MetaService, + private roleService: RoleService, + private noteEntityService: NoteEntityService, + ) { + } + + @bindThis + public create(id: string, connection: Channel['connection']): HybridAllTimelineChannel { + return new HybridAllTimelineChannel( + this.metaService, + this.roleService, + this.noteEntityService, + id, + connection, + ); + } +} diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 7faf4edbc2..a6dcc2f9b2 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -31,7 +31,9 @@ import type { EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, + ScheduleNotePostQueue, SystemQueue, + WebhookDeliverQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue, } from '@/core/QueueModule.js'; @@ -62,7 +64,6 @@ const clientAssets = `${_dirname}/../../../../frontend/assets/`; const assets = `${_dirname}/../../../../../built/_frontend_dist_/`; const swAssets = `${_dirname}/../../../../../built/_sw_dist_/`; const viteOut = `${_dirname}/../../../../../built/_vite_/`; -const tarball = `${_dirname}/../../../../../built/tarball/`; @Injectable() export class ClientServerService { @@ -116,6 +117,7 @@ export class ClientServerService { @Inject('queue:system') public systemQueue: SystemQueue, @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, + @Inject('queue:scheduleNotePost') public scheduleNotePostQueue: ScheduleNotePostQueue, @Inject('queue:deliver') public deliverQueue: DeliverQueue, @Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:db') public dbQueue: DbQueue, @@ -192,6 +194,7 @@ export class ClientServerService { appleTouchIcon: meta.app512IconUrl, themeColor: meta.themeColor, serverErrorImageUrl: meta.serverErrorImageUrl ?? 'https://xn--931a.moe/assets/error.jpg', + googleAnalyticsId: meta.googleAnalyticsId ?? null, infoImageUrl: meta.infoImageUrl ?? 'https://xn--931a.moe/assets/info.jpg', notFoundImageUrl: meta.notFoundImageUrl ?? 'https://xn--931a.moe/assets/not-found.jpg', instanceUrl: this.config.url, @@ -245,6 +248,7 @@ export class ClientServerService { queues: [ this.systemQueue, this.endedPollNotificationQueue, + this.scheduleNotePostQueue, this.deliverQueue, this.inboxQueue, this.dbQueue, @@ -322,18 +326,6 @@ export class ClientServerService { decorateReply: false, }); - fastify.register((fastify, options, done) => { - fastify.register(fastifyStatic, { - root: tarball, - prefix: '/tarball/', - maxAge: ms('30 days'), - immutable: true, - decorateReply: false, - }); - fastify.addHook('onRequest', handleRequestRedirectToOmitSearch); - done(); - }); - fastify.get('/favicon.ico', async (request, reply) => { return reply.sendFile('/favicon.ico', staticAssets); }); diff --git a/packages/backend/src/server/web/style.css b/packages/backend/src/server/web/style.css index fd25858425..4e3814bfa3 100644 --- a/packages/backend/src/server/web/style.css +++ b/packages/backend/src/server/web/style.css @@ -45,7 +45,13 @@ html { width: 28px; height: 28px; transform: translateY(70px); - color: var(--accent); + background: linear-gradient(270deg, #c06161, #c0a567, #b6ba69, #81bc72, #63c3be, #8bacd6, #9f8bd6, #d18bd6, #d883b4); + background-size: 1800% 1800%; + -webkit-animation: AnimationDark 44s cubic-bezier(0, 0.25, 0.25, 1) infinite; + -moz-animation: AnimationDark 44s cubic-bezier(0, 0.25, 0.25, 1) infinite; + animation: AnimationDark 44s cubic-bezier(0, 0.25, 0.25, 1) infinite; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; } #splashSpinner > .spinner { position: absolute; @@ -74,3 +80,68 @@ html { transform: rotate(360deg); } } +@-webkit-keyframes AnimationLight { + 0% { + background-position: 0% 50% + } + 50% { + background-position: 100% 50% + } + 100% { + background-position: 0% 50% + } +} +@-moz-keyframes AnimationLight { + 0% { + background-position: 0% 50% + } + 50% { + background-position: 100% 50% + } + 100% { + background-position: 0% 50% + } +} @keyframes AnimationLight { + 0% { + background-position: 0% 50% + } + 50% { + background-position: 100% 50% + } + 100% { + background-position: 0% 50% + } + } +@-webkit-keyframes AnimationDark { + 0% { + background-position: 0% 50% + } + 50% { + background-position: 100% 50% + } + 100% { + background-position: 0% 50% + } +} +@-moz-keyframes AnimationDark { + 0% { + background-position: 0% 50% + } + 50% { + background-position: 100% 50% + } + 100% { + background-position: 0% 50% + } +} +@keyframes AnimationDark { + 0% { + background-position: 0% 50% + } + 50% { + background-position: 100% 50% + } + 100% { + background-position: 0% 50% + } +} diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug index 57eed8d59f..a17ecc2a1d 100644 --- a/packages/backend/src/server/web/views/base.pug +++ b/packages/backend/src/server/web/views/base.pug @@ -40,6 +40,14 @@ html link(rel='prefetch' href=serverErrorImageUrl) link(rel='prefetch' href=infoImageUrl) link(rel='prefetch' href=notFoundImageUrl) + if googleAnalyticsId + script(async src='https://www.googletagmanager.com/gtag/js?id='+ googleAnalyticsId) + script. + window.dataLayer = window.dataLayer || []; + function gtag(){dataLayer.push(arguments);} + gtag('js', new Date()); + gtag('config', '#{googleAnalyticsId}'); + //- https://github.com/misskey-dev/misskey/issues/9842 link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v3.3.0') link(rel='modulepreload' href=`/vite/${clientEntry.file}`) diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 81407789b4..eee86aa9c5 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -3,6 +3,14 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import type { MiDriveFile } from '@/models/DriveFile.js'; +import type { IPoll } from '@/models/Poll.js'; +import type { MiChannel } from '@/models/Channel.js'; +import type { MiApp } from '@/models/App.js'; +import type { MiUser } from '@/models/User.js'; +import type { MiNote } from '@/models/Note.js'; +import type { MiScheduledNote } from '@/models/ScheduledNote.js'; + /** * note - 通知オンにしているユーザーが投稿した * follow - フォローされた @@ -34,6 +42,7 @@ export const notificationTypes = [ 'achievementEarned', 'app', 'test', + 'loginbonus', ] as const; export const groupedNotificationTypes = [ @@ -57,6 +66,7 @@ export const moderationLogTypes = [ 'unsuspend', 'updateUserNote', 'addCustomEmoji', + 'requestCustomEmoji', 'updateCustomEmoji', 'deleteCustomEmoji', 'assignRole', @@ -124,6 +134,10 @@ export type ModerationLogPayloads = { emojiId: string; emoji: any; }; + requestCustomEmoji: { + emojiId: string; + emoji: any; + }; updateCustomEmoji: { emojiId: string; before: any; @@ -316,6 +330,36 @@ export type ModerationLogPayloads = { }; }; +export type MiMinimumUser = { + id: MiUser['id']; + host: MiUser['host']; + username: MiUser['username']; + uri: MiUser['uri']; +}; + +export type MiNoteCreateOption = { + createdAt?: Date | null; + name?: string | null; + text?: string | null; + reply?: MiNote | null; + renote?: MiNote | null; + files?: MiDriveFile[] | null; + poll?: IPoll | null; + schedule?: MiScheduledNote | null; + localOnly?: boolean | null; + reactionAcceptance?: MiNote['reactionAcceptance']; + cw?: string | null; + visibility?: string; + visibleUsers?: MiMinimumUser[] | null; + channel?: MiChannel | null; + apMentions?: MiMinimumUser[] | null; + apHashtags?: string[] | null; + apEmojis?: string[] | null; + uri?: string | null; + url?: string | null; + app?: MiApp | null; +}; + export type Serialized = { [K in keyof T]: T[K] extends Date diff --git a/packages/backend/test/unit/entities/UserEntityService.ts b/packages/backend/test/unit/entities/UserEntityService.ts index 0b44e9bbb0..e69de29bb2 100644 --- a/packages/backend/test/unit/entities/UserEntityService.ts +++ b/packages/backend/test/unit/entities/UserEntityService.ts @@ -1,528 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Test, TestingModule } from '@nestjs/testing'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { GlobalModule } from '@/GlobalModule.js'; -import { CoreModule } from '@/core/CoreModule.js'; -import type { MiUser } from '@/models/User.js'; -import { secureRndstr } from '@/misc/secure-rndstr.js'; -import { genAidx } from '@/misc/id/aidx.js'; -import { - BlockingsRepository, - FollowingsRepository, FollowRequestsRepository, - MiUserProfile, MutingsRepository, RenoteMutingsRepository, - UserMemoRepository, - UserProfilesRepository, - UsersRepository, -} from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; -import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { PageEntityService } from '@/core/entities/PageEntityService.js'; -import { CustomEmojiService } from '@/core/CustomEmojiService.js'; -import { AnnouncementService } from '@/core/AnnouncementService.js'; -import { RoleService } from '@/core/RoleService.js'; -import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; -import { IdService } from '@/core/IdService.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; -import { MetaService } from '@/core/MetaService.js'; -import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; -import { CacheService } from '@/core/CacheService.js'; -import { ApResolverService } from '@/core/activitypub/ApResolverService.js'; -import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js'; -import { ApImageService } from '@/core/activitypub/models/ApImageService.js'; -import { ApMfmService } from '@/core/activitypub/ApMfmService.js'; -import { MfmService } from '@/core/MfmService.js'; -import { HashtagService } from '@/core/HashtagService.js'; -import UsersChart from '@/core/chart/charts/users.js'; -import { ChartLoggerService } from '@/core/chart/ChartLoggerService.js'; -import InstanceChart from '@/core/chart/charts/instance.js'; -import { ApLoggerService } from '@/core/activitypub/ApLoggerService.js'; -import { AccountMoveService } from '@/core/AccountMoveService.js'; -import { ReactionService } from '@/core/ReactionService.js'; -import { NotificationService } from '@/core/NotificationService.js'; - -process.env.NODE_ENV = 'test'; - -describe('UserEntityService', () => { - describe('pack/packMany', () => { - let app: TestingModule; - let service: UserEntityService; - let usersRepository: UsersRepository; - let userProfileRepository: UserProfilesRepository; - let userMemosRepository: UserMemoRepository; - let followingRepository: FollowingsRepository; - let followingRequestRepository: FollowRequestsRepository; - let blockingRepository: BlockingsRepository; - let mutingRepository: MutingsRepository; - let renoteMutingsRepository: RenoteMutingsRepository; - - async function createUser(userData: Partial = {}, profileData: Partial = {}) { - const un = secureRndstr(16); - const user = await usersRepository - .insert({ - ...userData, - id: genAidx(Date.now()), - username: un, - usernameLower: un, - }) - .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); - - await userProfileRepository.insert({ - ...profileData, - userId: user.id, - }); - - return user; - } - - async function memo(writer: MiUser, target: MiUser, memo: string) { - await userMemosRepository.insert({ - id: genAidx(Date.now()), - userId: writer.id, - targetUserId: target.id, - memo, - }); - } - - async function follow(follower: MiUser, followee: MiUser) { - await followingRepository.insert({ - id: genAidx(Date.now()), - followerId: follower.id, - followeeId: followee.id, - }); - } - - async function requestFollow(requester: MiUser, requestee: MiUser) { - await followingRequestRepository.insert({ - id: genAidx(Date.now()), - followerId: requester.id, - followeeId: requestee.id, - }); - } - - async function block(blocker: MiUser, blockee: MiUser) { - await blockingRepository.insert({ - id: genAidx(Date.now()), - blockerId: blocker.id, - blockeeId: blockee.id, - }); - } - - async function mute(mutant: MiUser, mutee: MiUser) { - await mutingRepository.insert({ - id: genAidx(Date.now()), - muterId: mutant.id, - muteeId: mutee.id, - }); - } - - async function muteRenote(mutant: MiUser, mutee: MiUser) { - await renoteMutingsRepository.insert({ - id: genAidx(Date.now()), - muterId: mutant.id, - muteeId: mutee.id, - }); - } - - function randomIntRange(weight = 10) { - return [...Array(Math.floor(Math.random() * weight))].map((it, idx) => idx); - } - - beforeAll(async () => { - const services = [ - UserEntityService, - ApPersonService, - NoteEntityService, - PageEntityService, - CustomEmojiService, - AnnouncementService, - RoleService, - FederatedInstanceService, - IdService, - AvatarDecorationService, - UtilityService, - EmojiEntityService, - ModerationLogService, - GlobalEventService, - DriveFileEntityService, - MetaService, - FetchInstanceMetadataService, - CacheService, - ApResolverService, - ApNoteService, - ApImageService, - ApMfmService, - MfmService, - HashtagService, - UsersChart, - ChartLoggerService, - InstanceChart, - ApLoggerService, - AccountMoveService, - ReactionService, - NotificationService, - ]; - - app = await Test.createTestingModule({ - imports: [GlobalModule, CoreModule], - providers: [ - ...services, - ...services.map(x => ({ provide: x.name, useExisting: x })), - ], - }).compile(); - await app.init(); - app.enableShutdownHooks(); - - service = app.get(UserEntityService); - usersRepository = app.get(DI.usersRepository); - userProfileRepository = app.get(DI.userProfilesRepository); - userMemosRepository = app.get(DI.userMemosRepository); - followingRepository = app.get(DI.followingsRepository); - followingRequestRepository = app.get(DI.followRequestsRepository); - blockingRepository = app.get(DI.blockingsRepository); - mutingRepository = app.get(DI.mutingsRepository); - renoteMutingsRepository = app.get(DI.renoteMutingsRepository); - }); - - afterAll(async () => { - await app.close(); - }); - - test('UserLite', async() => { - const me = await createUser(); - const who = await createUser(); - - await memo(me, who, 'memo'); - - const actual = await service.pack(who, me, { schema: 'UserLite' }) as any; - // no detail - expect(actual.memo).toBeUndefined(); - // no detail and me - expect(actual.birthday).toBeUndefined(); - // no detail and me - expect(actual.achievements).toBeUndefined(); - }); - - test('UserDetailedNotMe', async() => { - const me = await createUser(); - const who = await createUser({}, { birthday: '2000-01-01' }); - - await memo(me, who, 'memo'); - - const actual = await service.pack(who, me, { schema: 'UserDetailedNotMe' }) as any; - // is detail - expect(actual.memo).toBe('memo'); - // is detail - expect(actual.birthday).toBe('2000-01-01'); - // no detail and me - expect(actual.achievements).toBeUndefined(); - }); - - test('MeDetailed', async() => { - const achievements = [{ name: 'achievement', unlockedAt: new Date().getTime() }]; - const me = await createUser({}, { - birthday: '2000-01-01', - achievements: achievements, - }); - await memo(me, me, 'memo'); - - const actual = await service.pack(me, me, { schema: 'MeDetailed' }) as any; - // is detail - expect(actual.memo).toBe('memo'); - // is detail - expect(actual.birthday).toBe('2000-01-01'); - // is detail and me - expect(actual.achievements).toEqual(achievements); - }); - - describe('packManyによるpreloadがある時、preloadが無い時とpackの結果が同じになるか見たい', () => { - test('no-preload', async() => { - const me = await createUser(); - // meがフォローしてる人たち - const followeeMe = await Promise.all(randomIntRange().map(() => createUser())); - for (const who of followeeMe) { - await follow(me, who); - const actual = await service.pack(who, me, { schema: 'UserDetailed' }) as any; - expect(actual.isFollowing).toBe(true); - expect(actual.isFollowed).toBe(false); - expect(actual.hasPendingFollowRequestFromYou).toBe(false); - expect(actual.hasPendingFollowRequestToYou).toBe(false); - expect(actual.isBlocking).toBe(false); - expect(actual.isBlocked).toBe(false); - expect(actual.isMuted).toBe(false); - expect(actual.isRenoteMuted).toBe(false); - } - - // meをフォローしてる人たち - const followerMe = await Promise.all(randomIntRange().map(() => createUser())); - for (const who of followerMe) { - await follow(who, me); - const actual = await service.pack(who, me, { schema: 'UserDetailed' }) as any; - expect(actual.isFollowing).toBe(false); - expect(actual.isFollowed).toBe(true); - expect(actual.hasPendingFollowRequestFromYou).toBe(false); - expect(actual.hasPendingFollowRequestToYou).toBe(false); - expect(actual.isBlocking).toBe(false); - expect(actual.isBlocked).toBe(false); - expect(actual.isMuted).toBe(false); - expect(actual.isRenoteMuted).toBe(false); - } - - // meがフォローリクエストを送った人たち - const requestsFromYou = await Promise.all(randomIntRange().map(() => createUser())); - for (const who of requestsFromYou) { - await requestFollow(me, who); - const actual = await service.pack(who, me, { schema: 'UserDetailed' }) as any; - expect(actual.isFollowing).toBe(false); - expect(actual.isFollowed).toBe(false); - expect(actual.hasPendingFollowRequestFromYou).toBe(true); - expect(actual.hasPendingFollowRequestToYou).toBe(false); - expect(actual.isBlocking).toBe(false); - expect(actual.isBlocked).toBe(false); - expect(actual.isMuted).toBe(false); - expect(actual.isRenoteMuted).toBe(false); - } - - // meにフォローリクエストを送った人たち - const requestsToYou = await Promise.all(randomIntRange().map(() => createUser())); - for (const who of requestsToYou) { - await requestFollow(who, me); - const actual = await service.pack(who, me, { schema: 'UserDetailed' }) as any; - expect(actual.isFollowing).toBe(false); - expect(actual.isFollowed).toBe(false); - expect(actual.hasPendingFollowRequestFromYou).toBe(false); - expect(actual.hasPendingFollowRequestToYou).toBe(true); - expect(actual.isBlocking).toBe(false); - expect(actual.isBlocked).toBe(false); - expect(actual.isMuted).toBe(false); - expect(actual.isRenoteMuted).toBe(false); - } - - // meがブロックしてる人たち - const blockingYou = await Promise.all(randomIntRange().map(() => createUser())); - for (const who of blockingYou) { - await block(me, who); - const actual = await service.pack(who, me, { schema: 'UserDetailed' }) as any; - expect(actual.isFollowing).toBe(false); - expect(actual.isFollowed).toBe(false); - expect(actual.hasPendingFollowRequestFromYou).toBe(false); - expect(actual.hasPendingFollowRequestToYou).toBe(false); - expect(actual.isBlocking).toBe(true); - expect(actual.isBlocked).toBe(false); - expect(actual.isMuted).toBe(false); - expect(actual.isRenoteMuted).toBe(false); - } - - // meをブロックしてる人たち - const blockingMe = await Promise.all(randomIntRange().map(() => createUser())); - for (const who of blockingMe) { - await block(who, me); - const actual = await service.pack(who, me, { schema: 'UserDetailed' }) as any; - expect(actual.isFollowing).toBe(false); - expect(actual.isFollowed).toBe(false); - expect(actual.hasPendingFollowRequestFromYou).toBe(false); - expect(actual.hasPendingFollowRequestToYou).toBe(false); - expect(actual.isBlocking).toBe(false); - expect(actual.isBlocked).toBe(true); - expect(actual.isMuted).toBe(false); - expect(actual.isRenoteMuted).toBe(false); - } - - // meがミュートしてる人たち - const muters = await Promise.all(randomIntRange().map(() => createUser())); - for (const who of muters) { - await mute(me, who); - const actual = await service.pack(who, me, { schema: 'UserDetailed' }) as any; - expect(actual.isFollowing).toBe(false); - expect(actual.isFollowed).toBe(false); - expect(actual.hasPendingFollowRequestFromYou).toBe(false); - expect(actual.hasPendingFollowRequestToYou).toBe(false); - expect(actual.isBlocking).toBe(false); - expect(actual.isBlocked).toBe(false); - expect(actual.isMuted).toBe(true); - expect(actual.isRenoteMuted).toBe(false); - } - - // meがリノートミュートしてる人たち - const renoteMuters = await Promise.all(randomIntRange().map(() => createUser())); - for (const who of renoteMuters) { - await muteRenote(me, who); - const actual = await service.pack(who, me, { schema: 'UserDetailed' }) as any; - expect(actual.isFollowing).toBe(false); - expect(actual.isFollowed).toBe(false); - expect(actual.hasPendingFollowRequestFromYou).toBe(false); - expect(actual.hasPendingFollowRequestToYou).toBe(false); - expect(actual.isBlocking).toBe(false); - expect(actual.isBlocked).toBe(false); - expect(actual.isMuted).toBe(false); - expect(actual.isRenoteMuted).toBe(true); - } - }); - - test('preload', async() => { - const me = await createUser(); - - { - // meがフォローしてる人たち - const followeeMe = await Promise.all(randomIntRange().map(() => createUser())); - for (const who of followeeMe) { - await follow(me, who); - } - const actualList = await service.packMany(followeeMe, me, { schema: 'UserDetailed' }) as any; - for (const actual of actualList) { - expect(actual.isFollowing).toBe(true); - expect(actual.isFollowed).toBe(false); - expect(actual.hasPendingFollowRequestFromYou).toBe(false); - expect(actual.hasPendingFollowRequestToYou).toBe(false); - expect(actual.isBlocking).toBe(false); - expect(actual.isBlocked).toBe(false); - expect(actual.isMuted).toBe(false); - expect(actual.isRenoteMuted).toBe(false); - } - } - - { - // meをフォローしてる人たち - const followerMe = await Promise.all(randomIntRange().map(() => createUser())); - for (const who of followerMe) { - await follow(who, me); - } - const actualList = await service.packMany(followerMe, me, { schema: 'UserDetailed' }) as any; - for (const actual of actualList) { - expect(actual.isFollowing).toBe(false); - expect(actual.isFollowed).toBe(true); - expect(actual.hasPendingFollowRequestFromYou).toBe(false); - expect(actual.hasPendingFollowRequestToYou).toBe(false); - expect(actual.isBlocking).toBe(false); - expect(actual.isBlocked).toBe(false); - expect(actual.isMuted).toBe(false); - expect(actual.isRenoteMuted).toBe(false); - } - } - - { - // meがフォローリクエストを送った人たち - const requestsFromYou = await Promise.all(randomIntRange().map(() => createUser())); - for (const who of requestsFromYou) { - await requestFollow(me, who); - } - const actualList = await service.packMany(requestsFromYou, me, { schema: 'UserDetailed' }) as any; - for (const actual of actualList) { - expect(actual.isFollowing).toBe(false); - expect(actual.isFollowed).toBe(false); - expect(actual.hasPendingFollowRequestFromYou).toBe(true); - expect(actual.hasPendingFollowRequestToYou).toBe(false); - expect(actual.isBlocking).toBe(false); - expect(actual.isBlocked).toBe(false); - expect(actual.isMuted).toBe(false); - expect(actual.isRenoteMuted).toBe(false); - } - } - - { - // meにフォローリクエストを送った人たち - const requestsToYou = await Promise.all(randomIntRange().map(() => createUser())); - for (const who of requestsToYou) { - await requestFollow(who, me); - } - const actualList = await service.packMany(requestsToYou, me, { schema: 'UserDetailed' }) as any; - for (const actual of actualList) { - expect(actual.isFollowing).toBe(false); - expect(actual.isFollowed).toBe(false); - expect(actual.hasPendingFollowRequestFromYou).toBe(false); - expect(actual.hasPendingFollowRequestToYou).toBe(true); - expect(actual.isBlocking).toBe(false); - expect(actual.isBlocked).toBe(false); - expect(actual.isMuted).toBe(false); - expect(actual.isRenoteMuted).toBe(false); - } - } - - { - // meがブロックしてる人たち - const blockingYou = await Promise.all(randomIntRange().map(() => createUser())); - for (const who of blockingYou) { - await block(me, who); - } - const actualList = await service.packMany(blockingYou, me, { schema: 'UserDetailed' }) as any; - for (const actual of actualList) { - expect(actual.isFollowing).toBe(false); - expect(actual.isFollowed).toBe(false); - expect(actual.hasPendingFollowRequestFromYou).toBe(false); - expect(actual.hasPendingFollowRequestToYou).toBe(false); - expect(actual.isBlocking).toBe(true); - expect(actual.isBlocked).toBe(false); - expect(actual.isMuted).toBe(false); - expect(actual.isRenoteMuted).toBe(false); - } - } - - { - // meをブロックしてる人たち - const blockingMe = await Promise.all(randomIntRange().map(() => createUser())); - for (const who of blockingMe) { - await block(who, me); - } - const actualList = await service.packMany(blockingMe, me, { schema: 'UserDetailed' }) as any; - for (const actual of actualList) { - expect(actual.isFollowing).toBe(false); - expect(actual.isFollowed).toBe(false); - expect(actual.hasPendingFollowRequestFromYou).toBe(false); - expect(actual.hasPendingFollowRequestToYou).toBe(false); - expect(actual.isBlocking).toBe(false); - expect(actual.isBlocked).toBe(true); - expect(actual.isMuted).toBe(false); - expect(actual.isRenoteMuted).toBe(false); - } - } - - { - // meがミュートしてる人たち - const muters = await Promise.all(randomIntRange().map(() => createUser())); - for (const who of muters) { - await mute(me, who); - } - const actualList = await service.packMany(muters, me, { schema: 'UserDetailed' }) as any; - for (const actual of actualList) { - expect(actual.isFollowing).toBe(false); - expect(actual.isFollowed).toBe(false); - expect(actual.hasPendingFollowRequestFromYou).toBe(false); - expect(actual.hasPendingFollowRequestToYou).toBe(false); - expect(actual.isBlocking).toBe(false); - expect(actual.isBlocked).toBe(false); - expect(actual.isMuted).toBe(true); - expect(actual.isRenoteMuted).toBe(false); - } - } - - { - // meがリノートミュートしてる人たち - const renoteMuters = await Promise.all(randomIntRange().map(() => createUser())); - for (const who of renoteMuters) { - await muteRenote(me, who); - } - const actualList = await service.packMany(renoteMuters, me, { schema: 'UserDetailed' }) as any; - for (const actual of actualList) { - expect(actual.isFollowing).toBe(false); - expect(actual.isFollowed).toBe(false); - expect(actual.hasPendingFollowRequestFromYou).toBe(false); - expect(actual.hasPendingFollowRequestToYou).toBe(false); - expect(actual.isBlocking).toBe(false); - expect(actual.isBlocked).toBe(false); - expect(actual.isMuted).toBe(false); - expect(actual.isRenoteMuted).toBe(true); - } - } - }); - }); - }); -}); diff --git a/packages/frontend/package.json b/packages/frontend/package.json index a63d97658b..a8ab11b045 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -20,6 +20,7 @@ "@discordapp/twemoji": "15.0.3", "@github/webauthn-json": "2.1.1", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", + "@meersagor/wavesurfer-vue": "^0.1.0", "@misskey-dev/browser-image-resizer": "2024.1.0", "@rollup/plugin-json": "6.1.0", "@rollup/plugin-replace": "5.0.5", @@ -73,7 +74,8 @@ "v-code-diff": "1.11.0", "vite": "5.2.11", "vue": "3.4.26", - "vuedraggable": "next" + "vuedraggable": "next", + "wavesurfer.js": "^7.7.14" }, "devDependencies": { "@misskey-dev/eslint-plugin": "1.0.0", diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index 563a42276c..b118a596b6 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -28,6 +28,7 @@ export async function mainBoot() { !$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) : ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) : ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) : + ui === 'twilike' ? defineAsyncComponent(() => import('@/ui/twilike.vue')) : defineAsyncComponent(() => import('@/ui/universal.vue')), )); diff --git a/packages/frontend/src/cache.ts b/packages/frontend/src/cache.ts index 96f1b362c0..1e6fb11e15 100644 --- a/packages/frontend/src/cache.ts +++ b/packages/frontend/src/cache.ts @@ -12,3 +12,6 @@ export const rolesCache = new Cache(1000 * 60 * 30, () => misskeyApi('admin/role export const userListsCache = new Cache(1000 * 60 * 30, () => misskeyApi('users/lists/list')); export const antennasCache = new Cache(1000 * 60 * 30, () => misskeyApi('antennas/list')); export const favoritedChannelsCache = new Cache(1000 * 60 * 30, () => misskeyApi('channels/my-favorites', { limit: 100 })); +export const userFavoriteListsCache = new Cache(1000 * 60 * 30, () => misskeyApi('users/lists/list-favorite')); +export const userChannelsCache = new Cache(1000 * 60 * 30, () => misskeyApi('channels/owned')); +export const userChannelFollowingsCache = new Cache(1000 * 60 * 30, () => misskeyApi('channels/followed')); diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue index fb72cf2388..8ba812dce6 100644 --- a/packages/frontend/src/components/MkAbuseReport.vue +++ b/packages/frontend/src/components/MkAbuseReport.vue @@ -4,13 +4,13 @@ SPDX-License-Identifier: AGPL-3.0-only --> @@ -37,23 +63,45 @@ import MkTextarea from '@/components/MkTextarea.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; +import MkPagination from '@/components/MkPagination.vue'; +import MkNoteSimple from '@/components/MkNoteSimple.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; const props = defineProps<{ user: Misskey.entities.UserDetailed; initialComment?: string; + initialNoteId?: Misskey.entities.Note['id']; }>(); +const Pagination = { + endpoint: 'users/notes' as const, + limit: 10, + params: { + userId: props.user.id, + }, +}; const emit = defineEmits<{ (ev: 'closed'): void; }>(); +const abuseNotesId = ref(props.initialNoteId ? [props.initialNoteId] : []); +const page = ref(0); const uiWindow = shallowRef>(); const comment = ref(props.initialComment ?? ''); +function pushAbuseReportNote(ev, id) { + if (ev) { + abuseNotesId.value.push(id); + } else { + abuseNotesId.value = abuseNotesId.value.filter(noteId => noteId !== id); + } +} + function send() { os.apiWithDialog('users/report-abuse', { userId: props.user.id, comment: comment.value, + noteIds: abuseNotesId.value, }, undefined).then(res => { os.alert({ type: 'success', @@ -69,4 +117,22 @@ function send() { .root { --root-margin: 16px; } +.transition_x_enterActive, +.transition_x_leaveActive { + transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1); +} +.transition_x_enterFrom { + opacity: 0; + transform: translateX(50px); +} +.transition_x_leaveTo { + opacity: 0; + transform: translateX(-50px); +} +.note{ + display: flex; + margin: var(--margin) 0; + align-items: center; + +} diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index 76ebadab79..fc1e290d6f 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -91,6 +91,10 @@ const emojiDb = computed(() => { const customEmojiDB: EmojiDef[] = []; for (const x of customEmojis.value) { + if (x.draft) { + continue; + } + customEmojiDB.push({ name: x.name, emoji: `:${x.name}:`, diff --git a/packages/frontend/src/components/MkAvatarDecoEditDialog.vue b/packages/frontend/src/components/MkAvatarDecoEditDialog.vue new file mode 100644 index 0000000000..a39bbc000b --- /dev/null +++ b/packages/frontend/src/components/MkAvatarDecoEditDialog.vue @@ -0,0 +1,164 @@ + + + + + + + diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index a4bfeebc1f..e254900dd7 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -6,7 +6,23 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
@@ -25,7 +34,9 @@ import { useInterval } from '@/scripts/use-interval.js'; import * as game from '@/scripts/clicker-game.js'; import number from '@/filters/number.js'; import { claimAchievement } from '@/scripts/achievements.js'; +import { defaultStore } from '@/store.js'; +const darkMode = computed(defaultStore.makeGetterSetter('darkMode')); const saveData = game.saveData; const cookies = computed(() => saveData.value?.cookies); const cps = ref(0); @@ -91,4 +102,15 @@ onUnmounted(() => { .img { max-width: 90px; } + +$color-scheme: var(--color-scheme); + +.icon { + width: 1.3em; + vertical-align: -24%; +} + +.dark { + filter: invert(1); +} diff --git a/packages/frontend/src/components/MkCustomEmojiEditLocal.vue b/packages/frontend/src/components/MkCustomEmojiEditLocal.vue new file mode 100644 index 0000000000..9a29622827 --- /dev/null +++ b/packages/frontend/src/components/MkCustomEmojiEditLocal.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/packages/frontend/src/components/MkCustomEmojiEditRemote.vue b/packages/frontend/src/components/MkCustomEmojiEditRemote.vue new file mode 100644 index 0000000000..b5d5b31e40 --- /dev/null +++ b/packages/frontend/src/components/MkCustomEmojiEditRemote.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/packages/frontend/src/components/MkCustomEmojiEditRequest.vue b/packages/frontend/src/components/MkCustomEmojiEditRequest.vue new file mode 100644 index 0000000000..cd970ac901 --- /dev/null +++ b/packages/frontend/src/components/MkCustomEmojiEditRequest.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue index 802129cd9d..e13240e428 100644 --- a/packages/frontend/src/components/MkDateSeparatedList.vue +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -43,65 +43,73 @@ export default defineComponent({ setup(props, { slots, expose }) { const $style = useCssModule(); // カスタムレンダラなので使っても大丈夫 + const dateTextCache = new Map(); + function getDateText(time: string) { + if (dateTextCache.has(time)) { + return dateTextCache.get(time)!; + } const date = new Date(time).getDate(); const month = new Date(time).getMonth() + 1; - return i18n.tsx.monthAndDay({ + const text = i18n.tsx.monthAndDay({ month: month.toString(), day: date.toString(), }); + dateTextCache.set(time, text); + return text; } if (props.items.length === 0) return; - const renderChildrenImpl = () => props.items.map((item, i) => { - if (!slots || !slots.default) return; + const renderChildrenImpl = () => { + const slotContent = slots.default ? slots.default : () => []; + return props.items.map((item, i) => { + const el = slotContent({ + item: item, + })[0]; + if (el.key == null && item.id) el.key = item.id; - const el = slots.default({ - item: item, - })[0]; - if (el.key == null && item.id) el.key = item.id; - - if ( - i !== props.items.length - 1 && - new Date(item.createdAt).getDate() !== new Date(props.items[i + 1].createdAt).getDate() - ) { - const separator = h('div', { - class: $style['separator'], - key: item.id + ':separator', - }, h('p', { - class: $style['date'], - }, [ - h('span', { - class: $style['date-1'], + if ( + i !== props.items.length - 1 && + new Date(item.createdAt).getDate() !== new Date(props.items[i + 1].createdAt).getDate() + ) { + const separator = h('div', { + class: $style['separator'], + key: item.id + ':separator', + }, h('p', { + class: $style['date'], }, [ - h('i', { - class: `ti ti-chevron-up ${$style['date-1-icon']}`, - }), - getDateText(item.createdAt), - ]), - h('span', { - class: $style['date-2'], - }, [ - getDateText(props.items[i + 1].createdAt), - h('i', { - class: `ti ti-chevron-down ${$style['date-2-icon']}`, - }), - ]), - ])); + h('span', { + class: $style['date-1'], + }, [ + h('i', { + class: `ti ti-chevron-up ${$style['date-1-icon']}`, + }), + getDateText(item.createdAt), + ]), + h('span', { + class: $style['date-2'], + }, [ + getDateText(props.items[i + 1].createdAt), + h('i', { + class: `ti ti-chevron-down ${$style['date-2-icon']}`, + }), + ]), + ])); - return [el, separator]; - } else { - if (props.ad && item._shouldInsertAd_) { - return [h(MkAd, { - key: item.id + ':ad', - prefer: ['horizontal', 'horizontal-big'], - }), el]; + return [el, separator]; } else { - return el; + if (props.ad && item._shouldInsertAd_) { + return [h(MkAd, { + key: item.id + ':ad', + prefer: ['horizontal', 'horizontal-big'], + }), el]; + } else { + return el; + } } - } - }); + }); + }; const renderChildren = () => { const children = renderChildrenImpl(); @@ -120,14 +128,12 @@ export default defineComponent({ function onBeforeLeave(element: Element) { const el = element as HTMLElement; - el.style.top = `${el.offsetTop}px`; - el.style.left = `${el.offsetLeft}px`; + el.classList.add('before-leave'); } function onLeaveCancelled(element: Element) { const el = element as HTMLElement; - el.style.top = ''; - el.style.left = ''; + el.classList.remove('before-leave'); } // eslint-disable-next-line vue/no-setup-props-reactivity-loss @@ -157,21 +163,21 @@ export default defineComponent({ container-type: inline-size; &:global { - > .list-move { - transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); - } + > .list-move { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); + } - &.deny-move-transition > .list-move { - transition: none !important; - } + &.deny-move-transition > .list-move { + transition: none !important; + } - > .list-enter-active { - transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); - } + > .list-enter-active { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); + } - > *:empty { - display: none; - } + > *:empty { + display: none; + } } &:not(.date-separated-list-nogap) > *:not(:last-child) { @@ -194,20 +200,20 @@ export default defineComponent({ .direction-up { &:global { - > .list-enter-from, - > .list-leave-to { - opacity: 0; - transform: translateY(64px); - } + > .list-enter-from, + > .list-leave-to { + opacity: 0; + transform: translateY(64px); + } } } .direction-down { &:global { - > .list-enter-from, - > .list-leave-to { - opacity: 0; - transform: translateY(-64px); - } + > .list-enter-from, + > .list-leave-to { + opacity: 0; + transform: translateY(-64px); + } } } @@ -246,5 +252,8 @@ export default defineComponent({ .date-2-icon { margin-left: 8px; } - +.before-leave { + position: absolute !important; +} + diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 15677071af..572b0a41c9 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -23,6 +23,7 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License +
@@ -56,9 +57,10 @@ import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import { i18n } from '@/i18n.js'; +import MkSwitch from "@/components/MkSwitch.vue"; type Input = { - type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local'; + type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local' | 'mksw'; placeholder?: string | null; autocomplete?: string; default: string | number | null; @@ -77,11 +79,12 @@ type Select = { type Result = string | number | true | null; const props = withDefaults(defineProps<{ - type?: 'success' | 'error' | 'warning' | 'info' | 'question' | 'waiting'; + type?: 'success' | 'error' | 'warning' | 'info' | 'question' | 'waiting' | 'mksw'; title?: string; text?: string; input?: Input; select?: Select; + mksw?: boolean; icon?: string; actions?: { text: string; @@ -110,7 +113,7 @@ const modal = shallowRef>(); const inputValue = ref(props.input?.default ?? null); const selectedValue = ref(props.select?.default ?? null); - +const mkresult= ref(false) const okButtonDisabledReason = computed(() => { if (props.input) { if (props.input.minLength) { @@ -142,6 +145,7 @@ async function ok() { const result = props.input ? inputValue.value : props.select ? selectedValue.value : + mkresult ? mkresult.value : true; done(false, result); } diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index 8400339e6f..3921d6f7a3 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -4,7 +4,7 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License