diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 297f93b61..444ee0b5c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,7 @@ on: pull_request: jobs: - mocha: + jest: runs-on: ubuntu-latest strategy: @@ -47,7 +47,12 @@ jobs: - name: Build run: yarn build - name: Test - run: yarn mocha + run: yarn jest-and-coverage + - name: Upload Coverage + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./packages/backend/coverage/coverage-final.json e2e: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index e3bbac9c3..db3681551 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ packages/sw/.yarn/cache cypress/screenshots cypress/videos +# Coverage +coverage + # config /.config/* !/.config/example.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index cc078e1d9..08d68dd6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,17 @@ You should also include the user name that made the change. --> -## 12.x.x (unreleased) +## 12.119.0 (2022/09/10) ### Improvements - Client: Add following badge to user preview popup @nvisser +- Client: mobile twitter url can be used as widget @caipira113 +- Client: Improve clock widget @syuilo + +### Bugfixes +- マイグレーションに失敗する問題を修正 +- Server: 他人の通知を既読にできる可能性があるのを修正 @syuilo +- Client: アクセストークン管理画面、アカウント管理画面表示できないのを修正 @futchitwo ## 12.118.1 (2022/08/08) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d599d39cf..bb5aa2d5e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -124,7 +124,7 @@ yarn test #### Run specify test ``` -TS_NODE_FILES=true TS_NODE_TRANSPILE_ONLY=true TS_NODE_PROJECT="./test/tsconfig.json" yarn dlx mocha test/foo.ts --require ts-node/register +yarn jest -- foo.ts ``` ### e2e tests diff --git a/ROADMAP.md b/ROADMAP.md index bd96b2c50..b2c5c8757 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,15 +6,20 @@ Also, the later tasks are more indefinite and are subject to change as developme This is the phase we are at now. We need to make a high-maintenance environment that can withstand future development. - Make the number of type errors zero (backend) - - Probably need to switch some libraries to others that make it difficult to reduce type errors - - e.g. koa to fastify https://github.com/misskey-dev/misskey/issues/7537 + - Probably need to switch some libraries to others that make it difficult to reduce type errors + - e.g. koa to fastify https://github.com/misskey-dev/misskey/issues/7537 - Improve CI - - Fix tests - - mocha, jest, etc. do not support the combination of `TypeScript + ESM + Path alias`, and the tests currently do not work. - - Fix random test failures - https://github.com/misskey-dev/misskey/issues/7985 and https://github.com/misskey-dev/misskey/issues/7986 - - Add more tests - - May need to implement a mechanism that allows for DI + - Fix tests + - mocha, jest, etc. do not support the combination of `TypeScript + ESM + Path alias`, and the tests currently do not work. + - Fix random test failures - https://github.com/misskey-dev/misskey/issues/7985 and https://github.com/misskey-dev/misskey/issues/7986 + - Add more tests + - May need to implement a mechanism that allows for DI + - https://github.com/misskey-dev/misskey/pull/9085 + - Measure coverage + - https://github.com/misskey-dev/misskey/pull/9081 - Improve documentation +- Refactoring + - Extract the logic of each endpoint definition into a service and just call it ## (2) Improve functionality Once Phase 1 is complete and an environment conducive to the development of a stable system is in place, the implementation of new functions can begin gradually. diff --git a/locales/cs-CZ.yml b/locales/cs-CZ.yml index ff8a7ae8b..9d54e0082 100644 --- a/locales/cs-CZ.yml +++ b/locales/cs-CZ.yml @@ -206,6 +206,7 @@ instanceFollowers: "Následovníci na instanci" instanceUsers: "Uživatelé této instance" changePassword: "Změnit heslo" security: "Zabezpečení" +retypedNotMatch: "Zadané údaje se neshodují." currentPassword: "Současné heslo" newPassword: "Nové heslo" newPasswordRetype: "Nové heslo (znovu)" @@ -268,6 +269,7 @@ addFile: "Přidat soubor" emptyFolder: "Tato složka je prázdná" unableToDelete: "Nelze smazat" inputNewFileName: "Zadejte nový název" +inputNewFolderName: "Zadejte název nové složky" copyUrl: "Kopírovat URL" rename: "Přejmenovat" avatar: "Avatar" @@ -310,9 +312,11 @@ pinnedUsers: "Připnutí uživatelé" pinnedNotes: "Připnutá poznámka" hcaptcha: "hCaptcha" enableHcaptcha: "Aktivovat hCaptchu" +hcaptchaSiteKey: "Klíč stránky" hcaptchaSecretKey: "Tajný Klíč (Secret Key)" recaptcha: "reCAPTCHA" enableRecaptcha: "Zapnout ReCAPTCHu" +recaptchaSiteKey: "Klíč stránky" recaptchaSecretKey: "Tajný Klíč (Secret Key)" antennas: "Antény" manageAntennas: "Spravovat Antény" @@ -321,6 +325,10 @@ antennaSource: "Zdroj Antény" enableServiceworker: "Povolit ServiceWorker" caseSensitive: "Rozlišuje malá a velká písmena" connectedTo: "Následující účty jsou připojeny" +notesAndReplies: "Poznámky a odpovědi" +withFiles: "Včetně souborů" +popularUsers: "Populární uživatelé" +recentlyUpdatedUsers: "Nedávno aktívni uživatelé" popularTags: "Populární tagy" userList: "Seznamy" about: "Informace" @@ -365,10 +373,14 @@ next: "Další" retype: "Zadejte znovu" noteOf: "{user} poznámky" inviteToGroup: "Pozvat do skupiny" +quoteAttached: "Citace" +quoteQuestion: "Přiložit jako citaci?" +noMessagesYet: "Zatím tu nejsou žádné zprávy" newMessageExists: "Máte novou zprávu" onlyOneFileCanBeAttached: "Ke zprávě můžete přiložit jenom jeden soubor" signinRequired: "Přihlašte se, prosím" invitations: "Pozvat" +invitationCode: "Kód pozvánky" checking: "Ověřuji" available: "K dispozici" unavailable: "Není k dispozici" @@ -382,6 +394,7 @@ passwordMatched: "Hesla se schodují" passwordNotMatched: "Hesla se neschodují" signinWith: "Přihlásit se s {x}" signinFailed: "Nelze se přihlásit. Zkontrolujte prosím své uživatelské jméno a heslo." +tapSecurityKey: "Ťukněte na bezpečnostní klíč" or: "Nebo" language: "Jazyk" uiLanguage: "Jazyk uživatelského rozhraní" @@ -411,9 +424,20 @@ accountSettings: "Nastavení účtu" promotion: "Propagace" promote: "Propagovat" numberOfDays: "Počet dní" +objectStorageBaseUrl: "Base URL" +objectStorageBucket: "Bucket" +objectStoragePrefix: "Předpona" +objectStorageEndpoint: "Endpoint" +objectStorageRegion: "Región" +objectStorageUseSSL: "Použít SSL" deleteAll: "Smazat vše" showFixedPostForm: "Zobrazit formulář pro nové příspěvky nad časovou osou" +listen: "Poslouchat" +showInPage: "Zobrazit na stránce" +popout: "Pop-out" +volume: "Hlasitost" masterVolume: "Celková hlasitost" +details: "Detaily" chooseEmoji: "Vybrat emotikon" unableToProcess: "Operace nebyla dokončena." recentUsed: "Naposledy použité" @@ -434,13 +458,20 @@ deleteAllFiles: "Smazat všechny soubory" deleteAllFilesConfirm: "Jste si jistí že chcete smazat všechny soubory?" userSuspended: "Tomuto uživateli byl pozastaven účet." menu: "Menu" +divider: "Dělící čára" addItem: "Přidat položku" +relays: "Relay" +addRelay: "Přidat Relay" inboxUrl: "Inbox URL" deletedNote: "Odstraněné příspěvky" invisibleNote: "Skryté příspěvky" description: "Popis" author: "Autor" manage: "Administrace" +width: "Šířka" +height: "Výška" +large: "Velké" +medium: "Střední" small: "Malé" generateAccessToken: "Vygenerovat přístupový token" permission: "Oprávnění" @@ -458,11 +489,16 @@ smtpPort: "Port" smtpUser: "Uživatelské jméno" smtpPass: "Heslo" smtpSecureInfo: "Toto vypněte pokud používáte STARTTLS" +testEmail: "Otestovat doručení emailů" makeActive: "Aktivovat" display: "Zobrazit" copy: "Kopírovat" +metrics: "Metriky" +overview: "Shrnutí" logs: "Logy" +delayed: "Prodleva" database: "Databáze" +channel: "Kanály" create: "Vytvořit" notificationSetting: "Nastavení oznámení" useGlobalSetting: "Použít globální nastavení" @@ -470,79 +506,415 @@ other: "Ostatní" fileIdOrUrl: "ID nebo URL souboru" behavior: "Chování" sample: "Ukázka" +send: "Odeslat" +openInNewTab: "Otevřít v nové kartě" +random: "Náhodně" +system: "Systém" +desktop: "Plocha" +clip: "Oříznout" +createNew: "Vytvořit nový" +optional: "Volitelné" +yes: "Ano" +no: "Ne" +notSet: "Není nastaveno" +emailVerified: "Váš e-mail byl ověřen" +contact: "Kontakt" +useSystemFont: "Použít výchozí font systému" +clips: "Oříznout" +experimentalFeatures: "Experimentální funkce" +developer: "Vývojář" +duplicate: "Duplikovat" +left: "Vlevo" +center: "Uprostřed" +wide: "Široké" +narrow: "Úzké" clearCache: "Vyprázdnit mezipaměť" +nUsers: "{n} užívatelů" +nNotes: "{n} poznámek" +myTheme: "Moje vzhledy" +backgroundColor: "Pozadí" +accentColor: "Akcent" +textColor: "Barva textu" +saveAs: "Uložit jako…" +advanced: "Pokročilé" +value: "Hodnota" +createdAt: "Vytvořeno" +updatedAt: "Upraveno" +saveConfirm: "Uložit změny?" +deleteConfirm: "Opravdu smazat?" +invalidValue: "Neplatná hodnota." +registry: "Registr" info: "Informace" +unknown: "Neznámý" +onlineStatus: "Online status" +hideOnlineStatus: "Skrýt Váš online status" +hideOnlineStatusDescription: "Skrytí vašeho online stavu může snížit funkcionalitu některých funkcí, například vyhledávání." +online: "Online" +active: "Aktivní" +offline: "Offline" +notRecommended: "Nedoporučuje se" +botProtection: "Bot ochrana" +instanceBlocking: "Blokované instance" +selectAccount: "Vybrat účet" +switchAccount: "Přepnout účet" +enabled: "Zapnuto" +disabled: "Vypnuto" +quickAction: "Rychlé akce" user: "Uživatelé" administration: "Administrace" +accounts: "Účty" +switch: "Přepnout" +configure: "Nastavit" +gallery: "Galerie" +recentPosts: "Poslední příspěvky" +ads: "Reklamy" +memo: "Memo" +priority: "Priorita" +high: "Vysoká" +middle: "Střední" +low: "Nízká" +emailNotConfiguredWarning: "E-mailová adresa není nastavena." +ratio: "Poměr" +global: "Globální" +sent: "Odeslat" +hashtags: "Hashtagy" +troubleshooting: "Poradce při potížích" +whatIsNew: "Zobrazit změny" +translate: "Přeložit" +hide: "Skrýt" +smartphone: "Telefon" +tablet: "Tablet" +auto: "Auto" +size: "Velikost" +numberOfColumn: "Počet sloupců" searchByGoogle: "Vyhledávání" +indefinitely: "Navždy" +tenMinutes: "10 minut" +oneHour: "1 hodina" +oneDay: "1 den" +oneWeek: "1 týden" +reflectMayTakeTime: "Může trvat nějakou dobu, než se projeví změny." +cropImage: "Oříznout obrázek" file: "Soubor(ů)" +recentNHours: "Posledních {n} hodin" +recentNDays: "Posledních {n} dnů" +recommended: "Doporučeno" +deleteAccount: "Odstranit účet" +document: "Dokumentace" +logoutConfirm: "Opravdu se chcete odhlásit?" +pleaseSelect: "Vybrat možnost" +reverse: "Otočit" +colored: "Barevné" +type: "Typ" +speed: "Rychlost" +slow: "Pomalá" +fast: "Rychlá" +account: "Účty" +_ad: + back: "Zpět" +_gallery: + my: "Moje galerie" _email: _follow: title: "Máte nového následovníka" +_plugin: + install: "Instalovat plugin" + manage: "Správce pluginů" +_preferencesBackups: + list: "Vytvořit backup" + loadFile: "Načíst ze souboru" + save: "Uložit změny" +_registry: + scope: "Rozsah" + key: "Klíč" + keys: "Klíče" + domain: "Doména" + createKey: "Vytvořit klíč" +_aboutMisskey: + allContributors: "Všichni přispěvatelé" + source: "Zdrojový kód" _mfm: mention: "Zmínění" + hashtag: "Hashtag" + link: "Odkaz" + bold: "Tučně" quote: "Citovat" emoji: "Vlastní emoji" search: "Vyhledávání" + flip: "Otočit" + tada: "Animace (tadá)" + blur: "Rozmazání" + font: "Font" + rainbow: "Duha" +_channel: + featured: "Trendy" +_menuDisplay: + top: "Nahoru" + hide: "Skrýt" _theme: + install: "Nainstalovat vzhled" + manage: "Správa vzhledů" + code: "Kód vzhledu" description: "Popis" + installedThemes: "Nainstalované vzhledy" + constant: "Konstanta" + defaultValue: "Výchozí hodnota" + color: "Barva" + key: "Klíč" + func: "Funkce " keys: + shadow: "Stín" + header: "Nadpis" + link: "Odkaz" + hashtag: "Hashtag" mention: "Zmínění" renote: "Přeposlat" + divider: "Dělící čára" _sfx: note: "Poznámky" notification: "Oznámení" chat: "Zprávy" +_ago: + future: "Budoucí" + justNow: "Teď" +_time: + second: "Sekund" + minute: "Minut" + hour: "Hodin" +_2fa: + registerDevice: "Přidat zařízení" + registerKey: "Přidat bezpečnostní klíč" +_weekday: + sunday: "Neděle" + monday: "Pondělí" + tuesday: "Úterý" + wednesday: "Středa" + thursday: "Čtvrtek" + friday: "Pátek" + saturday: "Sobota" _widgets: notifications: "Oznámení" timeline: "Časová osa" + calendar: "Kalendář" + trends: "Trendy" + clock: "Hodiny" + rss: "RSS čtečka" activity: "Aktivita" + photos: "Fotky" + digitalClock: "Digitální hodiny" federation: "Federace" + slideshow: "Prezentace" + button: "Tlačítko" + onlineUsers: "Online uživatelé" jobQueue: "Fronta úloh" + aiscript: "AiScript conzole" + aichan: "Ai" _cw: + hide: "Skrýt" show: "Zobrazit více" +_poll: + noMore: "Více už přidat nemůžete" + infinite: "Nikdy" + deadlineDate: "Datum ukončení" + deadlineTime: "Hodin" + duration: "Trvání" _visibility: home: "Domů" followers: "Sledující" +_postForm: + _placeholders: + f: "Čekám, až něco napíšete..." _profile: name: "Jméno" username: "Uživatelské jméno" + description: "O mně" + youCanIncludeHashtags: "V popisku o Vás můžete použít i hastagy." + metadata: "Doplňující informace" + metadataContent: "Obsah" _exportOrImport: + allNotes: "Všechny poznámky" followingList: "Sledovaní" muteList: "Ztlumit" blockingList: "Zablokovat" userLists: "Seznamy" _charts: federation: "Federace" + apRequest: "Požadavek" + usersTotal: "Celkem uživatelů" + activeUsers: "Aktivní uživatelé" + notesTotal: "Celkový počet poznámek" _timelines: home: "Domů" + global: "Globální" _pages: + newPage: "Vytvořit novou stránku" + editPage: "Upravit stránku" + created: "Stránka byla úspěšně vytvořena" + updated: "Stránka byla úspěšně aktualizována" + deleted: "Stránka byla úspěšně smazána" + pageSetting: "Nastavení stránky" + invalidNameText: "Ujistěte se že jméno stránky je vyplněno" + contents: "Obsah" + fontSerif: "Serif" + fontSansSerif: "Sans Serif" + chooseBlock: "Přidat blok" + selectType: "Vyberte typ" + contentBlocks: "Obsah" + inputBlocks: "Vstup" + specialBlocks: "Speciální" blocks: + text: "Text" + textarea: "Textové pole" + section: "Sekce" image: "Obrázky" + button: "Tlačítko" + if: "Pokud" + _if: + variable: "Proměnná" + _post: + text: "Obsah" + canvasId: "Canvas ID" + _textInput: + name: "Jméno proměnné" + text: "Titulek" + default: "Výchozí hodnota" + _textareaInput: + name: "Jméno proměnné" + text: "Titulek" + default: "Výchozí hodnota" + _numberInput: + name: "Jméno proměnné" + text: "Titulek" + default: "Výchozí hodnota" + canvas: "Canvas" + _canvas: + id: "Canvas ID" + width: "Šířka" + height: "Výška" + _switch: + name: "Jméno proměnné" + text: "Titulek" + default: "Výchozí hodnota" + _counter: + name: "Jméno proměnné" + text: "Titulek" + inc: "Krok" + _button: + text: "Titulek" + colored: "Barevné" + _action: + _dialog: + content: "Obsah" + _radioButton: + name: "Jméno proměnné" + default: "Výchozí hodnota" script: categories: list: "Seznamy" blocks: + text: "Text" + _strLen: + arg1: "Text" + _strPick: + arg1: "Text" + _strReplace: + arg1: "Text" + _strReverse: + arg1: "Text" _join: arg1: "Seznamy" + _subtract: + arg1: "A" + arg2: "B" + _multiply: + arg1: "A" + arg2: "B" + _divide: + arg1: "A" + arg2: "B" + _mod: + arg1: "A" + arg2: "B" + round: "Zaokrouhlení zlomku" + _round: + arg1: "Číselná hodnota" + eq: "A a B jsou stejné" + _eq: + arg1: "A" + arg2: "B" + notEq: "A a B jsou odlišné" + _notEq: + arg1: "A" + arg2: "B" + _and: + arg1: "A" + arg2: "B" + _or: + arg1: "A" + arg2: "B" + _lt: + arg1: "A" + arg2: "B" + _gt: + arg1: "A" + arg2: "B" + _ltEq: + arg1: "A" + arg2: "B" + _gtEq: + arg1: "A" + arg2: "B" + if: "Větev" + _if: + arg1: "Pokud" + arg2: "Potom" + arg3: "Nebo" + random: "Náhodně" + _random: + arg1: "Pravděpodobnost" + rannum: "Náhodné číslo" + _rannum: + arg1: "Minimální hodnota" + arg2: "Maximální hodnota" _randomPick: arg1: "Seznamy" + _dailyRandom: + arg1: "Pravděpodobnost" + _dailyRannum: + arg1: "Minimální hodnota" + arg2: "Maximální hodnota" _dailyRandomPick: arg1: "Seznamy" + _seedRandom: + arg2: "Pravděpodobnost" + _seedRannum: + arg2: "Minimální hodnota" + arg3: "Maximální hodnota" _seedRandomPick: arg2: "Seznamy" _pick: arg1: "Seznamy" _listLen: arg1: "Seznamy" + number: "Číselná hodnota" + _stringToNumber: + arg1: "Text" + _numberToString: + arg1: "Číselná hodnota" + _splitStrByLine: + arg1: "Text" types: + string: "Text" + number: "Číselná hodnota" array: "Seznamy" _notification: youWereFollowed: "Máte nového následovníka" youWereInvitedToGroup: "Pozvat do skupiny" _types: + all: "Vše" follow: "Sledovaní" mention: "Zmínění" + reply: "Odpovědi" renote: "Přeposlat" quote: "Citovat" reaction: "Reakce" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index b10cce923..cedfe7b1b 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2,6 +2,7 @@ _lang_: "日本語" headlineMisskey: "ノートでつながるネットワーク" introMisskey: "ようこそ!Misskeyは、オープンソースの分散型マイクロブログサービスです。\n「ノート」を作成して、いま起こっていることを共有したり、あなたについて皆に発信しよう📡\n「リアクション」機能で、皆のノートに素早く反応を追加することもできます👍\n新しい世界を探検しよう🚀" +poweredByMisskeyDescription: "{name}は、オープンソースのプラットフォームMisskeyを使ったサービス(Misskeyインスタンスと呼ばれます)のひとつです。" monthAndDay: "{month}月 {day}日" search: "検索" notifications: "通知" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 168fa9d65..d77f7e920 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -562,6 +562,7 @@ author: "작성자" leaveConfirm: "저장하지 않은 변경사항이 있습니다. 취소하시겠습니까?" manage: "관리" plugins: "플러그인" +preferencesBackups: "환경설정 백업" deck: "덱" undeck: "덱 해제" useBlurEffectForModal: "모달에 흐림 효과 사용" @@ -612,7 +613,7 @@ create: "생성" notificationSetting: "알림 설정" notificationSettingDesc: "표시할 알림의 종류를 선택해 주세요." useGlobalSetting: "글로벌 설정을 사용하기" -useGlobalSettingDesc: "활성화하면 계정의 알림 설정이 적용되니다. 비활성화하면 개별적으로 설정할 수 있게 됩니다." +useGlobalSettingDesc: "활성화하면 계정의 알림 설정이 적용됩니다. 비활성화하면 개별적으로 설정할 수 있게 됩니다." other: "기타" regenerateLoginToken: "로그인 토큰을 재생성" regenerateLoginTokenDescription: "로그인할 때 사용되는 내부 토큰을 재생성합니다. 일반적으로 이 작업을 실행할 필요는 없습니다. 이 기능을 사용하면 이 계정으로 로그인한 모든 기기에서 로그아웃됩니다." @@ -941,6 +942,24 @@ _plugin: install: "플러그인 설치" installWarn: "신뢰할 수 없는 플러그인은 설치하지 않는 것이 좋습니다." manage: "플러그인 관리" +_preferencesBackups: + list: "생성한 백업" + saveNew: "새 백업 만들기" + loadFile: "파일 가져오기" + apply: "이 기기에 적용" + save: "현재 설정으로 덮어쓰기" + inputName: "백업 이름을 입력하세요" + cannotSave: "저장하지 못했습니다" + nameAlreadyExists: "\"{name}\" 백업이 이미 존재합니다. 다른 이름을 설정하여 주십시오." + applyConfirm: "\"{name}\" 백업을 현재 기기에 적용하시겠습니까? 현재 설정은 덮어 씌워집니다." + saveConfirm: "{name} 을 덮어쓰시겠습니까?" + deleteConfirm: "{name} 을(를) 삭제하시겠습니까?" + renameConfirm: "\"{old}\" 백업을 \"{new}\"(으)로 바꾸시겠습니까?" + noBackups: "저장된 백업이 없습니다. \"새 백업 만들기\"를 눌러 현재 클라이언트 설정을 서버에 백업할 수 있습니다." + createdAt: "생성 날짜: {date} {time}" + updatedAt: "갱신 날짜: {date} {time}" + cannotLoad: "가져오기에 실패했습니다" + invalidFile: "파일 형식이 올바르지 않습니다." _registry: scope: "범위" key: "키" @@ -1258,6 +1277,7 @@ _widgets: activity: "활동" photos: "사진" digitalClock: "디지털 시계" + unixClock: "UNIX 시계" federation: "연합" instanceCloud: "인스턴스 구름" postForm: "글 입력란" diff --git a/locales/pl-PL.yml b/locales/pl-PL.yml index f0470395f..933b722c5 100644 --- a/locales/pl-PL.yml +++ b/locales/pl-PL.yml @@ -1,5 +1,5 @@ --- -_lang_: "język polski" +_lang_: "Polski" headlineMisskey: "Sieć połączona wpisami" introMisskey: "Misskey jest serwisem mikroblogowym typu open source.\nMisskey to opensource'owy serwis mikroblogowy, w którym możesz tworzyć \"notatki\", aby dzielić się tym, co się dzieje i opowiadać wszystkim o sobie.\nMożesz również użyć funkcji \"Reakcje\", aby szybko dodać własne reakcje do notatek innych użytkowników👍.\nOdkrywaj nowy świat🚀!" monthAndDay: "{month}-{day}" @@ -88,7 +88,7 @@ enterListName: "Nazwa listy" privacy: "Prywatność" makeFollowManuallyApprove: "Prośby o możliwość obserwacji wymagają zatwierdzenia" defaultNoteVisibility: "Domyślna widoczność" -follow: "Obserwowani" +follow: "Obserwuj" followRequest: "Poproś o możliwość obserwacji" followRequests: "Prośby o możliwość obserwacji" unfollow: "Przestań obserwować" @@ -127,7 +127,7 @@ unsuspendConfirm: "Czy na pewno chcesz cofnąć zawieszenie tego konta?" selectList: "Wybierz listę" selectAntenna: "Wybierz Antennę" selectWidget: "Wybierz widżet" -editWidgets: "Edytuj widżet" +editWidgets: "Edytuj widżety" editWidgetsExit: "Gotowe" customEmojis: "Niestandardowe emoji" emoji: "Emoji" @@ -142,6 +142,7 @@ flagAsBot: "To konto jest botem" flagAsBotDescription: "Jeżeli ten kanał jest kontrolowany przez jakiś program, ustaw tę opcję. Jeżeli włączona, będzie działać jako flaga informująca innych programistów, aby zapobiegać nieskończonej interakcji z różnymi botami i dostosowywać wewnętrzne systemy Misskey, traktując konto jako bota." flagAsCat: "To konto jest kotem" flagAsCatDescription: "Przełącz tę opcję, aby konto było oznaczone jako kot." +flagShowTimelineReplies: "Pokazuj odpowiedzi na osi czasu" autoAcceptFollowed: "Automatycznie przyjmuj prośby o możliwość obserwacji od użytkowników, których obserwujesz" addAccount: "Dodaj konto" loginFailed: "Nie udało się zalogować" @@ -201,6 +202,7 @@ done: "Gotowe" processing: "Przetwarzanie" preview: "Podgląd" default: "Domyślne" +defaultValueIs: "Domyślne: {value}" noCustomEmojis: "Brak emoji" noJobs: "Brak zadań" federating: "Federowanie" @@ -235,6 +237,7 @@ resetAreYouSure: "Czy na pewno chcesz zresetować?" saved: "Zapisano" messaging: "Wiadomości" upload: "Wyślij" +keepOriginalUploading: "Zachowaj oryginalny obraz" fromDrive: "Z dysku" fromUrl: "Z adresu URL" uploadFromUrl: "Wyślij z adresu URL" @@ -377,6 +380,7 @@ administrator: "Admin" token: "Token" twoStepAuthentication: "Uwierzytelnianie dwuskładnikowe" moderator: "Moderator" +moderation: "Moderacja" nUsersMentioned: "{n} wspomnianych użytkowników" securityKey: "Klucz bezpieczeństwa" securityKeyName: "Nazwa klucza" @@ -444,11 +448,13 @@ uiLanguage: "Język wyświetlania UI" groupInvited: "Zaproszony(-a) do grupy" aboutX: "O {x}" useOsNativeEmojis: "Używaj natywnych Emoji systemu" +disableDrawer: "Nie używaj menu w stylu szuflady" youHaveNoGroups: "Nie masz żadnych grup" joinOrCreateGroup: "Uzyskaj zaproszenie do dołączenia do grupy lub utwórz własną grupę." noHistory: "Brak historii" signinHistory: "Historia logowania" disableAnimatedMfm: "Wyłącz MFM z animacją" +doing: "Przetwarzanie..." category: "Kategoria" tags: "Tagi" docSource: "Źródło tego dokumentu" @@ -523,6 +529,9 @@ deleteAllFilesConfirm: "Czy na pewno chcesz usunąć wszystkie pliki?" removeAllFollowingDescription: "Przestań obserwować wszystkie konta z {host}. Wykonaj to, jeżeli instancja już nie istnieje." userSuspended: "To konto zostało zawieszone." userSilenced: "Ten użytkownik został wyciszony." +yourAccountSuspendedTitle: "To konto jest zawieszone" +yourAccountSuspendedDescription: "To konto zostało zawieszone z powodu złamania regulaminu serwera lub innych podobnych. Skontaktuj się z administratorem, jeśli chciałbyś poznać bardziej szczegółowy powód. Proszę nie zakładać nowego konta." +menu: "Menu" divider: "Rozdzielacz" addItem: "Dodaj element" relays: "Przekaźniki" @@ -541,7 +550,7 @@ disablePlayer: "Zamknij odtwarzacz wideo" expandTweet: "Rozwiń tweet" themeEditor: "Edytor motywu" description: "Opis" -describeFile: "dodaj podpis" +describeFile: "Dodaj podpis" enterFileDescription: "Wprowadź napis" author: "Autor" leaveConfirm: "Są niezapisane zmiany. Czy chcesz je odrzucić?" @@ -578,6 +587,7 @@ emptyToDisableSmtpAuth: "Pozostaw adres e-mail i hasło puste, aby wyłączyć w smtpSecureInfo: "Wyłącz, jeżeli używasz STARTTLS" testEmail: "Przetestuj dostarczanie wiadomości e-mail" wordMute: "Wyciszenie słowa" +instanceMute: "Wyciszone instancje" userSaysSomething: "{name} powiedział(-a) coś" makeActive: "Aktywuj" display: "Wyświetlanie" @@ -607,6 +617,7 @@ fillAbuseReportDescription: "Wypełnij szczegóły zgłoszenia. Jeżeli dotyczy abuseReported: "Twoje zgłoszenie zostało wysłane. Dziękujemy." reporteeOrigin: "Pochodzenie zgłoszonego" reporterOrigin: "Pochodzenie zgłaszającego" +forwardReport: "Przekaż zgłoszenie do innej instancji" send: "Wyślij" abuseMarkAsResolved: "Oznacz zgłoszenie jako rozwiązane" openInNewTab: "Otwórz w nowej karcie" @@ -619,8 +630,12 @@ random: "Losowe" system: "System" switchUi: "Przełącz interfejs użytkownika" desktop: "Pulpit" +clip: "Klip" createNew: "Utwórz nowy" optional: "Nieobowiązkowe" +createNewClip: "Utwórz nowy klip" +unclip: "Odczep" +confirmToUnclipAlreadyClippedNote: "Ten wpis jest już częścią klipu \"{name}\". Czy chcesz ją usunąć z tego klipu?" public: "Publiczny" i18nInfo: "Misskey jest tłumaczone na wiele języków przez wolontariuszy. Możesz pomóc na {link}." manageAccessTokens: "Zarządzaj tokenami dostępu" @@ -654,6 +669,7 @@ pageLikesCount: "Liczba otrzymanych polubień stron" pageLikedCount: "Liczba polubionych stron" contact: "Kontakt" useSystemFont: "Używaj domyślnej czcionki systemu" +clips: "Klipy" experimentalFeatures: "Eksperymentalne funkcje" developer: "Programista" makeExplorable: "Pokazuj konto na stronie „Eksploruj”" @@ -725,6 +741,7 @@ notRecommended: "Nie zalecane" botProtection: "Zabezpieczenie przed botami" instanceBlocking: "Zablokowane instancje" selectAccount: "Wybierz konto" +switchAccount: "Przełącz konto" enabled: "Właczono" disabled: "Wyłączono" quickAction: "Szybkie działania" @@ -756,22 +773,103 @@ global: "Globalna" squareAvatars: "Wyświetlaj kwadratowe awatary" sent: "Wyślij" received: "Otrzymane" +searchResult: "Wyniki wyszukiwania" hashtags: "Hashtag" +troubleshooting: "Rozwiązywanie problemów" +useBlurEffect: "Użyj efektów rozmycia w UI" +learnMore: "Dowiedz się więcej" +misskeyUpdated: "Misskey zostało zaktualizowane!" +whatIsNew: "Pokaż zmiany" +translate: "Przetłumacz" +translatedFrom: "Przetłumaczone z {x}" +accountDeletionInProgress: "Trwa usuwanie konta" +usernameInfo: "Nazwa, która identyfikuje Twoje konto spośród innych na tym serwerze. Możesz użyć alfabetu (a~z, A~Z), cyfr (0~9) lub podkreślników (_). Nazwy użytkownika nie mogą być później zmieniane." +aiChanMode: "Tryb Ai" +keepCw: "Zostaw ostrzeżenia o zawartości" pubSub: "Konta Pub/Sub" +resolved: "Rozwiązane" +unresolved: "Nierozwiązane" +breakFollow: "Usuń obserwującego" +itsOn: "Włączone" +itsOff: "Wyłączone" +unread: "Nieodczytane" +filter: "Filtr" +controlPanel: "Panel sterowania" +manageAccounts: "Zarządzaj kontami" +makeReactionsPublic: "Ustawić historię reakcji jako publiczną" +makeReactionsPublicDescription: "To spowoduje, że lista wszystkich Twoich dotychczasowych reakcji będzie publicznie widoczna." +classic: "Klasyczny" +muteThread: "Wycisz wątek" +unmuteThread: "Wyłącz wyciszenie wątku" +ffVisibility: "Widoczność obserwowanych/obserwujących" +ffVisibilityDescription: "Pozwala skonfigurować, kto może zobaczyć, kogo obserwujesz i kto Cię obserwuje." +continueThread: "Pokaż kontynuację wątku" +deleteAccountConfirm: "Spowoduje to nieodwracalne usunięcie Twojego konta. Kontynuować?" +incorrectPassword: "Nieprawidłowe hasło." +voteConfirm: "Potwierdzić swój głos na \"{choice}\"?" hide: "Ukryj" +leaveGroup: "Opuść grupę" +leaveGroupConfirm: "Czy na pewno chcesz opuścić \"{name}\"?" +useDrawerReactionPickerForMobile: "Wyświetlaj wybornik reakcji jako szufladę na urządzeniach mobilnych" +welcomeBackWithName: "Witaj z powrotem, {name}" +clickToFinishEmailVerification: "Kliknij [{ok}], aby zakończyć weryfikację e-mail." +overridedDeviceKind: "Typ urządzenia" +smartphone: "Smartfon" +tablet: "Tablet" +auto: "Automatycznie" +size: "Rozmiar" +numberOfColumn: "Liczba kolumn" searchByGoogle: "Szukaj" indefinitely: "Nigdy" file: "Pliki" +logoutConfirm: "Czy na pewno chcesz się wylogować?" +lastActiveDate: "Ostatnio użyte w" +statusbar: "Pasek stanu" +pleaseSelect: "Wybierz opcję" reverse: "Odwróć" colored: "Kolorowe" label: "Etykieta" +type: "Typ" +speed: "Prędkość" +localOnly: "Lokalne tylko" +failedToUpload: "Przesyłanie nie powiodło się" +cannotUploadBecauseInappropriate: "Nie można przesłać tego pliku, ponieważ jego części zostały wykryte jako potencjalnie nieodpowiednie." +cannotUploadBecauseNoFreeSpace: "Przesyłanie nie powiodło się z powodu braku miejsca na dysku." +beta: "Beta" +enableAutoSensitive: "Automatyczne oznaczanie NSFW" +enableAutoSensitiveDescription: "Umożliwia automatyczne wykrywanie i oznaczanie zawartości NSFW za pomocą uczenia maszynowego. Nawet jeśli ta opcja jest wyłączona, może być włączona w całej instancji." +navbar: "Pasek nawigacyjny" account: "Konta" +move: "Przenieś" +_sensitiveMediaDetection: + description: "Zmniejsza wysiłek związany z moderacją serwera dzięki automatycznemu rozpoznawaniu zawartości NSFW za pomocą uczenia maszynowego. To nieznacznie zwiększy obciążenie serwera." + setSensitiveFlagAutomatically: "Oznacz jako NSFW" +_emailUnavailable: + used: "Ten adres e-mail jest już używany" + format: "Format tego adresu e-mail jest nieprawidłowy" + disposable: "Nie można używać jednorazowych adresów e-mail" + mx: "Ten serwer e-mail jest nieprawidłowy" + smtp: "Ten serwer e-mail nie odpowiada" _ffVisibility: - public: "Publikuj" + public: "Publiczne" + followers: "Widoczne tylko dla obserwujących" + private: "Prywatne" +_signup: + almostThere: "Prawie na miejscu" + emailAddressInfo: "Podaj swój adres e-mail. Nie zostanie on upubliczniony." + emailSent: "E-mail z potwierdzeniem został wysłany na Twój adres e-mail ({email}). Kliknij dołączony link, aby dokończyć tworzenie konta." +_accountDelete: + accountDelete: "Usuń konto" + mayTakeTime: "Ponieważ usuwanie konta jest procesem wymagającym dużej ilości zasobów, jego ukończenie może zająć trochę czasu, w zależności od ilości utworzonej zawartości i liczby przesłanych plików." + sendEmail: "Po zakończeniu usuwania konta na adres e-mail zarejestrowany na tym koncie zostanie wysłana wiadomość e-mail." + requestAccountDelete: "Poproś o usunięcie konta" + started: "Usuwanie się rozpoczęło." + inProgress: "Usuwanie jest obecnie w toku" _ad: back: "Wróć" reduceFrequencyOfThisAd: "Pokazuj tę reklamę rzadziej" _forgotPassword: + enterEmail: "Wpisz adres e-mail użyty do rejestracji. Zostanie do niego wysłany link, za pomocą którego możesz zresetować hasło." ifNoEmail: "Jeżeli nie podano adresu e-mail podczas rejestracji, skontaktuj się z administratorem zamiast tego." contactAdmin: "Jeżeli Twoja instancja nie obsługuje adresów e-mail, skontaktuj się zamiast tego z administratorem, aby zresetować hasło." _gallery: @@ -788,6 +886,23 @@ _plugin: install: "Zainstaluj wtyczki" installWarn: "Nie instaluj niezaufanych wtyczek." manage: "Zarządzanie wtyczkami" +_preferencesBackups: + list: "Utworzone kopie zapasowe" + saveNew: "Zapisz nową kopię zapasową" + loadFile: "Załaduj z pliku" + apply: "Zastosuj do tego urządzenia" + save: "Zapisz zmiany" + inputName: "Proszę podać nazwę dla tej kopii zapasowej" + cannotSave: "Zapisanie nie powiodło się" + nameAlreadyExists: "Kopia zapasowa o nazwie \"{name}\" już istnieje. Proszę podać inną nazwę." + applyConfirm: "Czy na pewno chcesz zastosować kopię zapasową \"{name}\" na tym urządzeniu? Istniejące ustawienia tego urządzenia zostaną nadpisane." + saveConfirm: "Zapisać kopię zapasową jako {name}?" + deleteConfirm: "Usunąć kopię zapasową {name}?" + renameConfirm: "Zmienić nazwę kopii zapasowej z \"{old}\" na \"{new}\"?" + createdAt: "Utworzony w: {date} {time}" + updatedAt: "Zaktualizowano w: {date} {time}" + cannotLoad: "Ładowanie nie powiodło się" + invalidFile: "Nieprawidłowy format pliku" _registry: scope: "Zakres" key: "Klucz" @@ -822,10 +937,13 @@ _mfm: bold: "Pogrubienie" boldDescription: "Wyróżnia litery pogrubiając je." small: "Małe" + smallDescription: "Wyświetla treść jako małą i cienką." center: "Wyśrodkowanie" centerDescription: "Wyśrodkowuje zawartość." + inlineCode: "Kod (w wierszu)" blockCode: "Kod (blok)" blockCodeDescription: "Wyświetla kod z podświetlaną składnią składający się z wielu linii." + blockMath: "Matematyka (Blok)" quote: "Cytuj" quoteDescription: "Wyświetla treść jako cytat." emoji: "Niestandardowe emoji" @@ -834,6 +952,20 @@ _mfm: searchDescription: "Wyświetla pole wyszukiwania z wcześniej wpisanym tekstem." flip: "Odwróć" flipDescription: "Przerzuca treść poziomo lub pionowo." + jelly: "Animacja (Galaretka)" + jellyDescription: "Nadaje treści galaretowatą animację." + tada: "Animation (Tada)" + tadaDescription: "Nadaje treści animację podobną do \"Tada!\"." + jump: "Animacja (Skok)" + jumpDescription: "Nadaje treści animację skakania." + bounce: "Animacja (Odbijanie)" + bounceDescription: "Nadaje treści animację odbijania się." + shake: "Animacja (Wstrząsanie)" + shakeDescription: "Nadaje treści animację wstrząsania." + twitch: "Animacja (Drganie)" + twitchDescription: "Nadaje treści mocno drgającą animację." + spin: "Animacja (Obrót)" + spinDescription: "Nadaje treści animację obracania." x2: "Duże" x2Description: "Czyni treść większą." x3: "Bardzo duże" @@ -841,9 +973,17 @@ _mfm: x4: "Ogromne" x4Description: "Czyni treść jeszcze większą niż jeszcze większa." blur: "Rozmycie" + blurDescription: "Rozmywa treść. Zostanie wyraźnie wyświetlona po najechaniu." font: "Czcionka" fontDescription: "Wybiera czcionkę do wyświetlania treści." + rainbow: "Tęcza" + rainbowDescription: "Sprawia, że zawartość pojawia się w kolorach tęczy." + sparkle: "Blask" + sparkleDescription: "Nadaje zawartości efekt lśniącego brokatu." rotate: "Obróć" + rotateDescription: "Obraca zawartość o określony kąt." + plain: "Zwyczajny" + plainDescription: "Wyłącza efekty wszystkich MFM zawartych w tym efekcie MFM." _instanceTicker: none: "Nigdy nie pokazuj" remote: "Pokaż dla zdalnych użytkowników" @@ -863,6 +1003,7 @@ _channel: usersCount: "{n} uczestnicy" notesCount: "{n} wpisy" _menuDisplay: + top: "Góra" hide: "Ukryj" _wordMute: muteWords: "Słowo do wyciszenia" @@ -870,6 +1011,9 @@ _wordMute: soft: "Łagodny" hard: "Twardy" mutedNotes: "Wyciszone wpisy" +_instanceMute: + title: "Ukrywa wpisy z wymienionych instancji." + heading: "Lista instancji do wyciszenia" _theme: explore: "Przeglądaj motywy" install: "Zainstaluj motyw" @@ -950,6 +1094,7 @@ _sfx: notification: "Powiadomienia" chat: "Wiadomości" chatBg: "Rozmowy (tło)" + antenna: "Anteny" channel: "Powiadomienia kanału" _ago: future: "W przyszłości" @@ -969,12 +1114,30 @@ _time: _tutorial: title: "Jak korzystać z Misskey" step1_1: "Witaj!" + step1_2: "Ta strona nazywa się „oś czasu”. Pokazuje chronologicznie uporządkowane wpisy osób, które „śledzisz”." step1_3: "Twoja oś czasu jest jeszcze pusta, ponieważ nie opublikowałeś(-aś) jeszcze żadnych wpisów i nie obserwujesz jeszcze nikogo." step2_1: "Ukończmy konfigurację profilu zanim utworzymy wpis lub zaczniemy kogoś obserwować." + step2_2: "Podanie pewnych informacji o tym, kim jesteś, ułatwi innym określenie, czy chcą widzieć Twoje wpisy lub Cię obserwować." step3_1: "Zakończyłeś(-aś) konfigurację profilu?" + step3_2: "Następnie spróbujmy opublikować wpis. Możesz to zrobić, naciskając przycisk z ikoną ołówka na ekranie." step3_3: "Wypełnij pole i kliknij przycisk w prawym górnym rogu by wysłać post." + step3_4: "Nie masz nic do powiedzenia? Spróbuj \"ustawiam swój misskey\"!" + step4_1: "Zakończyłeś publikowanie pierwszego wpisu?" + step4_2: "Hurra! Teraz Twój pierwszy wpis powinien być wyświetlany na Twojej osi czasu." + step5_1: "Teraz spróbujmy ożywić Twoją oś czasu, przez zaobserwowanie innych ludzi." + step5_2: "{featured} pokaże Ci popularne wpisy na tej instancji. {explore} pozwoli Ci znaleźć popularnych użytkowników. Spróbuj znaleźć tam osoby, które chcesz obserwować!" + step5_3: "Aby obserwować innych użytkowników, kliknij ich ikonę i naciśnij przycisk \"Obserwuj\" na ich profilu." + step5_4: "Jeśli inny użytkownik ma ikonę kłódki obok swojej nazwy, może minąć trochę czasu, zanim ten użytkownik ręcznie zatwierdzi Twoją prośbę o obserwowanie." + step6_1: "Powinieneś teraz widzieć wpisy innych użytkowników na swojej osi czasu." + step6_2: "Możesz także umieścić „reakcje” na wpisach innych osób, aby szybko na nie odpowiedzieć." + step6_3: "Aby dodać \"reakcję\", naciśnij znak \"+\" na wpisie innego użytkownika i wybierz emotikonę, którą chcesz zareagować." + step7_1: "Gratulacje! Ukończyłeś podstawowy samouczek Misskey." + step7_2: "Jeśli chcesz dowiedzieć się więcej o Misskey, wypróbuj sekcję {help}." + step7_3: "A teraz powodzenia i baw się dobrze z Misskey! 🚀" _2fa: + alreadyRegistered: "Zarejestrowałeś już urządzenie do uwierzytelniania dwuskładnikowego." registerDevice: "Zarejestruj nowe urządzenie" + registerKey: "Zarejestruj klucz bezpieczeństwa" step1: "Najpierw, zainstaluj aplikację uwierzytelniającą (taką jak {a} lub {b}) na swoim urządzeniu." step2: "Następnie, zeskanuje kod QR z ekranu." step3: "Wprowadź token podany w aplikacji, aby ukończyć konfigurację." @@ -990,6 +1153,7 @@ _permissions: "write:favorites": "Edycja Twojej listy ulubionych." "read:following": "Wyświetlanie informacji o obserwowanych" "write:following": "Obserwowanie lub cofanie obserwacji innych kont" + "read:messaging": "Zobacz swoje czaty" "read:mutes": "Wyświetlanie listy osób, które wyciszyłeś(-aś)" "write:mutes": "Edycja listy osób, które wyciszyłeś(-aś)" "read:notifications": "Wyświetlanie powiadomień" @@ -1003,6 +1167,10 @@ _permissions: "write:page-likes": "Edycja polubień na stronach" "read:user-groups": "Wyświetlanie grup użytkownika" "write:user-groups": "Edycja lub usuwanie grup użytkownika" + "read:channels": "Zobacz swoje kanały" + "write:channels": "Edytuj swoje kanały" + "read:gallery": "Zobacz swoją galerię" + "write:gallery": "Edytuj swoją galerię" _auth: shareAccess: "Czy chcesz autoryzować „{name}” do dostępu do tego konta?" permissionAsk: "Ta aplikacja wymaga następujących uprawnień:" @@ -1021,12 +1189,21 @@ _widgets: calendar: "Kalendarz" trends: "Na czasie" clock: "Zegar" + rss: "Czytnik RSS" activity: "Aktywność" photos: "Zdjęcia" + digitalClock: "Zegar cyfrowy" + unixClock: "Zegar UNIX" federation: "Federacja" - postForm: "Utwórz wpis" + instanceCloud: "Chmura instancji" + postForm: "Formularz tworzenia wpisu" + slideshow: "Pokaz slajdów" button: "Przycisk" + onlineUsers: "Użytkownicy online" jobQueue: "Kolejka zadań" + serverMetric: "Metryka serwera" + aiscript: "Konsola AiScript" + aichan: "Ai" _cw: hide: "Ukryj" show: "Załaduj więcej" @@ -1393,9 +1570,11 @@ _notification: youReceivedFollowRequest: "Otrzymałeś(-aś) prośbę o możliwość obserwacji" yourFollowRequestAccepted: "Twoja prośba o możliwość obserwacji została przyjęta" youWereInvitedToGroup: "Zaproszony(-a) do grupy" + pollEnded: "Wyniki ankiety stały się dostępne" + emptyPushNotificationMessage: "Powiadomienia push zostały zaktualizowane" _types: all: "Wszystkie" - follow: "Obserwowani" + follow: "Nowi obserwujący" mention: "Wspomnij" reply: "Odpowiedzi" renote: "Udostępnij" @@ -1407,12 +1586,14 @@ _notification: groupInvited: "Zaproszono do grup" app: "Powiadomienia z aplikacji" _actions: + followBack: "zaobserwował cię z powrotem" reply: "Odpowiedz" renote: "Udostępnij" _deck: alwaysShowMainColumn: "Zawsze pokazuj główną kolumnę" columnAlign: "Wyrównaj kolumny" addColumn: "Dodaj kolumnę" + configureColumn: "Ustawienia kolumny" swapLeft: "Przesuń w lewo" swapRight: "Przesuń w prawo" swapUp: "Zamień z powyższym" @@ -1420,6 +1601,9 @@ _deck: stackLeft: "Przypnij do lewej" popRight: "Odepnij w prawo" profile: "Profil" + newProfile: "Nowy profil" + deleteProfile: "Usuń profil" + widgetsIntroduction: "Wybierz \"Edytuj widżety\" w menu kolumny i dodaj widżet." _columns: main: "Główna" widgets: "Widżety" diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index 2a6988e9b..afce5ec02 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -562,6 +562,7 @@ author: "Автор" leaveConfirm: "Вы не сохранили изменения. Хотите выйти и потерять их?" manage: "Управление" plugins: "Расширения" +preferencesBackups: "Резервная копия" deck: "Пульт" undeck: "Покинуть пульт" useBlurEffectForModal: "Размывка под формой поверх всего" diff --git a/locales/th-TH.yml b/locales/th-TH.yml index 6c731ea6e..6f794a7c7 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -562,6 +562,7 @@ author: "ผู้เขียน" leaveConfirm: "คุณมีการเปลี่ยนแปลงที่ไม่ได้บันทึกนะ นายต้องการทิ้งการเปลี่ยนแปลงเหล่านั้นหรอ?" manage: "การจัดการ" plugins: "ปลั๊กอิน" +preferencesBackups: "ตั้งค่าการสำรองข้อมูล" deck: "เด็ค" undeck: "ออกจากเด็ค" useBlurEffectForModal: "ใช้เอฟเฟกต์เบลอสำหรับโมดอล" @@ -598,7 +599,7 @@ wordMute: "ปิดเสียงคำ" regexpError: "ข้อผิดพลาดของนิพจน์ทั่วไป" regexpErrorDescription: "เกิดข้อผิดพลาดในนิพจน์ทั่วไปในบรรทัดที่ {line} ของการปิดเสียงคำ {tab} ของคุณ:" instanceMute: "ปิดเสียง อินสแตนซ์" -userSaysSomething: "{ชื่อ} พูดอะไรบางอย่าง" +userSaysSomething: "{name} พูดอะไรบางอย่าง" makeActive: "เปิดใช้งาน" display: "แสดงผล" copy: "คัดลอก" @@ -913,13 +914,89 @@ _ffVisibility: _signup: almostThere: "เกือบจะมี" emailAddressInfo: "โปรดกรอกอีเมลของคุณ มันจะไม่เปิดเผยต่อสาธารณะ" + emailSent: "เราได้ส่งอีเมลยืนยันไปยังที่อยู่อีเมลของคุณแล้วนะ ({email}) โปรดคลิกลิงก์ที่รวมไว้เพื่อสร้างบัญชีให้เสร็จสิ้น" +_accountDelete: + accountDelete: "ลบบัญชีผู้ใช้" + mayTakeTime: "เนื่องจากการลบบัญชีนี้จะเป็นกระบวนการที่ต้องใช้ทรัพยากรมาก จึงอาจจะต้องใช้เวลาสักครู่ถึงจะเสร็จสมบูรณ์ ทั้งนี้ขึ้นอยู่กับจำนวนเนื้อหาที่คุณสร้างและจำนวนไฟล์ที่คุณอัปโหลดนะ" + sendEmail: "เมื่อการลบบัญชีนี้เสร็จสิ้น เราอาจจะส่งอีเมลไปยังที่อยู่อีเมลของคุณที่เคยลงทะเบียนไว้กับบัญชีนี้นะ" + requestAccountDelete: "ร้องขอให้ลบบัญชี" + started: "การลบได้เริ่มต้นขึ้น" + inProgress: "ปัจจุบันกำลังดำเนินการลบอยู่" _ad: back: "ย้อนกลับ" + reduceFrequencyOfThisAd: "แสดงโฆษณานี้ให้น้อยลง" +_forgotPassword: + enterEmail: "ป้อนที่อยู่อีเมลที่คุณเคยใช้ในการลงทะเบียนไว้ ลิงก์ที่คุณสามารถรีเซ็ตรหัสผ่านได้นั้นจะถูกส่งไปนะ" + ifNoEmail: "ถ้าหากคุณไม่ได้ใช้อีเมลระหว่างการลงทะเบียน กรุณาติดต่อผู้ดูแลระบบอินสแตนซ์แทนนะ" + contactAdmin: "อินสแตนซ์นี้ไม่รองรับการใช้งานที่อยู่อีเมลนี้ กรุณาติดต่อผู้ดูแลระบบอินสแตนซ์เพื่อรีเซ็ตรหัสผ่านของคุณแทน" +_gallery: + my: "แกลลอรี่ของฉัน" + liked: "โพสต์ที่ถูกใจ" + like: "ชื่นชอบ" + unlike: "ลบไลค์" _email: _follow: title: "ได้ติดตามคุณ" + _receiveFollowRequest: + title: "คุณได้รับคำขอติดตาม" +_plugin: + install: "ติดตั้งปลั๊กอิน" + installWarn: "กรุณาอย่าติดตั้งปลั๊กอินที่ไม่น่าเชื่อถือนะคะ" + manage: "จัดการปลั๊กอิน" +_preferencesBackups: + list: "สร้างการสำรองข้อมูล" + saveNew: "บันทึกใหม่" + loadFile: "โหลดจากไฟล์" + apply: "นำไปใช้กับอุปกรณ์นี้" + save: "บันทึก" + inputName: "กรุณาป้อนชื่อสำหรับข้อมูลสำรองนี้" + cannotSave: "การบันทึกล้มเหลว" + nameAlreadyExists: "มีข้อมูลสำรองชื่อ \"{name}\" นี้อยู่แล้ว กรุณาป้อนชื่ออื่นนะ" + applyConfirm: "คุณต้องการใช้ข้อมูลสำรอง \"{name}\" กับอุปกรณ์นี้อย่างงั้นจริงหรอ การตั้งค่าที่มีอยู่ของอุปกรณ์นี้จะถูกเขียนทับนะ" + saveConfirm: "บันทึกข้อมูลสำรองเป็น {name} มั้ย?" + deleteConfirm: "ลบข้อมูลสำรอง {name} มั้ย?" + renameConfirm: "เปลี่ยนชื่อข้อมูลสำรองนี้จาก \"{old}\" เป็น \"{new}\" หรือป่าว" + noBackups: "ไม่มีข้อมูลสำรองนะ คุณสามารถสำรองข้อมูลการตั้งค่าไคลเอนต์ของคุณบนเซิร์ฟเวอร์นี้โดยใช้ \"สร้างการสำรองข้อมูลใหม่\"ได้นะ" + createdAt: "สร้างเมื่อ: {date} {time}" + updatedAt: "อัปเดตเมื่อ: {date} {time}" + cannotLoad: "การโหลดล้มเหลว" + invalidFile: "รูปแบบไฟล์ไม่ถูกต้องนะ" +_registry: + scope: "สโคป" + key: "คีย์" + keys: "คีย์" + domain: "โดเมน" + createKey: "สร้างคีย์" +_aboutMisskey: + about: "Misskey เป็นซอฟต์แวร์โอเพ่นซอร์สที่ถูกพัฒนาโดย Syuilo ตั้งแต่ปี 2014" + contributors: "ผู้สนับสนุนหลัก" + allContributors: "ผู้มีส่วนร่วมทั้งหมด" + source: "ซอร์สโค้ด" + translation: "รับแปลภาษา Misskey" + donate: "บริจาคให้กับ Misskey" + morePatrons: "เราขอขอบคุณสำหรับความช่วยเหลือจากผู้ช่วยอื่นๆ ที่ไม่ได้ระบุไว้ที่นี่นะ ขอขอบคุณ! 🥰" + patrons: "สมาชิกพันธมิตร" +_nsfw: + respect: "ซ่อนสื่อ NSFW" + ignore: "อย่าซ่อนสื่อ NSFW" + force: "ซ่อนสื่อทั้งหมด" _mfm: + cheatSheet: "โค้ด MFM Cheat Sheet" + intro: "MFM เป็นภาษามาร์กอัปพิเศษเฉพาะของ Misskey ที่สามารถใช้ได้ในหลายที่ คุณยังสามารถดูรายการไวยากรณ์ MFM ที่มีอยู่ทั้งหมดได้ที่นี่นะ" + dummy: "Misskey ขยายโลกของ Fediverse" mention: "กล่าวถึง" + mentionDescription: "คุณสามารถระบุผู้ใช้โดยใช้ At-Symbol และชื่อผู้ใช้ได้นะ" + hashtag: "แฮชแท็ก" + hashtagDescription: "คุณสามารถระบุชื่อแฮชแท็กได้โดยใช้เครื่องหมายตัวเลขและข้อความได้นะ" + url: "URL" + urlDescription: "สามารถแสดง URL ได้นะ" + link: "ลิงก์" + linkDescription: "เจาะจงเฉพาะ ส่วนของข้อความที่สามารถแสดงเป็น URL ได้" + bold: "ตัวหนา" + boldDescription: "ไฮไลท์ตัวอักษรโดยทำให้หนาขึ้น" + small: "ขนาดเล็ก" + smallDescription: "แสดงผลเนื้อหาขนาดเล็กและบาง" + center: "เซ็นเตอร์" centerDescription: "แสดงผลเนื้อหาเป็นศูนย์กลาง" inlineCode: "โค้ด (อินไลน์)" inlineCodeDescription: "แสดงผลการเน้นไวยากรณ์แบบอินไลน์สำหรับโค้ด (โปรแกรม)" @@ -936,9 +1013,117 @@ _mfm: search: "ค้นหา" searchDescription: "แสดงผลกล่องค้นหาพร้อมกับข้อความที่ป้อนไว้ล่วงหน้า" flip: "พลิก" + flipDescription: "พลิกเนื้อหาในแนวนอนหรือแนวตั้ง" + jelly: "แอนิเมชั่น (เยลลี่)" + jellyDescription: "ให้เนื้อหาเป็นแอนิเมชั่นเหมือนเยลลี่" + tada: "แอนิเมชั่น (ธาดา)" + tadaDescription: "ให้เนื้อหาเป็นแอนิเมชั่นเหมือน \"ทาด้า!\"" + jump: "อนิเมชั่น (กระโดด)" + jumpDescription: "ให้เนื้อหามีภาพเคลื่อนไหวแบบกระโดด" + bounce: "อนิเมชั่น (เด้ง)" + bounceDescription: "ให้เนื้อหามีอนิเมชั่นเด้ง" + shake: "อนิเมชั่น (เขย่า)" + shakeDescription: "ให้เนื้อหามีภาพเคลื่อนไหวสั่น" + twitch: "แอนิเมชั่น (Twitch)" + twitchDescription: "ให้เนื้อหามีแอนิเมชั่นกระตุกอย่างแรง" + spin: "แอนิเมชั่น (สปิน)" + spinDescription: "ให้เนื้อหาเป็นภาพเคลื่อนไหวแบบหมุน" + x2: "ขนาดใหญ่" + x2Description: "แสดงเนื้อหาที่ใหญ่ขึ้น" + x3: "ใหญ่มาก" + x3Description: "แสดงเนื้อหาอีเว้นท์ที่ใหญ่ขึ้น" + x4: "ใหญ่อย่างไม่น่าเชื่อ" + x4Description: "แสดงผลเนื้อหาที่ใหญ่กว่าใหญ่กว่าขนาดใหญ่" + blur: "เบลอ" + blurDescription: "เบลอเนื้อหา จะแสดงผลอย่างชัดเจนต่อเมื่อวางเมาส์เหนือ" + font: "ตัวอักษร" + fontDescription: "ตั้งค่าตัวอักษรเพื่อแสดงเนื้อหาใน" + rainbow: "สายรุ้ง" + rainbowDescription: "ทำให้เนื้อหานั้นปรากฏเป็นสีรุ้ง" + sparkle: "กลิตเตอร์" + sparkleDescription: "ให้เนื้อหานั้นมีเอฟเฟกต์แบบอนุภาคประกาย" + rotate: "หมุนหน้าจอ" + rotateDescription: "เปลี่ยนเนื้อหาตามด้วยมุมที่ระบุไว้" + plain: "เรียบง่าย" + plainDescription: "ปิดการใช้งานเอฟเฟกต์ของ MFM ทั้งหมดที่มีอยู่ในเอฟเฟกต์ MFM นี้" +_instanceTicker: + none: "ไม่ต้องแสดง" + remote: "แสดงสำหรับผู้ใช้ระยะไกล" + always: "แสดงเสมอ" +_serverDisconnectedBehavior: + reload: "โหลดใหม่โดยอัตโนมัติ" + dialog: "แสดงกล่องโต้ตอบคำเตือน" + quiet: "แสดงคำเตือนที่ไม่เป็นการรบกวน" +_channel: + create: "สร้างแชนแนลใหม่" + edit: "แก้ไขแชนแนล" + setBanner: "เซตแบนเนอร์" + removeBanner: "ลบแบนเนอร์" + featured: "เทรนด์" + owned: "เจ้าของ" + following: "ติดตามแล้ว" + usersCount: "{n} ผู้เข้าร่วม" + notesCount: "{n} โน้ต" +_menuDisplay: + sideFull: "ด้านข้าง" + sideIcon: "ด้านข้าง (ไอคอน)" + top: "ท็อป" + hide: "ซ่อน" +_wordMute: + muteWords: "ปิดเสียงคำ" + muteWordsDescription: "คั่นด้วยช่องว่างสำหรับเงื่อนไข AND หรือด้วยการขึ้นบรรทัดใหม่สำหรับเงื่อนไข OR นะ" + muteWordsDescription2: "ล้อมรอบคีย์เวิร์ดด้วยเครื่องหมายทับเพื่อใช้นิพจน์ทั่วไป" + softDescription: "ซ่อนโน้ตให้ตรงตามเงื่อนไขที่ตั้งไว้จากไทม์ไลน์" + hardDescription: "ป้องกันไม่ให้โน้ตย่อที่ตรงตามเงื่อนไขที่ตั้งไว้ไม่ให้ถูกเพิ่มลงในไทม์ไลน์ นอกจากนี้ โน้ตเหล่านี้จะไม่ถูกเพิ่มลงในไทม์ไลน์แม้ว่าจะมีการเปลี่ยนแปลงเงื่อนไขยังไงก็ตาม" + soft: "ซอฟ" + hard: "ยาก" + mutedNotes: "ปิดเสียงโน้ต" +_instanceMute: + instanceMuteDescription: "การดำเนินการนี้จะปิดเสียง\"โน้ต/รีโน้ต\"จากอินสแตนซ์ที่อยู่ในรายการ รวมถึงบันทึกของผู้ใช้ที่ตอบกลับผู้ใช้จากอินสแตนซ์ที่ปิดเสียง" + instanceMuteDescription2: "คั่นด้วยการขึ้นบรรทัดใหม่" + title: "ซ่อนโน้ตจากอินสแตนซ์ที่มีอยู่ในรายการ" + heading: "รายชื่ออินสแตนซ์ที่ถูกปิดเสียง" _theme: + explore: "สำรวจธีม" + install: "ติดตั้งธีม" + manage: "จัดการธีม" + code: "โค้ดธีม" description: "รายละเอียด" + installed: "{name} ได้รับการติดตั้ง" + installedThemes: "ธีมที่ติดตั้ง" + builtinThemes: "ธีมในตัว" + alreadyInstalled: "ธีมนี้ได้รับการติดตั้งแล้ว" + invalid: "รูปแบบของธีมนี้ไม่ถูกต้องนะ" + make: "ทำธีม" + base: "ฐาน" + addConstant: "เพิ่มค่าคงที่" + constant: "ตัวแปร" + defaultValue: "ค่าเริ่มต้น" + color: "สี" + refProp: "อ้างอิงคุณสมบัติ" + refConst: "อ้างอิงค่าคงที่" + key: "คีย์" + func: "ฟังก์ชัน" + funcKind: "ประเภทฟังก์ชัน" + argument: "อากิวเม้นต์" + basedProp: "ทรัพย์สินอ้างอิง" + alpha: "ความทึบแสง" + darken: "มืดลง" + lighten: "สว่าง" + inputConstantName: "ป้อนชื่อสำหรับค่าคงที่นี้" + importInfo: "ถ้าหากต้องการป้อนโค้ดที่นี่ คุณยังสามารถนำเข้าไปยังโปรแกรมแก้ไขธีมได้" + deleteConstantConfirm: "คุณต้องการลบค่าคงที่ {const} หรือป่าว?" keys: + accent: "เน้น" + bg: "ภาพพื้นหลัง" + fg: "ข้อความ" + focus: "โฟกัส" + indicator: "ตัวบ่งชี้" + panel: "แผงควบคุม" + shadow: "เงา" + header: "ส่วนหัว" + navBg: "พื้นหลังแถบด้านข้าง" + navFg: "ข้อความแถบด้านข้าง" mention: "กล่าวถึง" renote: "รีโน้ต" divider: "ตัวแบ่ง" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 182cebf5a..1b6f29667 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -141,7 +141,7 @@ cacheRemoteFilesDescription: "当禁用此设定时远程文件将直接从远 flagAsBot: "这是一个机器人账号" flagAsBotDescription: "如果此帐户由程序控制,请启用此项。启用后,此标志可以帮助其他开发人员防止机器人之间产生无限互动的行为,并让Misskey的内部系统将此帐户识别为机器人。" flagAsCat: "将这个账户设定为一只猫" -flagAsCatDescription: "如果您想表明此帐户是一只猫,请打开此标志。" +flagAsCatDescription: "如果您想表明此帐户是一只猫,请打开此标志。\n开启后,会在您的头像上出现猫耳朵,并将你的帖子中的「na」替换为「nya」,日文同理。" flagShowTimelineReplies: "在时间线上显示帖子的回复" flagShowTimelineRepliesDescription: "启用时,时间线除了显示用户的帖子外,还会显示其他用户对帖子的回复。" autoAcceptFollowed: "自动允许关注者的关注" @@ -252,7 +252,7 @@ messageRead: "已读" noMoreHistory: "没有更多的历史记录" startMessaging: "添加聊天" nUsersRead: "{n}人已读" -agreeTo: "{0}勾选则表示已阅读并同意" +agreeTo: "勾选则表示已阅读并同意{0}" tos: "服务条款" start: "开始" home: "首页" @@ -668,7 +668,7 @@ yes: "是" no: "否" driveFilesCount: "网盘的文件数" driveUsage: "网盘的空间用量" -noCrawle: "要求搜索引擎不索引该站点" +noCrawle: "要求搜索引擎不索引该用户" noCrawleDescription: "要求搜索引擎不要收录(索引)您的用户页面,帖子,页面等。" lockedAccountInfo: "即使通过了关注请求,只要您不将帖子可见范围设置成“关注者”,任何人都可以看到您的帖子。" alwaysMarkSensitive: "默认将媒体文件标记为敏感内容" @@ -747,7 +747,7 @@ userInfo: "用户信息" unknown: "未知" onlineStatus: "在线状态" hideOnlineStatus: "隐藏在线状态" -hideOnlineStatusDescription: "隐藏在线状态后,可能会降低例如搜索等功能的便利性。" +hideOnlineStatusDescription: "隐藏在线状态后,可能会降低搜索等功能的便利性。" online: "在线" active: "活动" offline: "离线" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index c7b4fe77f..3449da99a 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -883,8 +883,8 @@ sensitiveMediaDetection: "敏感性媒體的檢測" localOnly: "僅限本地" remoteOnly: "僅限遠端" failedToUpload: "上傳失敗" -cannotUploadBecauseInappropriate: "由於判定可能包含不適當的內容,因此無法上船。" -cannotUploadBecauseNoFreeSpace: "由於雲端硬碟沒有可用空間,因此無法上船>" +cannotUploadBecauseInappropriate: "由於判定可能包含不適當的內容,因此無法上傳。" +cannotUploadBecauseNoFreeSpace: "由於雲端硬碟沒有可用空間,因此無法上傳。" beta: "Beta" enableAutoSensitive: "自動NSFW判定" enableAutoSensitiveDescription: "如果可用,請利用機器學習在媒體上自動設置 NSFW 旗標。 即使關閉此功能,依實例而定也可能會自動設置。" @@ -917,7 +917,7 @@ _signup: emailSent: "已將確認郵件發送至您輸入的電子郵件地址 ({email})。請開啟電子郵件中的連結以完成帳戶創建。" _accountDelete: accountDelete: "刪除帳戶" - mayTakeTime: "刪除帳戶的處理負荷較大,如果帳戶產生的內容數量上船的檔案數量較多的話,就需要花费一段時間才能完成。" + mayTakeTime: "刪除帳戶的處理負荷較大,如果帳戶產生的內容數量上傳的檔案數量較多的話,就需要花费一段時間才能完成。" sendEmail: "帳戶删除完成後,將向註冊地電子郵件地址發送通知。" requestAccountDelete: "刪除帳戶請求" started: "已開始刪除作業。" diff --git a/package.json b/package.json index 739340d96..304fa2c22 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "12.118.1", + "version": "12.120.0-alpha.4", "codename": "indigo", "repository": { "type": "git", @@ -14,11 +14,12 @@ ], "private": true, "scripts": { - "build": "yarn workspaces foreach run build && yarn run gulp", - "start": "yarn workspace backend run start", - "start:test": "yarn workspace backend run start:test", + "postinstall": "node ./scripts/install-packages.js", + "build": "node ./scripts/build.js", + "start": "cd packages/backend && node --experimental-json-modules ./built/boot/index.js", + "start:test": "cd packages/backend && cross-env NODE_ENV=test node --experimental-json-modules ./built/boot/index.js", "init": "yarn migrate", - "migrate": "yarn workspace backend run migrate", + "migrate": "cd packages/backend && npx typeorm migration:run -d ormconfig.js", "migrateandstart": "yarn migrate && yarn start", "gulp": "gulp build", "watch": "yarn dev", @@ -27,8 +28,10 @@ "cy:open": "cypress open --browser --e2e --config-file=cypress.config.ts", "cy:run": "cypress run", "e2e": "start-server-and-test start:test http://localhost:61812 cy:run", - "mocha": "yarn workspace backend run mocha", - "test": "yarn mocha", + "jest": "cd packages/backend && cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --detectOpenHandles --runInBand", + "jest-and-coverage": "cd packages/backend && cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --detectOpenHandles --runInBand", + "test": "yarn jest", + "test-and-coverage": "yarn jest-and-coverage", "format": "gulp format", "clean": "node ./scripts/clean.js", "clean-all": "node ./scripts/clean-all.js", @@ -52,13 +55,10 @@ "@types/gulp": "4.0.9", "@types/gulp-rename": "2.0.1", "@typescript-eslint/eslint-plugin": "latest", - "@typescript-eslint/parser": "5.33.0", + "@typescript-eslint/parser": "5.38.0", "cross-env": "7.0.3", - "cypress": "10.4.0", - "eslint-plugin-import": "^2.26.0", - "eslint-plugin-vue": "latest", + "cypress": "10.8.0", "start-server-and-test": "1.14.0", - "typescript": "4.7.4", - "vue-eslint-parser": "^9.0.2" + "typescript": "4.8.3" } } diff --git a/packages/backend/.madgerc b/packages/backend/.madgerc new file mode 100644 index 000000000..f0a816a0a --- /dev/null +++ b/packages/backend/.madgerc @@ -0,0 +1,3 @@ +{ + "tsConfig": "./tsconfig.json" +} diff --git a/packages/backend/.mocharc.json b/packages/backend/.mocharc.json deleted file mode 100644 index f836f9e90..000000000 --- a/packages/backend/.mocharc.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extension": ["ts","js","cjs","mjs"], - "node-option": [ - "experimental-specifier-resolution=node", - "loader=./test/loader.js" - ], - "slow": 1000, - "timeout": 30000, - "exit": true -} diff --git a/packages/backend/jest-resolver.cjs b/packages/backend/jest-resolver.cjs new file mode 100644 index 000000000..4424b800d --- /dev/null +++ b/packages/backend/jest-resolver.cjs @@ -0,0 +1,14 @@ +// https://github.com/facebook/jest/issues/12270#issuecomment-1194746382 + +const nativeModule = require('node:module'); + +function resolver(module, options) { + const { basedir, defaultResolver } = options; + try { + return defaultResolver(module, options); + } catch (error) { + return nativeModule.createRequire(basedir).resolve(module); + } +} + +module.exports = resolver; diff --git a/packages/backend/jest.config.cjs b/packages/backend/jest.config.cjs new file mode 100644 index 000000000..4f85bd36b --- /dev/null +++ b/packages/backend/jest.config.cjs @@ -0,0 +1,214 @@ +/* +* For a detailed explanation regarding each configuration property and type check, visit: +* https://jestjs.io/docs/en/configuration.html +*/ + +module.exports = { + // All imported modules in your tests should be mocked automatically + // automock: false, + + // Stop running tests after `n` failures + // bail: 0, + + // The directory where Jest should store its cached dependency information + // cacheDirectory: "C:\\Users\\ai\\AppData\\Local\\Temp\\jest", + + // Automatically clear mock calls and instances between every test + // clearMocks: false, + + // Indicates whether the coverage information should be collected while executing the test + // collectCoverage: false, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + collectCoverageFrom: ['src/**/*.ts'], + + // The directory where Jest should output its coverage files + coverageDirectory: "coverage", + + // An array of regexp pattern strings used to skip coverage collection + // coveragePathIgnorePatterns: [ + // "\\\\node_modules\\\\" + // ], + + // Indicates which provider should be used to instrument code for coverage + coverageProvider: "v8", + + // A list of reporter names that Jest uses when writing coverage reports + // coverageReporters: [ + // "json", + // "text", + // "lcov", + // "clover" + // ], + + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: undefined, + + // A path to a custom dependency extractor + // dependencyExtractor: undefined, + + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, + + // Force coverage collection from ignored files using an array of glob patterns + // forceCoverageMatch: [], + + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: undefined, + + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: undefined, + + // A set of global variables that need to be available in all test environments + globals: { + "ts-jest": { + "useESM": true, + tsconfig: "test/tsconfig.json", + diagnostics: { + exclude: ['**'], + }, + } + }, + + // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. + // maxWorkers: "50%", + + // An array of directory names to be searched recursively up from the requiring module's location + // moduleDirectories: [ + // "node_modules" + // ], + + // An array of file extensions your modules use + // moduleFileExtensions: [ + // "js", + // "json", + // "jsx", + // "ts", + // "tsx", + // "node" + // ], + + // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module + moduleNameMapper: { + "^@/(.*?).js": "/src/$1.ts", + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], + + // Activates notifications for test results + // notify: false, + + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "failure-change", + + // A preset that is used as a base for Jest's configuration + preset: "ts-jest/presets/js-with-ts-esm", + + // Run tests from one or more projects + // projects: undefined, + + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, + + // Automatically reset mock state between every test + // resetMocks: false, + + // Reset the module registry before running each individual test + // resetModules: false, + + // A path to a custom resolver + resolver: './jest-resolver.cjs', + + // Automatically restore mock state between every test + // restoreMocks: false, + + // The root directory that Jest should scan for tests and modules within + // rootDir: undefined, + + // A list of paths to directories that Jest should use to search for files in + roots: [ + "" + ], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + // setupFiles: [], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test + // setupFilesAfterEnv: [], + + // The number of seconds after which a test is considered as slow and reported as such in the results. + // slowTestThreshold: 5, + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], + + // The test environment that will be used for testing + testEnvironment: "node", + + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + testMatch: [ + "/test/unit/**/*.ts", + //"/test/e2e/**/*.ts" + ], + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + // testPathIgnorePatterns: [ + // "\\\\node_modules\\\\" + // ], + + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], + + // This option allows the use of a custom results processor + // testResultsProcessor: undefined, + + // This option allows use of a custom test runner + // testRunner: "jasmine2", + + // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href + // testURL: "http://localhost", + + // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" + // timers: "real", + + // A map from regular expressions to paths to transformers + transform: { + "": [ + "ts-jest", + { + "useESM": true + } + ] + }, + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + // transformIgnorePatterns: [ + // "\\\\node_modules\\\\", + // "\\.pnp\\.[^\\\\]+$" + // ], + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + // verbose: undefined, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true, + + extensionsToTreatAsEsm: ['.ts'], +}; diff --git a/packages/backend/ormconfig.js b/packages/backend/ormconfig.js index a4e903aba..32c26f7b6 100644 --- a/packages/backend/ormconfig.js +++ b/packages/backend/ormconfig.js @@ -1,6 +1,8 @@ import { DataSource } from 'typeorm'; -import config from './built/config/index.js'; -import { entities } from './built/db/postgre.js'; +import { loadConfig } from './built/config.js'; +import { entities } from './built/postgre.js'; + +const config = loadConfig(); export default new DataSource({ type: 'postgres', diff --git a/packages/backend/package.json b/packages/backend/package.json index 75add8cf7..c236fc56f 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -10,32 +10,42 @@ "build": "tsc -p tsconfig.json || echo done. && tsc-alias -p tsconfig.json", "watch": "node watch.mjs", "lint": "eslint --quiet \"src/**/*.ts\"", - "mocha": "NODE_ENV=test TS_NODE_FILES=true TS_NODE_TRANSPILE_ONLY=true TS_NODE_PROJECT=\"./test/tsconfig.json\" mocha", - "test": "yarn mocha" + "jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --detectOpenHandles --runInBand", + "jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --detectOpenHandles --runInBand", + "test": "yarn jest", + "test-and-coverage": "yarn jest-and-coverage" + }, + "resolutions": { + "chokidar": "^3.3.1", + "lodash": "^4.17.21" }, "optionalDependencies": { - "@tensorflow/tfjs-node": "3.19.0" + "@tensorflow/tfjs-node": "3.20.0" }, "dependencies": { - "@bull-board/api": "4.2.2", - "@bull-board/koa": "4.2.2", - "@bull-board/ui": "4.2.2", + "@bull-board/api": "4.3.1", + "@bull-board/koa": "4.3.1", + "@bull-board/ui": "4.3.1", "@discordapp/twemoji": "14.0.2", "@elastic/elasticsearch": "7.17.0", "@koa/cors": "3.3.0", "@koa/multer": "3.0.0", "@koa/router": "9.0.1", + "@nestjs/common": "9.1.1", + "@nestjs/core": "9.1.1", + "@nestjs/testing": "9.1.1", "@peertube/http-signature": "1.7.0", "@sinonjs/fake-timers": "9.1.2", "@syuilo/aiscript": "0.11.1", + "@types/pg": "8.6.5", "ajv": "8.11.0", "archiver": "5.3.1", "autobind-decorator": "2.4.0", "autwh": "0.1.0", - "aws-sdk": "2.1194.0", + "aws-sdk": "2.1213.0", "bcryptjs": "2.4.3", "blurhash": "1.1.5", - "bull": "4.8.5", + "bull": "4.9.0", "cacheable-lookup": "6.1.0", "cbor": "8.1.0", "chalk": "5.0.1", @@ -44,14 +54,14 @@ "cli-highlight": "2.1.11", "color-convert": "2.0.1", "content-disposition": "0.5.4", - "date-fns": "2.29.1", + "date-fns": "2.29.3", "deep-email-validator": "0.1.21", "escape-regexp": "0.0.1", "feed": "4.2.2", - "file-type": "17.1.6", + "file-type": "18.0.0", "fluent-ffmpeg": "2.1.2", - "got": "12.3.1", - "hpagent": "0.1.2", + "got": "12.5.0", + "hpagent": "1.0.0", "ioredis": "4.28.5", "ip-cidr": "3.0.10", "is-svg": "4.3.2", @@ -59,8 +69,8 @@ "jsdom": "20.0.0", "json5": "2.2.1", "json5-loader": "4.0.1", - "jsonld": "6.0.0", - "jsrsasign": "10.5.26", + "jsonld": "8.1.0", + "jsrsasign": "10.5.27", "koa": "2.13.4", "koa-bodyparser": "4.3.0", "koa-favicon": "2.1.0", @@ -73,17 +83,15 @@ "mfm-js": "0.23.0", "mime-types": "2.1.35", "misskey-js": "0.0.14", - "mocha": "10.0.0", "ms": "3.0.0-canary.1", "multer": "1.4.4", "nested-property": "4.0.0", "node-fetch": "3.2.10", "nodemailer": "6.7.8", - "nsfwjs": "2.4.1", - "oauth": "^0.9.15", + "nsfwjs": "2.4.2", "os-utils": "0.0.14", - "parse5": "7.0.0", - "pg": "8.7.3", + "parse5": "7.1.1", + "pg": "8.8.0", "private-ip": "2.3.4", "probe-image-size": "7.2.3", "promise-limit": "2.7.0", @@ -99,8 +107,9 @@ "rename": "1.0.4", "rndstr": "1.0.0", "rss-parser": "3.12.0", + "rxjs": "7.5.6", "s-age": "1.1.2", - "sanitize-html": "2.7.1", + "sanitize-html": "2.7.2", "semver": "7.3.7", "sharp": "0.30.6", "speakeasy": "2.0.0", @@ -108,36 +117,37 @@ "stringz": "2.1.0", "summaly": "2.7.0", "syslog-pro": "1.0.0", - "systeminformation": "5.12.5", + "systeminformation": "5.12.6", "tinycolor2": "1.4.2", "tmp": "0.2.1", - "ts-loader": "9.3.1", + "ts-loader": "9.4.0", "ts-node": "10.9.1", "tsc-alias": "1.7.0", "tsconfig-paths": "4.1.0", "twemoji-parser": "14.0.0", - "typeorm": "0.3.7", + "typeorm": "0.3.10", "ulid": "2.3.0", "unzipper": "0.10.11", - "uuid": "8.3.2", + "uuid": "9.0.0", "web-push": "3.5.0", "websocket": "1.0.34", "ws": "8.8.1", "xev": "3.0.2" }, "devDependencies": { - "@redocly/openapi-core": "1.0.0-beta.106", + "@redocly/openapi-core": "1.0.0-beta.108", "@types/bcryptjs": "2.4.2", "@types/bull": "3.15.9", "@types/cbor": "6.0.0", "@types/escape-regexp": "0.0.1", "@types/fluent-ffmpeg": "2.1.20", + "@types/jest": "29.0.3", "@types/js-yaml": "4.0.5", "@types/jsdom": "20.0.0", "@types/jsonld": "1.5.6", - "@types/jsrsasign": "10.5.2", + "@types/jsrsasign": "10.5.3", "@types/koa": "2.13.5", - "@types/koa-bodyparser": "4.3.7", + "@types/koa-bodyparser": "4.3.8", "@types/koa-cors": "0.0.2", "@types/koa-favicon": "2.0.21", "@types/koa-logger": "3.1.2", @@ -147,14 +157,13 @@ "@types/koa__cors": "3.3.0", "@types/koa__multer": "2.0.4", "@types/koa__router": "8.0.11", - "@types/mocha": "9.1.1", - "@types/node": "18.7.2", + "@types/node": "18.7.18", "@types/node-fetch": "3.0.3", - "@types/nodemailer": "6.4.5", + "@types/nodemailer": "6.4.6", "@types/oauth": "0.9.1", "@types/pug": "2.0.6", "@types/punycode": "2.1.0", - "@types/qrcode": "1.4.2", + "@types/qrcode": "1.5.0", "@types/random-seed": "0.3.3", "@types/ratelimiter": "3.4.3", "@types/redis": "4.0.11", @@ -170,13 +179,14 @@ "@types/web-push": "3.3.2", "@types/websocket": "1.0.5", "@types/ws": "8.5.3", - "@typescript-eslint/eslint-plugin": "5.33.0", - "@typescript-eslint/parser": "5.33.0", + "@typescript-eslint/eslint-plugin": "5.38.0", + "@typescript-eslint/parser": "5.38.0", "cross-env": "7.0.3", - "eslint": "8.21.0", + "eslint": "8.23.1", "eslint-plugin-import": "2.26.0", "execa": "6.1.0", - "form-data": "^4.0.0", - "typescript": "4.7.4" + "jest": "29.0.3", + "ts-jest": "29.0.1", + "typescript": "4.8.3" } } diff --git a/packages/backend/src/@types/http-signature.d.ts b/packages/backend/src/@types/http-signature.d.ts index d1f9cd955..f2f9bfcc3 100644 --- a/packages/backend/src/@types/http-signature.d.ts +++ b/packages/backend/src/@types/http-signature.d.ts @@ -1,5 +1,5 @@ declare module '@peertube/http-signature' { - import { IncomingMessage, ClientRequest } from 'node:http'; + import type { IncomingMessage, ClientRequest } from 'node:http'; interface ISignature { keyId: string; diff --git a/packages/backend/src/@types/koa-json-body.d.ts b/packages/backend/src/@types/koa-json-body.d.ts index 5aa8179c5..2971807d1 100644 --- a/packages/backend/src/@types/koa-json-body.d.ts +++ b/packages/backend/src/@types/koa-json-body.d.ts @@ -1,5 +1,5 @@ declare module 'koa-json-body' { - import { Middleware } from 'koa'; + import type { Middleware } from 'koa'; interface IKoaJsonBodyOptions { strict: boolean; diff --git a/packages/backend/src/@types/koa-slow.d.ts b/packages/backend/src/@types/koa-slow.d.ts index e748e2cc9..d048822ef 100644 --- a/packages/backend/src/@types/koa-slow.d.ts +++ b/packages/backend/src/@types/koa-slow.d.ts @@ -1,5 +1,5 @@ declare module 'koa-slow' { - import { Middleware } from 'koa'; + import type { Middleware } from 'koa'; interface ISlowOptions { url?: RegExp; diff --git a/packages/backend/src/@types/probe-image-size.d.ts b/packages/backend/src/@types/probe-image-size.d.ts index 11bb6c620..416e819ac 100644 --- a/packages/backend/src/@types/probe-image-size.d.ts +++ b/packages/backend/src/@types/probe-image-size.d.ts @@ -1,5 +1,5 @@ declare module 'probe-image-size' { - import { ReadStream } from 'node:fs'; + import type { ReadStream } from 'node:fs'; type ProbeOptions = { retries: 1; diff --git a/packages/backend/src/AppModule.ts b/packages/backend/src/AppModule.ts new file mode 100644 index 000000000..76de8ca82 --- /dev/null +++ b/packages/backend/src/AppModule.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { ServerModule } from '@/server/ServerModule.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { QueueProcessorModule } from '@/queue/QueueProcessorModule.js'; + +@Module({ + imports: [ + GlobalModule, + ServerModule, + QueueProcessorModule, + ], +}) +export class AppModule {} diff --git a/packages/backend/src/GlobalModule.ts b/packages/backend/src/GlobalModule.ts new file mode 100644 index 000000000..dbc5c698d --- /dev/null +++ b/packages/backend/src/GlobalModule.ts @@ -0,0 +1,63 @@ +import { Global, Inject, Module } from '@nestjs/common'; +import { Redis } from 'ioredis'; +import { DataSource } from 'typeorm'; +import { createRedisConnection } from '@/redis.js'; +import { DI } from './di-symbols.js'; +import { loadConfig } from './config.js'; +import { createPostgreDataSource } from './postgre.js'; +import { RepositoryModule } from './RepositoryModule.js'; +import type { Provider, OnApplicationShutdown } from '@nestjs/common'; + +const config = loadConfig(); + +const $config: Provider = { + provide: DI.config, + useValue: config, +}; + +const $db: Provider = { + provide: DI.db, + useFactory: async () => { + const db = createPostgreDataSource(); + return await db.initialize(); + }, +}; + +const $redis: Provider = { + provide: DI.redis, + useFactory: () => { + const redisClient = createRedisConnection(); + return redisClient; + }, +}; + +const $redisSubscriber: Provider = { + provide: DI.redisSubscriber, + useFactory: () => { + const redisSubscriber = createRedisConnection(); + redisSubscriber.subscribe(config.host); + return redisSubscriber; + }, +}; + +@Global() +@Module({ + imports: [RepositoryModule], + providers: [$config, $db, $redis, $redisSubscriber], + exports: [$config, $db, $redis, $redisSubscriber, RepositoryModule], +}) +export class GlobalModule implements OnApplicationShutdown { + constructor( + @Inject(DI.db) private db: DataSource, + @Inject(DI.redis) private redisClient: Redis, + @Inject(DI.redisSubscriber) private redisSubscriber: Redis, + ) {} + + async onApplicationShutdown(signal: string): Promise { + await Promise.all([ + this.db.destroy(), + this.redisClient.disconnect(), + this.redisSubscriber.disconnect(), + ]); + } +} diff --git a/packages/backend/src/RepositoryModule.ts b/packages/backend/src/RepositoryModule.ts new file mode 100644 index 000000000..0e3ef5899 --- /dev/null +++ b/packages/backend/src/RepositoryModule.ts @@ -0,0 +1,519 @@ +import { Module } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserGroup, UserGroupJoining, UserGroupInvitation, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, MessagingMessage, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest } from './models/index.js'; +import type { DataSource } from 'typeorm'; +import type { Provider } from '@nestjs/common'; + +const $usersRepository: Provider = { + provide: DI.usersRepository, + useFactory: (db: DataSource) => db.getRepository(User), + inject: [DI.db], +}; + +const $notesRepository: Provider = { + provide: DI.notesRepository, + useFactory: (db: DataSource) => db.getRepository(Note), + inject: [DI.db], +}; + +const $announcementsRepository: Provider = { + provide: DI.announcementsRepository, + useFactory: (db: DataSource) => db.getRepository(Announcement), + inject: [DI.db], +}; + +const $announcementReadsRepository: Provider = { + provide: DI.announcementReadsRepository, + useFactory: (db: DataSource) => db.getRepository(AnnouncementRead), + inject: [DI.db], +}; + +const $appsRepository: Provider = { + provide: DI.appsRepository, + useFactory: (db: DataSource) => db.getRepository(App), + inject: [DI.db], +}; + +const $noteFavoritesRepository: Provider = { + provide: DI.noteFavoritesRepository, + useFactory: (db: DataSource) => db.getRepository(NoteFavorite), + inject: [DI.db], +}; + +const $noteThreadMutingsRepository: Provider = { + provide: DI.noteThreadMutingsRepository, + useFactory: (db: DataSource) => db.getRepository(NoteThreadMuting), + inject: [DI.db], +}; + +const $noteReactionsRepository: Provider = { + provide: DI.noteReactionsRepository, + useFactory: (db: DataSource) => db.getRepository(NoteReaction), + inject: [DI.db], +}; + +const $noteUnreadsRepository: Provider = { + provide: DI.noteUnreadsRepository, + useFactory: (db: DataSource) => db.getRepository(NoteUnread), + inject: [DI.db], +}; + +const $pollsRepository: Provider = { + provide: DI.pollsRepository, + useFactory: (db: DataSource) => db.getRepository(Poll), + inject: [DI.db], +}; + +const $pollVotesRepository: Provider = { + provide: DI.pollVotesRepository, + useFactory: (db: DataSource) => db.getRepository(PollVote), + inject: [DI.db], +}; + +const $userProfilesRepository: Provider = { + provide: DI.userProfilesRepository, + useFactory: (db: DataSource) => db.getRepository(UserProfile), + inject: [DI.db], +}; + +const $userKeypairsRepository: Provider = { + provide: DI.userKeypairsRepository, + useFactory: (db: DataSource) => db.getRepository(UserKeypair), + inject: [DI.db], +}; + +const $userPendingsRepository: Provider = { + provide: DI.userPendingsRepository, + useFactory: (db: DataSource) => db.getRepository(UserPending), + inject: [DI.db], +}; + +const $attestationChallengesRepository: Provider = { + provide: DI.attestationChallengesRepository, + useFactory: (db: DataSource) => db.getRepository(AttestationChallenge), + inject: [DI.db], +}; + +const $userSecurityKeysRepository: Provider = { + provide: DI.userSecurityKeysRepository, + useFactory: (db: DataSource) => db.getRepository(UserSecurityKey), + inject: [DI.db], +}; + +const $userPublickeysRepository: Provider = { + provide: DI.userPublickeysRepository, + useFactory: (db: DataSource) => db.getRepository(UserPublickey), + inject: [DI.db], +}; + +const $userListsRepository: Provider = { + provide: DI.userListsRepository, + useFactory: (db: DataSource) => db.getRepository(UserList), + inject: [DI.db], +}; + +const $userListJoiningsRepository: Provider = { + provide: DI.userListJoiningsRepository, + useFactory: (db: DataSource) => db.getRepository(UserListJoining), + inject: [DI.db], +}; + +const $userGroupsRepository: Provider = { + provide: DI.userGroupsRepository, + useFactory: (db: DataSource) => db.getRepository(UserGroup), + inject: [DI.db], +}; + +const $userGroupJoiningsRepository: Provider = { + provide: DI.userGroupJoiningsRepository, + useFactory: (db: DataSource) => db.getRepository(UserGroupJoining), + inject: [DI.db], +}; + +const $userGroupInvitationsRepository: Provider = { + provide: DI.userGroupInvitationsRepository, + useFactory: (db: DataSource) => db.getRepository(UserGroupInvitation), + inject: [DI.db], +}; + +const $userNotePiningsRepository: Provider = { + provide: DI.userNotePiningsRepository, + useFactory: (db: DataSource) => db.getRepository(UserNotePining), + inject: [DI.db], +}; + +const $userIpsRepository: Provider = { + provide: DI.userIpsRepository, + useFactory: (db: DataSource) => db.getRepository(UserIp), + inject: [DI.db], +}; + +const $usedUsernamesRepository: Provider = { + provide: DI.usedUsernamesRepository, + useFactory: (db: DataSource) => db.getRepository(UsedUsername), + inject: [DI.db], +}; + +const $followingsRepository: Provider = { + provide: DI.followingsRepository, + useFactory: (db: DataSource) => db.getRepository(Following), + inject: [DI.db], +}; + +const $followRequestsRepository: Provider = { + provide: DI.followRequestsRepository, + useFactory: (db: DataSource) => db.getRepository(FollowRequest), + inject: [DI.db], +}; + +const $instancesRepository: Provider = { + provide: DI.instancesRepository, + useFactory: (db: DataSource) => db.getRepository(Instance), + inject: [DI.db], +}; + +const $emojisRepository: Provider = { + provide: DI.emojisRepository, + useFactory: (db: DataSource) => db.getRepository(Emoji), + inject: [DI.db], +}; + +const $driveFilesRepository: Provider = { + provide: DI.driveFilesRepository, + useFactory: (db: DataSource) => db.getRepository(DriveFile), + inject: [DI.db], +}; + +const $driveFoldersRepository: Provider = { + provide: DI.driveFoldersRepository, + useFactory: (db: DataSource) => db.getRepository(DriveFolder), + inject: [DI.db], +}; + +const $notificationsRepository: Provider = { + provide: DI.notificationsRepository, + useFactory: (db: DataSource) => db.getRepository(Notification), + inject: [DI.db], +}; + +const $metasRepository: Provider = { + provide: DI.metasRepository, + useFactory: (db: DataSource) => db.getRepository(Meta), + inject: [DI.db], +}; + +const $mutingsRepository: Provider = { + provide: DI.mutingsRepository, + useFactory: (db: DataSource) => db.getRepository(Muting), + inject: [DI.db], +}; + +const $blockingsRepository: Provider = { + provide: DI.blockingsRepository, + useFactory: (db: DataSource) => db.getRepository(Blocking), + inject: [DI.db], +}; + +const $swSubscriptionsRepository: Provider = { + provide: DI.swSubscriptionsRepository, + useFactory: (db: DataSource) => db.getRepository(SwSubscription), + inject: [DI.db], +}; + +const $hashtagsRepository: Provider = { + provide: DI.hashtagsRepository, + useFactory: (db: DataSource) => db.getRepository(Hashtag), + inject: [DI.db], +}; + +const $abuseUserReportsRepository: Provider = { + provide: DI.abuseUserReportsRepository, + useFactory: (db: DataSource) => db.getRepository(AbuseUserReport), + inject: [DI.db], +}; + +const $registrationTicketsRepository: Provider = { + provide: DI.registrationTicketsRepository, + useFactory: (db: DataSource) => db.getRepository(RegistrationTicket), + inject: [DI.db], +}; + +const $authSessionsRepository: Provider = { + provide: DI.authSessionsRepository, + useFactory: (db: DataSource) => db.getRepository(AuthSession), + inject: [DI.db], +}; + +const $accessTokensRepository: Provider = { + provide: DI.accessTokensRepository, + useFactory: (db: DataSource) => db.getRepository(AccessToken), + inject: [DI.db], +}; + +const $signinsRepository: Provider = { + provide: DI.signinsRepository, + useFactory: (db: DataSource) => db.getRepository(Signin), + inject: [DI.db], +}; + +const $messagingMessagesRepository: Provider = { + provide: DI.messagingMessagesRepository, + useFactory: (db: DataSource) => db.getRepository(MessagingMessage), + inject: [DI.db], +}; + +const $pagesRepository: Provider = { + provide: DI.pagesRepository, + useFactory: (db: DataSource) => db.getRepository(Page), + inject: [DI.db], +}; + +const $pageLikesRepository: Provider = { + provide: DI.pageLikesRepository, + useFactory: (db: DataSource) => db.getRepository(PageLike), + inject: [DI.db], +}; + +const $galleryPostsRepository: Provider = { + provide: DI.galleryPostsRepository, + useFactory: (db: DataSource) => db.getRepository(GalleryPost), + inject: [DI.db], +}; + +const $galleryLikesRepository: Provider = { + provide: DI.galleryLikesRepository, + useFactory: (db: DataSource) => db.getRepository(GalleryLike), + inject: [DI.db], +}; + +const $moderationLogsRepository: Provider = { + provide: DI.moderationLogsRepository, + useFactory: (db: DataSource) => db.getRepository(ModerationLog), + inject: [DI.db], +}; + +const $clipsRepository: Provider = { + provide: DI.clipsRepository, + useFactory: (db: DataSource) => db.getRepository(Clip), + inject: [DI.db], +}; + +const $clipNotesRepository: Provider = { + provide: DI.clipNotesRepository, + useFactory: (db: DataSource) => db.getRepository(ClipNote), + inject: [DI.db], +}; + +const $antennasRepository: Provider = { + provide: DI.antennasRepository, + useFactory: (db: DataSource) => db.getRepository(Antenna), + inject: [DI.db], +}; + +const $antennaNotesRepository: Provider = { + provide: DI.antennaNotesRepository, + useFactory: (db: DataSource) => db.getRepository(AntennaNote), + inject: [DI.db], +}; + +const $promoNotesRepository: Provider = { + provide: DI.promoNotesRepository, + useFactory: (db: DataSource) => db.getRepository(PromoNote), + inject: [DI.db], +}; + +const $promoReadsRepository: Provider = { + provide: DI.promoReadsRepository, + useFactory: (db: DataSource) => db.getRepository(PromoRead), + inject: [DI.db], +}; + +const $relaysRepository: Provider = { + provide: DI.relaysRepository, + useFactory: (db: DataSource) => db.getRepository(Relay), + inject: [DI.db], +}; + +const $mutedNotesRepository: Provider = { + provide: DI.mutedNotesRepository, + useFactory: (db: DataSource) => db.getRepository(MutedNote), + inject: [DI.db], +}; + +const $channelsRepository: Provider = { + provide: DI.channelsRepository, + useFactory: (db: DataSource) => db.getRepository(Channel), + inject: [DI.db], +}; + +const $channelFollowingsRepository: Provider = { + provide: DI.channelFollowingsRepository, + useFactory: (db: DataSource) => db.getRepository(ChannelFollowing), + inject: [DI.db], +}; + +const $channelNotePiningsRepository: Provider = { + provide: DI.channelNotePiningsRepository, + useFactory: (db: DataSource) => db.getRepository(ChannelNotePining), + inject: [DI.db], +}; + +const $registryItemsRepository: Provider = { + provide: DI.registryItemsRepository, + useFactory: (db: DataSource) => db.getRepository(RegistryItem), + inject: [DI.db], +}; + +const $webhooksRepository: Provider = { + provide: DI.webhooksRepository, + useFactory: (db: DataSource) => db.getRepository(Webhook), + inject: [DI.db], +}; + +const $adsRepository: Provider = { + provide: DI.adsRepository, + useFactory: (db: DataSource) => db.getRepository(Ad), + inject: [DI.db], +}; + +const $passwordResetRequestsRepository: Provider = { + provide: DI.passwordResetRequestsRepository, + useFactory: (db: DataSource) => db.getRepository(PasswordResetRequest), + inject: [DI.db], +}; + +@Module({ + imports: [ + ], + providers: [ + $usersRepository, + $notesRepository, + $announcementsRepository, + $announcementReadsRepository, + $appsRepository, + $noteFavoritesRepository, + $noteThreadMutingsRepository, + $noteReactionsRepository, + $noteUnreadsRepository, + $pollsRepository, + $pollVotesRepository, + $userProfilesRepository, + $userKeypairsRepository, + $userPendingsRepository, + $attestationChallengesRepository, + $userSecurityKeysRepository, + $userPublickeysRepository, + $userListsRepository, + $userListJoiningsRepository, + $userGroupsRepository, + $userGroupJoiningsRepository, + $userGroupInvitationsRepository, + $userNotePiningsRepository, + $userIpsRepository, + $usedUsernamesRepository, + $followingsRepository, + $followRequestsRepository, + $instancesRepository, + $emojisRepository, + $driveFilesRepository, + $driveFoldersRepository, + $notificationsRepository, + $metasRepository, + $mutingsRepository, + $blockingsRepository, + $swSubscriptionsRepository, + $hashtagsRepository, + $abuseUserReportsRepository, + $registrationTicketsRepository, + $authSessionsRepository, + $accessTokensRepository, + $signinsRepository, + $messagingMessagesRepository, + $pagesRepository, + $pageLikesRepository, + $galleryPostsRepository, + $galleryLikesRepository, + $moderationLogsRepository, + $clipsRepository, + $clipNotesRepository, + $antennasRepository, + $antennaNotesRepository, + $promoNotesRepository, + $promoReadsRepository, + $relaysRepository, + $mutedNotesRepository, + $channelsRepository, + $channelFollowingsRepository, + $channelNotePiningsRepository, + $registryItemsRepository, + $webhooksRepository, + $adsRepository, + $passwordResetRequestsRepository, + ], + exports: [ + $usersRepository, + $notesRepository, + $announcementsRepository, + $announcementReadsRepository, + $appsRepository, + $noteFavoritesRepository, + $noteThreadMutingsRepository, + $noteReactionsRepository, + $noteUnreadsRepository, + $pollsRepository, + $pollVotesRepository, + $userProfilesRepository, + $userKeypairsRepository, + $userPendingsRepository, + $attestationChallengesRepository, + $userSecurityKeysRepository, + $userPublickeysRepository, + $userListsRepository, + $userListJoiningsRepository, + $userGroupsRepository, + $userGroupJoiningsRepository, + $userGroupInvitationsRepository, + $userNotePiningsRepository, + $userIpsRepository, + $usedUsernamesRepository, + $followingsRepository, + $followRequestsRepository, + $instancesRepository, + $emojisRepository, + $driveFilesRepository, + $driveFoldersRepository, + $notificationsRepository, + $metasRepository, + $mutingsRepository, + $blockingsRepository, + $swSubscriptionsRepository, + $hashtagsRepository, + $abuseUserReportsRepository, + $registrationTicketsRepository, + $authSessionsRepository, + $accessTokensRepository, + $signinsRepository, + $messagingMessagesRepository, + $pagesRepository, + $pageLikesRepository, + $galleryPostsRepository, + $galleryLikesRepository, + $moderationLogsRepository, + $clipsRepository, + $clipNotesRepository, + $antennasRepository, + $antennaNotesRepository, + $promoNotesRepository, + $promoReadsRepository, + $relaysRepository, + $mutedNotesRepository, + $channelsRepository, + $channelFollowingsRepository, + $channelNotePiningsRepository, + $registryItemsRepository, + $webhooksRepository, + $adsRepository, + $passwordResetRequestsRepository, + ], +}) +export class RepositoryModule {} diff --git a/packages/backend/src/boot/index.ts b/packages/backend/src/boot/index.ts index c3d059225..fbf9e73e0 100644 --- a/packages/backend/src/boot/index.ts +++ b/packages/backend/src/boot/index.ts @@ -1,44 +1,28 @@ + +/** + * Misskey Entry Point! + */ + import cluster from 'node:cluster'; +import { EventEmitter } from 'node:events'; import chalk from 'chalk'; import Xev from 'xev'; - -import Logger from '@/services/logger.js'; +import Logger from '@/logger.js'; import { envOption } from '../env.js'; - -// for typeorm -import 'reflect-metadata'; import { masterMain } from './master.js'; import { workerMain } from './worker.js'; +import 'reflect-metadata'; + +process.title = `Misskey (${cluster.isPrimary ? 'master' : 'worker'})`; + +Error.stackTraceLimit = Infinity; +EventEmitter.defaultMaxListeners = 128; + const logger = new Logger('core', 'cyan'); const clusterLogger = logger.createSubLogger('cluster', 'orange', false); const ev = new Xev(); -/** - * Init process - */ -export default async function() { - process.title = `Misskey (${cluster.isPrimary ? 'master' : 'worker'})`; - - if (cluster.isPrimary || envOption.disableClustering) { - await masterMain(); - - if (cluster.isPrimary) { - ev.mount(); - } - } - - if (cluster.isWorker || envOption.disableClustering) { - await workerMain(); - } - - // ユニットテスト時にMisskeyが子プロセスで起動された時のため - // それ以外のときは process.send は使えないので弾く - if (process.send) { - process.send('ok'); - } -} - //#region Events // Listen new workers @@ -77,3 +61,21 @@ process.on('exit', code => { }); //#endregion + +if (cluster.isPrimary || envOption.disableClustering) { + await masterMain(); + + if (cluster.isPrimary) { + ev.mount(); + } +} + +if (cluster.isWorker || envOption.disableClustering) { + await workerMain(); +} + +// ユニットテスト時にMisskeyが子プロセスで起動された時のため +// それ以外のときは process.send は使えないので弾く +if (process.send) { + process.send('ok'); +} diff --git a/packages/backend/src/boot/master.ts b/packages/backend/src/boot/master.ts index bf5196048..ec09b4350 100644 --- a/packages/backend/src/boot/master.ts +++ b/packages/backend/src/boot/master.ts @@ -6,14 +6,17 @@ import cluster from 'node:cluster'; import chalk from 'chalk'; import chalkTemplate from 'chalk-template'; import semver from 'semver'; - -import Logger from '@/services/logger.js'; -import loadConfig from '@/config/load.js'; -import { Config } from '@/config/types.js'; -import { lessThan } from '@/prelude/array.js'; -import { envOption } from '../env.js'; +import { NestFactory } from '@nestjs/core'; +import Logger from '@/logger.js'; +import { loadConfig } from '@/config.js'; +import type { Config } from '@/config.js'; +import { lessThan } from '@/misc/prelude/array.js'; import { showMachineInfo } from '@/misc/show-machine-info.js'; -import { db, initDb } from '../db/postgre.js'; +import { DaemonModule } from '@/daemons/DaemonModule.js'; +import { JanitorService } from '@/daemons/JanitorService.js'; +import { QueueStatsService } from '@/daemons/QueueStatsService.js'; +import { ServerStatsService } from '@/daemons/ServerStatsService.js'; +import { envOption } from '../env.js'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); @@ -60,7 +63,7 @@ export async function masterMain() { await showMachineInfo(bootLogger); showNodejsVersion(); config = loadConfigBoot(); - await connectDb(); + //await connectDb(); } catch (e) { bootLogger.error('Fatal error occurred during initialization', null, true); process.exit(1); @@ -75,9 +78,11 @@ export async function masterMain() { bootLogger.succ(`Now listening on port ${config.port} on ${config.url}`, null, true); if (!envOption.noDaemons) { - import('../daemons/server-stats.js').then(x => x.default()); - import('../daemons/queue-stats.js').then(x => x.default()); - import('../daemons/janitor.js').then(x => x.default()); + const daemons = await NestFactory.createApplicationContext(DaemonModule); + daemons.enableShutdownHooks(); + daemons.get(JanitorService).start(); + daemons.get(QueueStatsService).start(); + daemons.get(ServerStatsService).start(); } } @@ -127,6 +132,7 @@ function loadConfigBoot(): Config { return config; } +/* async function connectDb(): Promise { const dbLogger = bootLogger.createSubLogger('db'); @@ -136,14 +142,15 @@ async function connectDb(): Promise { await initDb(); const v = await db.query('SHOW server_version').then(x => x[0].server_version); dbLogger.succ(`Connected: v${v}`); - } catch (e) { + } catch (err) { dbLogger.error('Cannot connect', null, true); - dbLogger.error(e); + dbLogger.error(err); process.exit(1); } } +*/ -async function spawnWorkers(limit: number = 1) { +async function spawnWorkers(limit = 1) { const workers = Math.min(limit, os.cpus().length); bootLogger.info(`Starting ${workers} worker${workers === 1 ? '' : 's'}...`); await Promise.all([...Array(workers)].map(spawnWorker)); @@ -155,7 +162,7 @@ function spawnWorker(): Promise { const worker = cluster.fork(); worker.on('message', message => { if (message === 'listenFailed') { - bootLogger.error(`The server Listen failed due to the previous error.`); + bootLogger.error('The server Listen failed due to the previous error.'); process.exit(1); } if (message !== 'ready') return; diff --git a/packages/backend/src/boot/worker.ts b/packages/backend/src/boot/worker.ts index 8038e2563..91f0c7631 100644 --- a/packages/backend/src/boot/worker.ts +++ b/packages/backend/src/boot/worker.ts @@ -1,17 +1,29 @@ import cluster from 'node:cluster'; -import { initDb } from '../db/postgre.js'; +import { NestFactory } from '@nestjs/core'; +import { envOption } from '@/env.js'; +import { ChartManagementService } from '@/core/chart/ChartManagementService.js'; +import { ServerService } from '@/server/ServerService.js'; +import { QueueProcessorService } from '@/queue/QueueProcessorService.js'; +import { AppModule } from '../AppModule.js'; /** * Init worker process */ export async function workerMain() { - await initDb(); + const app = await NestFactory.createApplicationContext(AppModule); + app.enableShutdownHooks(); // start server - await import('../server/index.js').then(x => x.default()); + const serverService = app.get(ServerService); + serverService.launch(); // start job queue - import('../queue/index.js').then(x => x.default()); + if (!envOption.onlyServer) { + const queueProcessorService = app.get(QueueProcessorService); + queueProcessorService.start(); + } + + app.get(ChartManagementService).run(); if (cluster.isWorker) { // Send a 'ready' message to parent process diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts new file mode 100644 index 000000000..11d8db5c0 --- /dev/null +++ b/packages/backend/src/config.ts @@ -0,0 +1,149 @@ +/** + * Config loader + */ + +import * as fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; +import * as yaml from 'js-yaml'; + +/** + * ユーザーが設定する必要のある情報 + */ +export type Source = { + repository_url?: string; + feedback_url?: string; + url: string; + port: number; + disableHsts?: boolean; + db: { + host: string; + port: number; + db: string; + user: string; + pass: string; + disableCache?: boolean; + extra?: { [x: string]: string }; + }; + redis: { + host: string; + port: number; + family?: number; + pass: string; + db?: number; + prefix?: string; + }; + elasticsearch: { + host: string; + port: number; + ssl?: boolean; + user?: string; + pass?: string; + index?: string; + }; + + proxy?: string; + proxySmtp?: string; + proxyBypassHosts?: string[]; + + allowedPrivateNetworks?: string[]; + + maxFileSize?: number; + + accesslog?: string; + + clusterLimit?: number; + + id: string; + + outgoingAddressFamily?: 'ipv4' | 'ipv6' | 'dual'; + + deliverJobConcurrency?: number; + inboxJobConcurrency?: number; + deliverJobPerSec?: number; + inboxJobPerSec?: number; + deliverJobMaxAttempts?: number; + inboxJobMaxAttempts?: number; + + syslog: { + host: string; + port: number; + }; + + mediaProxy?: string; + proxyRemoteFiles?: boolean; + + signToActivityPubGet?: boolean; +}; + +/** + * Misskeyが自動的に(ユーザーが設定した情報から推論して)設定する情報 + */ +export type Mixin = { + version: string; + host: string; + hostname: string; + scheme: string; + wsScheme: string; + apiUrl: string; + wsUrl: string; + authUrl: string; + driveUrl: string; + userAgent: string; + clientEntry: string; +}; + +export type Config = Source & Mixin; + +const _filename = fileURLToPath(import.meta.url); +const _dirname = dirname(_filename); + +/** + * Path of configuration directory + */ +const dir = `${_dirname}/../../../.config`; + +/** + * Path of configuration file + */ +const path = process.env.NODE_ENV === 'test' + ? `${dir}/test.yml` + : `${dir}/default.yml`; + +export function loadConfig() { + const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/meta.json`, 'utf-8')); + const clientManifest = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_client_dist_/manifest.json`, 'utf-8')); + const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source; + + const mixin = {} as Mixin; + + const url = tryCreateUrl(config.url); + + config.url = url.origin; + + config.port = config.port ?? parseInt(process.env.PORT ?? '', 10); + + mixin.version = meta.version; + mixin.host = url.host; + mixin.hostname = url.hostname; + mixin.scheme = url.protocol.replace(/:$/, ''); + mixin.wsScheme = mixin.scheme.replace('http', 'ws'); + mixin.wsUrl = `${mixin.wsScheme}://${mixin.host}`; + mixin.apiUrl = `${mixin.scheme}://${mixin.host}/api`; + mixin.authUrl = `${mixin.scheme}://${mixin.host}/auth`; + mixin.driveUrl = `${mixin.scheme}://${mixin.host}/files`; + mixin.userAgent = `Misskey/${meta.version} (${config.url})`; + mixin.clientEntry = clientManifest['src/init.ts']; + + if (!config.redis.prefix) config.redis.prefix = mixin.host; + + return Object.assign(config, mixin); +} + +function tryCreateUrl(url: string) { + try { + return new URL(url); + } catch (e) { + throw `url="${url}" is not a valid URL.`; + } +} diff --git a/packages/backend/src/config/index.ts b/packages/backend/src/config/index.ts deleted file mode 100644 index 3e53b0003..000000000 --- a/packages/backend/src/config/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import load from './load.js'; - -export default load(); diff --git a/packages/backend/src/config/load.ts b/packages/backend/src/config/load.ts deleted file mode 100644 index 9654a4f3b..000000000 --- a/packages/backend/src/config/load.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Config loader - */ - -import * as fs from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; -import * as yaml from 'js-yaml'; -import { Source, Mixin } from './types.js'; - -const _filename = fileURLToPath(import.meta.url); -const _dirname = dirname(_filename); - -/** - * Path of configuration directory - */ -const dir = `${_dirname}/../../../../.config`; - -/** - * Path of configuration file - */ -const path = process.env.NODE_ENV === 'test' - ? `${dir}/test.yml` - : `${dir}/default.yml`; - -export default function load() { - const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../../built/meta.json`, 'utf-8')); - const clientManifest = JSON.parse(fs.readFileSync(`${_dirname}/../../../../built/_client_dist_/manifest.json`, 'utf-8')); - const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source; - - const mixin = {} as Mixin; - - const url = tryCreateUrl(config.url); - - config.url = url.origin; - - config.port = config.port || parseInt(process.env.PORT || '', 10); - - mixin.version = meta.version; - mixin.host = url.host; - mixin.hostname = url.hostname; - mixin.scheme = url.protocol.replace(/:$/, ''); - mixin.wsScheme = mixin.scheme.replace('http', 'ws'); - mixin.wsUrl = `${mixin.wsScheme}://${mixin.host}`; - mixin.apiUrl = `${mixin.scheme}://${mixin.host}/api`; - mixin.authUrl = `${mixin.scheme}://${mixin.host}/auth`; - mixin.driveUrl = `${mixin.scheme}://${mixin.host}/files`; - mixin.userAgent = `Misskey/${meta.version} (${config.url})`; - mixin.clientEntry = clientManifest['src/init.ts']; - - if (!config.redis.prefix) config.redis.prefix = mixin.host; - - return Object.assign(config, mixin); -} - -function tryCreateUrl(url: string) { - try { - return new URL(url); - } catch (e) { - throw `url="${url}" is not a valid URL.`; - } -} diff --git a/packages/backend/src/config/types.ts b/packages/backend/src/config/types.ts deleted file mode 100644 index 78510c837..000000000 --- a/packages/backend/src/config/types.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * ユーザーが設定する必要のある情報 - */ -export type Source = { - repository_url?: string; - feedback_url?: string; - url: string; - port: number; - disableHsts?: boolean; - db: { - host: string; - port: number; - db: string; - user: string; - pass: string; - disableCache?: boolean; - extra?: { [x: string]: string }; - }; - redis: { - host: string; - port: number; - family?: number; - pass: string; - db?: number; - prefix?: string; - }; - elasticsearch: { - host: string; - port: number; - ssl?: boolean; - user?: string; - pass?: string; - index?: string; - }; - - proxy?: string; - proxySmtp?: string; - proxyBypassHosts?: string[]; - - allowedPrivateNetworks?: string[]; - - maxFileSize?: number; - - accesslog?: string; - - clusterLimit?: number; - - id: string; - - outgoingAddressFamily?: 'ipv4' | 'ipv6' | 'dual'; - - deliverJobConcurrency?: number; - inboxJobConcurrency?: number; - deliverJobPerSec?: number; - inboxJobPerSec?: number; - deliverJobMaxAttempts?: number; - inboxJobMaxAttempts?: number; - - syslog: { - host: string; - port: number; - }; - - mediaProxy?: string; - proxyRemoteFiles?: boolean; - - signToActivityPubGet?: boolean; -}; - -/** - * Misskeyが自動的に(ユーザーが設定した情報から推論して)設定する情報 - */ -export type Mixin = { - version: string; - host: string; - hostname: string; - scheme: string; - wsScheme: string; - apiUrl: string; - wsUrl: string; - authUrl: string; - driveUrl: string; - userAgent: string; - clientEntry: string; -}; - -export type Config = Source & Mixin; diff --git a/packages/backend/src/core/AccountUpdateService.ts b/packages/backend/src/core/AccountUpdateService.ts new file mode 100644 index 000000000..204e1d017 --- /dev/null +++ b/packages/backend/src/core/AccountUpdateService.ts @@ -0,0 +1,38 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { UsersRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type { User } from '@/models/entities/User.js'; +import { ApRendererService } from '@/core/remote/activitypub/ApRendererService.js'; +import { RelayService } from '@/core/RelayService.js'; +import { ApDeliverManagerService } from '@/core/remote/activitypub/ApDeliverManagerService.js'; +import { UserEntityService } from './entities/UserEntityService.js'; + +@Injectable() +export class AccountUpdateService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private userEntityService: UserEntityService, + private apRendererService: ApRendererService, + private apDeliverManagerService: ApDeliverManagerService, + private relayService: RelayService, + ) { + } + + public async publishToFollowers(userId: User['id']) { + const user = await this.usersRepository.findOneBy({ id: userId }); + if (user == null) throw new Error('user not found'); + + // フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信 + if (this.userEntityService.isLocalUser(user)) { + const content = this.apRendererService.renderActivity(this.apRendererService.renderUpdate(await this.apRendererService.renderPerson(user), user)); + this.apDeliverManagerService.deliverToFollowers(user, content); + this.relayService.deliverToRelays(user, content); + } + } +} diff --git a/packages/backend/src/core/AiService.ts b/packages/backend/src/core/AiService.ts new file mode 100644 index 000000000..e6102a1b9 --- /dev/null +++ b/packages/backend/src/core/AiService.ts @@ -0,0 +1,60 @@ +import * as fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; +import { Inject, Injectable } from '@nestjs/common'; +import * as nsfw from 'nsfwjs'; +import si from 'systeminformation'; +import { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; + +const _filename = fileURLToPath(import.meta.url); +const _dirname = dirname(_filename); + +const REQUIRED_CPU_FLAGS = ['avx2', 'fma']; +let isSupportedCpu: undefined | boolean = undefined; + +@Injectable() +export class AiService { + private model: nsfw.NSFWJS; + + constructor( + @Inject(DI.config) + private config: Config, + ) { + } + + public async detectSensitive(path: string): Promise { + try { + if (isSupportedCpu === undefined) { + const cpuFlags = await this.getCpuFlags(); + isSupportedCpu = REQUIRED_CPU_FLAGS.every(required => cpuFlags.includes(required)); + } + + if (!isSupportedCpu) { + console.error('This CPU cannot use TensorFlow.'); + return null; + } + + const tf = await import('@tensorflow/tfjs-node'); + + if (this.model == null) this.model = await nsfw.load(`file://${_dirname}/../../nsfw-model/`, { size: 299 }); + + const buffer = await fs.promises.readFile(path); + const image = await tf.node.decodeImage(buffer, 3) as any; + try { + const predictions = await this.model.classify(image); + return predictions; + } finally { + image.dispose(); + } + } catch (err) { + console.error(err); + return null; + } + } + + private async getCpuFlags(): Promise { + const str = await si.cpuFlags(); + return str.split(/\s+/); + } +} diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts new file mode 100644 index 000000000..e0af03395 --- /dev/null +++ b/packages/backend/src/core/AntennaService.ts @@ -0,0 +1,228 @@ +import { Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import type { Antenna } from '@/models/entities/Antenna.js'; +import type { Note } from '@/models/entities/Note.js'; +import type { User } from '@/models/entities/User.js'; +import { IdService } from '@/core/IdService.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import * as Acct from '@/misc/acct.js'; +import { Cache } from '@/misc/cache.js'; +import type { Packed } from '@/misc/schema.js'; +import { DI } from '@/di-symbols.js'; +import { MutingsRepository, BlockingsRepository, NotesRepository, AntennaNotesRepository, AntennasRepository, UserGroupJoiningsRepository, UserListJoiningsRepository } from '@/models/index.js'; +import { UtilityService } from './UtilityService.js'; +import type { OnApplicationShutdown } from '@nestjs/common'; + +@Injectable() +export class AntennaService implements OnApplicationShutdown { + private antennasFetched: boolean; + private antennas: Antenna[]; + private blockingCache: Cache; + + constructor( + @Inject(DI.redisSubscriber) + private redisSubscriber: Redis.Redis, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.antennaNotesRepository) + private antennaNotesRepository: AntennaNotesRepository, + + @Inject(DI.antennasRepository) + private antennasRepository: AntennasRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, + + private utilityService: UtilityService, + private idService: IdService, + private globalEventServie: GlobalEventService, + ) { + this.antennasFetched = false; + this.antennas = []; + this.blockingCache = new Cache(1000 * 60 * 5); + + this.redisSubscriber.on('message', this.onRedisMessage); + } + + public onApplicationShutdown(signal?: string | undefined) { + this.redisSubscriber.off('message', this.onRedisMessage); + } + + private async onRedisMessage(_, data) { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message; + switch (type) { + case 'antennaCreated': + this.antennas.push(body); + break; + case 'antennaUpdated': + this.antennas[this.antennas.findIndex(a => a.id === body.id)] = body; + break; + case 'antennaDeleted': + this.antennas = this.antennas.filter(a => a.id !== body.id); + break; + default: + break; + } + } + } + + public async addNoteToAntenna(antenna: Antenna, note: Note, noteUser: { id: User['id']; }): Promise { + // 通知しない設定になっているか、自分自身の投稿なら既読にする + const read = !antenna.notify || (antenna.userId === noteUser.id); + + this.antennaNotesRepository.insert({ + id: this.idService.genId(), + antennaId: antenna.id, + noteId: note.id, + read: read, + }); + + this.globalEventServie.publishAntennaStream(antenna.id, 'note', note); + + if (!read) { + const mutings = await this.mutingsRepository.find({ + where: { + muterId: antenna.userId, + }, + select: ['muteeId'], + }); + + // Copy + const _note: Note = { + ...note, + }; + + if (note.replyId != null) { + _note.reply = await this.notesRepository.findOneByOrFail({ id: note.replyId }); + } + if (note.renoteId != null) { + _note.renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId }); + } + + if (isUserRelated(_note, new Set(mutings.map(x => x.muteeId)))) { + return; + } + + // 2秒経っても既読にならなかったら通知 + setTimeout(async () => { + const unread = await this.antennaNotesRepository.findOneBy({ antennaId: antenna.id, read: false }); + if (unread) { + this.globalEventServie.publishMainStream(antenna.userId, 'unreadAntenna', antenna); + } + }, 2000); + } + } + + // NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている + + /** + * noteUserFollowers / antennaUserFollowing はどちらか一方が指定されていればよい + */ + public async checkHitAntenna(antenna: Antenna, note: (Note | Packed<'Note'>), noteUser: { id: User['id']; username: string; host: string | null; }, noteUserFollowers?: User['id'][], antennaUserFollowing?: User['id'][]): Promise { + if (note.visibility === 'specified') return false; + + // アンテナ作成者がノート作成者にブロックされていたらスキップ + const blockings = await this.blockingCache.fetch(noteUser.id, () => this.blockingsRepository.findBy({ blockerId: noteUser.id }).then(res => res.map(x => x.blockeeId))); + if (blockings.some(blocking => blocking === antenna.userId)) return false; + + if (note.visibility === 'followers') { + if (noteUserFollowers && !noteUserFollowers.includes(antenna.userId)) return false; + if (antennaUserFollowing && !antennaUserFollowing.includes(note.userId)) return false; + } + + if (!antenna.withReplies && note.replyId != null) return false; + + if (antenna.src === 'home') { + if (noteUserFollowers && !noteUserFollowers.includes(antenna.userId)) return false; + if (antennaUserFollowing && !antennaUserFollowing.includes(note.userId)) return false; + } else if (antenna.src === 'list') { + const listUsers = (await this.userListJoiningsRepository.findBy({ + userListId: antenna.userListId!, + })).map(x => x.userId); + + if (!listUsers.includes(note.userId)) return false; + } else if (antenna.src === 'group') { + const joining = await this.userGroupJoiningsRepository.findOneByOrFail({ id: antenna.userGroupJoiningId! }); + + const groupUsers = (await this.userGroupJoiningsRepository.findBy({ + userGroupId: joining.userGroupId, + })).map(x => x.userId); + + if (!groupUsers.includes(note.userId)) return false; + } else if (antenna.src === 'users') { + const accts = antenna.users.map(x => { + const { username, host } = Acct.parse(x); + return this.utilityService.getFullApAccount(username, host).toLowerCase(); + }); + if (!accts.includes(this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase())) return false; + } + + const keywords = antenna.keywords + // Clean up + .map(xs => xs.filter(x => x !== '')) + .filter(xs => xs.length > 0); + + if (keywords.length > 0) { + if (note.text == null) return false; + + const matched = keywords.some(and => + and.every(keyword => + antenna.caseSensitive + ? note.text!.includes(keyword) + : note.text!.toLowerCase().includes(keyword.toLowerCase()), + )); + + if (!matched) return false; + } + + const excludeKeywords = antenna.excludeKeywords + // Clean up + .map(xs => xs.filter(x => x !== '')) + .filter(xs => xs.length > 0); + + if (excludeKeywords.length > 0) { + if (note.text == null) return false; + + const matched = excludeKeywords.some(and => + and.every(keyword => + antenna.caseSensitive + ? note.text!.includes(keyword) + : note.text!.toLowerCase().includes(keyword.toLowerCase()), + )); + + if (matched) return false; + } + + if (antenna.withFile) { + if (note.fileIds && note.fileIds.length === 0) return false; + } + + // TODO: eval expression + + return true; + } + + public async getAntennas() { + if (!this.antennasFetched) { + this.antennas = await this.antennasRepository.find(); + this.antennasFetched = true; + } + + return this.antennas; + } +} diff --git a/packages/backend/src/core/AppLockService.ts b/packages/backend/src/core/AppLockService.ts new file mode 100644 index 000000000..04b3d8b11 --- /dev/null +++ b/packages/backend/src/core/AppLockService.ts @@ -0,0 +1,40 @@ +import { promisify } from 'node:util'; +import { Inject, Injectable } from '@nestjs/common'; +import redisLock from 'redis-lock'; +import Redis from 'ioredis'; +import { DI } from '@/di-symbols.js'; + +/** + * Retry delay (ms) for lock acquisition + */ +const retryDelay = 100; + +@Injectable() +export class AppLockService { + private lock: (key: string, timeout?: number) => Promise<() => void>; + + constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + ) { + this.lock = promisify(redisLock(this.redisClient, retryDelay)); + } + + /** + * Get AP Object lock + * @param uri AP object ID + * @param timeout Lock timeout (ms), The timeout releases previous lock. + * @returns Unlock function + */ + public getApLock(uri: string, timeout = 30 * 1000): Promise<() => void> { + return this.lock(`ap-object:${uri}`, timeout); + } + + public getFetchInstanceMetadataLock(host: string, timeout = 30 * 1000): Promise<() => void> { + return this.lock(`instance:${host}`, timeout); + } + + public getChartInsertLock(lockKey: string, timeout = 30 * 1000): Promise<() => void> { + return this.lock(`chart-insert:${lockKey}`, timeout); + } +} diff --git a/packages/backend/src/core/CaptchaService.ts b/packages/backend/src/core/CaptchaService.ts new file mode 100644 index 000000000..b1b52fd6a --- /dev/null +++ b/packages/backend/src/core/CaptchaService.ts @@ -0,0 +1,70 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { UsersRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import { HttpRequestService } from './HttpRequestService.js'; + +type CaptchaResponse = { + success: boolean; + 'error-codes'?: string[]; +}; + +@Injectable() +export class CaptchaService { + constructor( + @Inject(DI.config) + private config: Config, + + private httpRequestService: HttpRequestService, + ) { + } + + private async getCaptchaResponse(url: string, secret: string, response: string): Promise { + const params = new URLSearchParams({ + secret, + response, + }); + + const res = await fetch(url, { + method: 'POST', + body: params, + headers: { + 'User-Agent': this.config.userAgent, + }, + // TODO + //timeout: 10 * 1000, + agent: (url, bypassProxy) => this.httpRequestService.getAgentByUrl(url, bypassProxy), + }).catch(err => { + throw `${err.message ?? err}`; + }); + + if (!res.ok) { + throw `${res.status}`; + } + + return await res.json() as CaptchaResponse; + } + + public async verifyRecaptcha(secret: string, response: string): Promise { + const result = await this.getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(e => { + throw `recaptcha-request-failed: ${e}`; + }); + + if (result.success !== true) { + const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : ''; + throw `recaptcha-failed: ${errorCodes}`; + } + } + + public async verifyHcaptcha(secret: string, response: string): Promise { + const result = await this.getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(e => { + throw `hcaptcha-request-failed: ${e}`; + }); + + if (result.success !== true) { + const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : ''; + throw `hcaptcha-failed: ${errorCodes}`; + } + } +} + diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts new file mode 100644 index 000000000..da07728d2 --- /dev/null +++ b/packages/backend/src/core/CoreModule.ts @@ -0,0 +1,706 @@ +import { Module } from '@nestjs/common'; +import { DI } from '../di-symbols.js'; +import { AccountUpdateService } from './AccountUpdateService.js'; +import { AiService } from './AiService.js'; +import { AntennaService } from './AntennaService.js'; +import { AppLockService } from './AppLockService.js'; +import { CaptchaService } from './CaptchaService.js'; +import { CreateNotificationService } from './CreateNotificationService.js'; +import { CreateSystemUserService } from './CreateSystemUserService.js'; +import { CustomEmojiService } from './CustomEmojiService.js'; +import { DeleteAccountService } from './DeleteAccountService.js'; +import { DownloadService } from './DownloadService.js'; +import { DriveService } from './DriveService.js'; +import { EmailService } from './EmailService.js'; +import { FederatedInstanceService } from './FederatedInstanceService.js'; +import { FetchInstanceMetadataService } from './FetchInstanceMetadataService.js'; +import { GlobalEventService } from './GlobalEventService.js'; +import { HashtagService } from './HashtagService.js'; +import { HttpRequestService } from './HttpRequestService.js'; +import { IdService } from './IdService.js'; +import { ImageProcessingService } from './ImageProcessingService.js'; +import { InstanceActorService } from './InstanceActorService.js'; +import { InternalStorageService } from './InternalStorageService.js'; +import { MessagingService } from './MessagingService.js'; +import { MetaService } from './MetaService.js'; +import { MfmService } from './MfmService.js'; +import { ModerationLogService } from './ModerationLogService.js'; +import { NoteCreateService } from './NoteCreateService.js'; +import { NoteDeleteService } from './NoteDeleteService.js'; +import { NotePiningService } from './NotePiningService.js'; +import { NoteReadService } from './NoteReadService.js'; +import { NotificationService } from './NotificationService.js'; +import { PollService } from './PollService.js'; +import { PushNotificationService } from './PushNotificationService.js'; +import { QueryService } from './QueryService.js'; +import { ReactionService } from './ReactionService.js'; +import { RelayService } from './RelayService.js'; +import { S3Service } from './S3Service.js'; +import { SignupService } from './SignupService.js'; +import { TwoFactorAuthenticationService } from './TwoFactorAuthenticationService.js'; +import { UserBlockingService } from './UserBlockingService.js'; +import { UserCacheService } from './UserCacheService.js'; +import { UserFollowingService } from './UserFollowingService.js'; +import { UserKeypairStoreService } from './UserKeypairStoreService.js'; +import { UserListService } from './UserListService.js'; +import { UserMutingService } from './UserMutingService.js'; +import { UserSuspendService } from './UserSuspendService.js'; +import { VideoProcessingService } from './VideoProcessingService.js'; +import { WebhookService } from './WebhookService.js'; +import { ProxyAccountService } from './ProxyAccountService.js'; +import { UtilityService } from './UtilityService.js'; +import { FileInfoService } from './FileInfoService.js'; +import { ChartLoggerService } from './chart/ChartLoggerService.js'; +import FederationChart from './chart/charts/federation.js'; +import NotesChart from './chart/charts/notes.js'; +import UsersChart from './chart/charts/users.js'; +import ActiveUsersChart from './chart/charts/active-users.js'; +import InstanceChart from './chart/charts/instance.js'; +import PerUserNotesChart from './chart/charts/per-user-notes.js'; +import DriveChart from './chart/charts/drive.js'; +import PerUserReactionsChart from './chart/charts/per-user-reactions.js'; +import HashtagChart from './chart/charts/hashtag.js'; +import PerUserFollowingChart from './chart/charts/per-user-following.js'; +import PerUserDriveChart from './chart/charts/per-user-drive.js'; +import ApRequestChart from './chart/charts/ap-request.js'; +import { ChartManagementService } from './chart/ChartManagementService.js'; +import { AbuseUserReportEntityService } from './entities/AbuseUserReportEntityService.js'; +import { AntennaEntityService } from './entities/AntennaEntityService.js'; +import { AppEntityService } from './entities/AppEntityService.js'; +import { AuthSessionEntityService } from './entities/AuthSessionEntityService.js'; +import { BlockingEntityService } from './entities/BlockingEntityService.js'; +import { ChannelEntityService } from './entities/ChannelEntityService.js'; +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 { FollowingEntityService } from './entities/FollowingEntityService.js'; +import { FollowRequestEntityService } from './entities/FollowRequestEntityService.js'; +import { GalleryLikeEntityService } from './entities/GalleryLikeEntityService.js'; +import { GalleryPostEntityService } from './entities/GalleryPostEntityService.js'; +import { HashtagEntityService } from './entities/HashtagEntityService.js'; +import { InstanceEntityService } from './entities/InstanceEntityService.js'; +import { MessagingMessageEntityService } from './entities/MessagingMessageEntityService.js'; +import { ModerationLogEntityService } from './entities/ModerationLogEntityService.js'; +import { MutingEntityService } from './entities/MutingEntityService.js'; +import { NoteEntityService } from './entities/NoteEntityService.js'; +import { NoteFavoriteEntityService } from './entities/NoteFavoriteEntityService.js'; +import { NoteReactionEntityService } from './entities/NoteReactionEntityService.js'; +import { NotificationEntityService } from './entities/NotificationEntityService.js'; +import { PageEntityService } from './entities/PageEntityService.js'; +import { PageLikeEntityService } from './entities/PageLikeEntityService.js'; +import { SigninEntityService } from './entities/SigninEntityService.js'; +import { UserEntityService } from './entities/UserEntityService.js'; +import { UserGroupEntityService } from './entities/UserGroupEntityService.js'; +import { UserGroupInvitationEntityService } from './entities/UserGroupInvitationEntityService.js'; +import { UserListEntityService } from './entities/UserListEntityService.js'; +import { ApAudienceService } from './remote/activitypub/ApAudienceService.js'; +import { ApDbResolverService } from './remote/activitypub/ApDbResolverService.js'; +import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js'; +import { ApInboxService } from './remote/activitypub/ApInboxService.js'; +import { ApLoggerService } from './remote/activitypub/ApLoggerService.js'; +import { ApMfmService } from './remote/activitypub/ApMfmService.js'; +import { ApRendererService } from './remote/activitypub/ApRendererService.js'; +import { ApRequestService } from './remote/activitypub/ApRequestService.js'; +import { ApResolverService } from './remote/activitypub/ApResolverService.js'; +import { LdSignatureService } from './remote/activitypub/LdSignatureService.js'; +import { RemoteLoggerService } from './remote/RemoteLoggerService.js'; +import { ResolveUserService } from './remote/ResolveUserService.js'; +import { WebfingerService } from './remote/WebfingerService.js'; +import { ApImageService } from './remote/activitypub/models/ApImageService.js'; +import { ApMentionService } from './remote/activitypub/models/ApMentionService.js'; +import { ApNoteService } from './remote/activitypub/models/ApNoteService.js'; +import { ApPersonService } from './remote/activitypub/models/ApPersonService.js'; +import { ApQuestionService } from './remote/activitypub/models/ApQuestionService.js'; +import { QueueModule } from './queue/QueueModule.js'; +import { QueueService } from './QueueService.js'; +import { LoggerService } from './LoggerService.js'; +import type { Provider } from '@nestjs/common'; + +//#region 文字列ベースでのinjection用(循環参照対応のため) +const $LoggerService: Provider = { provide: 'LoggerService', useExisting: LoggerService }; +const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService }; +const $AiService: Provider = { provide: 'AiService', useExisting: AiService }; +const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService }; +const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppLockService }; +const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService }; +const $CreateNotificationService: Provider = { provide: 'CreateNotificationService', useExisting: CreateNotificationService }; +const $CreateSystemUserService: Provider = { provide: 'CreateSystemUserService', useExisting: CreateSystemUserService }; +const $CustomEmojiService: Provider = { provide: 'CustomEmojiService', useExisting: CustomEmojiService }; +const $DeleteAccountService: Provider = { provide: 'DeleteAccountService', useExisting: DeleteAccountService }; +const $DownloadService: Provider = { provide: 'DownloadService', useExisting: DownloadService }; +const $DriveService: Provider = { provide: 'DriveService', useExisting: DriveService }; +const $EmailService: Provider = { provide: 'EmailService', useExisting: EmailService }; +const $FederatedInstanceService: Provider = { provide: 'FederatedInstanceService', useExisting: FederatedInstanceService }; +const $FetchInstanceMetadataService: Provider = { provide: 'FetchInstanceMetadataService', useExisting: FetchInstanceMetadataService }; +const $GlobalEventService: Provider = { provide: 'GlobalEventService', useExisting: GlobalEventService }; +const $HashtagService: Provider = { provide: 'HashtagService', useExisting: HashtagService }; +const $HttpRequestService: Provider = { provide: 'HttpRequestService', useExisting: HttpRequestService }; +const $IdService: Provider = { provide: 'IdService', useExisting: IdService }; +const $ImageProcessingService: Provider = { provide: 'ImageProcessingService', useExisting: ImageProcessingService }; +const $InstanceActorService: Provider = { provide: 'InstanceActorService', useExisting: InstanceActorService }; +const $InternalStorageService: Provider = { provide: 'InternalStorageService', useExisting: InternalStorageService }; +const $MessagingService: Provider = { provide: 'MessagingService', useExisting: MessagingService }; +const $MetaService: Provider = { provide: 'MetaService', useExisting: MetaService }; +const $MfmService: Provider = { provide: 'MfmService', useExisting: MfmService }; +const $ModerationLogService: Provider = { provide: 'ModerationLogService', useExisting: ModerationLogService }; +const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService }; +const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService }; +const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService }; +const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService }; +const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService }; +const $PollService: Provider = { provide: 'PollService', useExisting: PollService }; +const $ProxyAccountService: Provider = { provide: 'ProxyAccountService', useExisting: ProxyAccountService }; +const $PushNotificationService: Provider = { provide: 'PushNotificationService', useExisting: PushNotificationService }; +const $QueryService: Provider = { provide: 'QueryService', useExisting: QueryService }; +const $ReactionService: Provider = { provide: 'ReactionService', useExisting: ReactionService }; +const $RelayService: Provider = { provide: 'RelayService', useExisting: RelayService }; +const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service }; +const $SignupService: Provider = { provide: 'SignupService', useExisting: SignupService }; +const $TwoFactorAuthenticationService: Provider = { provide: 'TwoFactorAuthenticationService', useExisting: TwoFactorAuthenticationService }; +const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService }; +const $UserCacheService: Provider = { provide: 'UserCacheService', useExisting: UserCacheService }; +const $UserFollowingService: Provider = { provide: 'UserFollowingService', useExisting: UserFollowingService }; +const $UserKeypairStoreService: Provider = { provide: 'UserKeypairStoreService', useExisting: UserKeypairStoreService }; +const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService }; +const $UserMutingService: Provider = { provide: 'UserMutingService', useExisting: UserMutingService }; +const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService }; +const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService }; +const $WebhookService: Provider = { provide: 'WebhookService', useExisting: WebhookService }; +const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService }; +const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService }; +const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService }; +const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart }; +const $NotesChart: Provider = { provide: 'NotesChart', useExisting: NotesChart }; +const $UsersChart: Provider = { provide: 'UsersChart', useExisting: UsersChart }; +const $ActiveUsersChart: Provider = { provide: 'ActiveUsersChart', useExisting: ActiveUsersChart }; +const $InstanceChart: Provider = { provide: 'InstanceChart', useExisting: InstanceChart }; +const $PerUserNotesChart: Provider = { provide: 'PerUserNotesChart', useExisting: PerUserNotesChart }; +const $DriveChart: Provider = { provide: 'DriveChart', useExisting: DriveChart }; +const $PerUserReactionsChart: Provider = { provide: 'PerUserReactionsChart', useExisting: PerUserReactionsChart }; +const $HashtagChart: Provider = { provide: 'HashtagChart', useExisting: HashtagChart }; +const $PerUserFollowingChart: Provider = { provide: 'PerUserFollowingChart', useExisting: PerUserFollowingChart }; +const $PerUserDriveChart: Provider = { provide: 'PerUserDriveChart', useExisting: PerUserDriveChart }; +const $ApRequestChart: Provider = { provide: 'ApRequestChart', useExisting: ApRequestChart }; +const $ChartManagementService: Provider = { provide: 'ChartManagementService', useExisting: ChartManagementService }; + +const $AbuseUserReportEntityService: Provider = { provide: 'AbuseUserReportEntityService', useExisting: AbuseUserReportEntityService }; +const $AntennaEntityService: Provider = { provide: 'AntennaEntityService', useExisting: AntennaEntityService }; +const $AppEntityService: Provider = { provide: 'AppEntityService', useExisting: AppEntityService }; +const $AuthSessionEntityService: Provider = { provide: 'AuthSessionEntityService', useExisting: AuthSessionEntityService }; +const $BlockingEntityService: Provider = { provide: 'BlockingEntityService', useExisting: BlockingEntityService }; +const $ChannelEntityService: Provider = { provide: 'ChannelEntityService', useExisting: ChannelEntityService }; +const $ClipEntityService: Provider = { provide: 'ClipEntityService', useExisting: ClipEntityService }; +const $DriveFileEntityService: Provider = { provide: 'DriveFileEntityService', useExisting: DriveFileEntityService }; +const $DriveFolderEntityService: Provider = { provide: 'DriveFolderEntityService', useExisting: DriveFolderEntityService }; +const $EmojiEntityService: Provider = { provide: 'EmojiEntityService', useExisting: EmojiEntityService }; +const $FollowingEntityService: Provider = { provide: 'FollowingEntityService', useExisting: FollowingEntityService }; +const $FollowRequestEntityService: Provider = { provide: 'FollowRequestEntityService', useExisting: FollowRequestEntityService }; +const $GalleryLikeEntityService: Provider = { provide: 'GalleryLikeEntityService', useExisting: GalleryLikeEntityService }; +const $GalleryPostEntityService: Provider = { provide: 'GalleryPostEntityService', useExisting: GalleryPostEntityService }; +const $HashtagEntityService: Provider = { provide: 'HashtagEntityService', useExisting: HashtagEntityService }; +const $InstanceEntityService: Provider = { provide: 'InstanceEntityService', useExisting: InstanceEntityService }; +const $MessagingMessageEntityService: Provider = { provide: 'MessagingMessageEntityService', useExisting: MessagingMessageEntityService }; +const $ModerationLogEntityService: Provider = { provide: 'ModerationLogEntityService', useExisting: ModerationLogEntityService }; +const $MutingEntityService: Provider = { provide: 'MutingEntityService', useExisting: MutingEntityService }; +const $NoteEntityService: Provider = { provide: 'NoteEntityService', useExisting: NoteEntityService }; +const $NoteFavoriteEntityService: Provider = { provide: 'NoteFavoriteEntityService', useExisting: NoteFavoriteEntityService }; +const $NoteReactionEntityService: Provider = { provide: 'NoteReactionEntityService', useExisting: NoteReactionEntityService }; +const $NotificationEntityService: Provider = { provide: 'NotificationEntityService', useExisting: NotificationEntityService }; +const $PageEntityService: Provider = { provide: 'PageEntityService', useExisting: PageEntityService }; +const $PageLikeEntityService: Provider = { provide: 'PageLikeEntityService', useExisting: PageLikeEntityService }; +const $SigninEntityService: Provider = { provide: 'SigninEntityService', useExisting: SigninEntityService }; +const $UserEntityService: Provider = { provide: 'UserEntityService', useExisting: UserEntityService }; +const $UserGroupEntityService: Provider = { provide: 'UserGroupEntityService', useExisting: UserGroupEntityService }; +const $UserGroupInvitationEntityService: Provider = { provide: 'UserGroupInvitationEntityService', useExisting: UserGroupInvitationEntityService }; +const $UserListEntityService: Provider = { provide: 'UserListEntityService', useExisting: UserListEntityService }; + +const $ApAudienceService: Provider = { provide: 'ApAudienceService', useExisting: ApAudienceService }; +const $ApDbResolverService: Provider = { provide: 'ApDbResolverService', useExisting: ApDbResolverService }; +const $ApDeliverManagerService: Provider = { provide: 'ApDeliverManagerService', useExisting: ApDeliverManagerService }; +const $ApInboxService: Provider = { provide: 'ApInboxService', useExisting: ApInboxService }; +const $ApLoggerService: Provider = { provide: 'ApLoggerService', useExisting: ApLoggerService }; +const $ApMfmService: Provider = { provide: 'ApMfmService', useExisting: ApMfmService }; +const $ApRendererService: Provider = { provide: 'ApRendererService', useExisting: ApRendererService }; +const $ApRequestService: Provider = { provide: 'ApRequestService', useExisting: ApRequestService }; +const $ApResolverService: Provider = { provide: 'ApResolverService', useExisting: ApResolverService }; +const $LdSignatureService: Provider = { provide: 'LdSignatureService', useExisting: LdSignatureService }; +const $RemoteLoggerService: Provider = { provide: 'RemoteLoggerService', useExisting: RemoteLoggerService }; +const $ResolveUserService: Provider = { provide: 'ResolveUserService', useExisting: ResolveUserService }; +const $WebfingerService: Provider = { provide: 'WebfingerService', useExisting: WebfingerService }; +const $ApImageService: Provider = { provide: 'ApImageService', useExisting: ApImageService }; +const $ApMentionService: Provider = { provide: 'ApMentionService', useExisting: ApMentionService }; +const $ApNoteService: Provider = { provide: 'ApNoteService', useExisting: ApNoteService }; +const $ApPersonService: Provider = { provide: 'ApPersonService', useExisting: ApPersonService }; +const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting: ApQuestionService }; +//#endregion + +@Module({ + imports: [ + QueueModule, + ], + providers: [ + LoggerService, + AccountUpdateService, + AiService, + AntennaService, + AppLockService, + CaptchaService, + CreateNotificationService, + CreateSystemUserService, + CustomEmojiService, + DeleteAccountService, + DownloadService, + DriveService, + EmailService, + FederatedInstanceService, + FetchInstanceMetadataService, + GlobalEventService, + HashtagService, + HttpRequestService, + IdService, + ImageProcessingService, + InstanceActorService, + InternalStorageService, + MessagingService, + MetaService, + MfmService, + ModerationLogService, + NoteCreateService, + NoteDeleteService, + NotePiningService, + NoteReadService, + NotificationService, + PollService, + ProxyAccountService, + PushNotificationService, + QueryService, + ReactionService, + RelayService, + S3Service, + SignupService, + TwoFactorAuthenticationService, + UserBlockingService, + UserCacheService, + UserFollowingService, + UserKeypairStoreService, + UserListService, + UserMutingService, + UserSuspendService, + VideoProcessingService, + WebhookService, + UtilityService, + FileInfoService, + ChartLoggerService, + FederationChart, + NotesChart, + UsersChart, + ActiveUsersChart, + InstanceChart, + PerUserNotesChart, + DriveChart, + PerUserReactionsChart, + HashtagChart, + PerUserFollowingChart, + PerUserDriveChart, + ApRequestChart, + ChartManagementService, + AbuseUserReportEntityService, + AntennaEntityService, + AppEntityService, + AuthSessionEntityService, + BlockingEntityService, + ChannelEntityService, + ClipEntityService, + DriveFileEntityService, + DriveFolderEntityService, + EmojiEntityService, + FollowingEntityService, + FollowRequestEntityService, + GalleryLikeEntityService, + GalleryPostEntityService, + HashtagEntityService, + InstanceEntityService, + MessagingMessageEntityService, + ModerationLogEntityService, + MutingEntityService, + NoteEntityService, + NoteFavoriteEntityService, + NoteReactionEntityService, + NotificationEntityService, + PageEntityService, + PageLikeEntityService, + SigninEntityService, + UserEntityService, + UserGroupEntityService, + UserGroupInvitationEntityService, + UserListEntityService, + ApAudienceService, + ApDbResolverService, + ApDeliverManagerService, + ApInboxService, + ApLoggerService, + ApMfmService, + ApRendererService, + ApRequestService, + ApResolverService, + LdSignatureService, + RemoteLoggerService, + ResolveUserService, + WebfingerService, + ApImageService, + ApMentionService, + ApNoteService, + ApPersonService, + ApQuestionService, + QueueService, + + //#region 文字列ベースでのinjection用(循環参照対応のため) + $LoggerService, + $AccountUpdateService, + $AiService, + $AntennaService, + $AppLockService, + $CaptchaService, + $CreateNotificationService, + $CreateSystemUserService, + $CustomEmojiService, + $DeleteAccountService, + $DownloadService, + $DriveService, + $EmailService, + $FederatedInstanceService, + $FetchInstanceMetadataService, + $GlobalEventService, + $HashtagService, + $HttpRequestService, + $IdService, + $ImageProcessingService, + $InstanceActorService, + $InternalStorageService, + $MessagingService, + $MetaService, + $MfmService, + $ModerationLogService, + $NoteCreateService, + $NoteDeleteService, + $NotePiningService, + $NoteReadService, + $NotificationService, + $PollService, + $ProxyAccountService, + $PushNotificationService, + $QueryService, + $ReactionService, + $RelayService, + $S3Service, + $SignupService, + $TwoFactorAuthenticationService, + $UserBlockingService, + $UserCacheService, + $UserFollowingService, + $UserKeypairStoreService, + $UserListService, + $UserMutingService, + $UserSuspendService, + $VideoProcessingService, + $WebhookService, + $UtilityService, + $FileInfoService, + $ChartLoggerService, + $FederationChart, + $NotesChart, + $UsersChart, + $ActiveUsersChart, + $InstanceChart, + $PerUserNotesChart, + $DriveChart, + $PerUserReactionsChart, + $HashtagChart, + $PerUserFollowingChart, + $PerUserDriveChart, + $ApRequestChart, + $ChartManagementService, + $AbuseUserReportEntityService, + $AntennaEntityService, + $AppEntityService, + $AuthSessionEntityService, + $BlockingEntityService, + $ChannelEntityService, + $ClipEntityService, + $DriveFileEntityService, + $DriveFolderEntityService, + $EmojiEntityService, + $FollowingEntityService, + $FollowRequestEntityService, + $GalleryLikeEntityService, + $GalleryPostEntityService, + $HashtagEntityService, + $InstanceEntityService, + $MessagingMessageEntityService, + $ModerationLogEntityService, + $MutingEntityService, + $NoteEntityService, + $NoteFavoriteEntityService, + $NoteReactionEntityService, + $NotificationEntityService, + $PageEntityService, + $PageLikeEntityService, + $SigninEntityService, + $UserEntityService, + $UserGroupEntityService, + $UserGroupInvitationEntityService, + $UserListEntityService, + $ApAudienceService, + $ApDbResolverService, + $ApDeliverManagerService, + $ApInboxService, + $ApLoggerService, + $ApMfmService, + $ApRendererService, + $ApRequestService, + $ApResolverService, + $LdSignatureService, + $RemoteLoggerService, + $ResolveUserService, + $WebfingerService, + $ApImageService, + $ApMentionService, + $ApNoteService, + $ApPersonService, + $ApQuestionService, + //#endregion + ], + exports: [ + QueueModule, + LoggerService, + AccountUpdateService, + AiService, + AntennaService, + AppLockService, + CaptchaService, + CreateNotificationService, + CreateSystemUserService, + CustomEmojiService, + DeleteAccountService, + DownloadService, + DriveService, + EmailService, + FederatedInstanceService, + FetchInstanceMetadataService, + GlobalEventService, + HashtagService, + HttpRequestService, + IdService, + ImageProcessingService, + InstanceActorService, + InternalStorageService, + MessagingService, + MetaService, + MfmService, + ModerationLogService, + NoteCreateService, + NoteDeleteService, + NotePiningService, + NoteReadService, + NotificationService, + PollService, + ProxyAccountService, + PushNotificationService, + QueryService, + ReactionService, + RelayService, + S3Service, + SignupService, + TwoFactorAuthenticationService, + UserBlockingService, + UserCacheService, + UserFollowingService, + UserKeypairStoreService, + UserListService, + UserMutingService, + UserSuspendService, + VideoProcessingService, + WebhookService, + UtilityService, + FileInfoService, + FederationChart, + NotesChart, + UsersChart, + ActiveUsersChart, + InstanceChart, + PerUserNotesChart, + DriveChart, + PerUserReactionsChart, + HashtagChart, + PerUserFollowingChart, + PerUserDriveChart, + ApRequestChart, + ChartManagementService, + AbuseUserReportEntityService, + AntennaEntityService, + AppEntityService, + AuthSessionEntityService, + BlockingEntityService, + ChannelEntityService, + ClipEntityService, + DriveFileEntityService, + DriveFolderEntityService, + EmojiEntityService, + FollowingEntityService, + FollowRequestEntityService, + GalleryLikeEntityService, + GalleryPostEntityService, + HashtagEntityService, + InstanceEntityService, + MessagingMessageEntityService, + ModerationLogEntityService, + MutingEntityService, + NoteEntityService, + NoteFavoriteEntityService, + NoteReactionEntityService, + NotificationEntityService, + PageEntityService, + PageLikeEntityService, + SigninEntityService, + UserEntityService, + UserGroupEntityService, + UserGroupInvitationEntityService, + UserListEntityService, + ApAudienceService, + ApDbResolverService, + ApDeliverManagerService, + ApInboxService, + ApLoggerService, + ApMfmService, + ApRendererService, + ApRequestService, + ApResolverService, + LdSignatureService, + RemoteLoggerService, + ResolveUserService, + WebfingerService, + ApImageService, + ApMentionService, + ApNoteService, + ApPersonService, + ApQuestionService, + QueueService, + + //#region 文字列ベースでのinjection用(循環参照対応のため) + $LoggerService, + $AccountUpdateService, + $AiService, + $AntennaService, + $AppLockService, + $CaptchaService, + $CreateNotificationService, + $CreateSystemUserService, + $CustomEmojiService, + $DeleteAccountService, + $DownloadService, + $DriveService, + $EmailService, + $FederatedInstanceService, + $FetchInstanceMetadataService, + $GlobalEventService, + $HashtagService, + $HttpRequestService, + $IdService, + $ImageProcessingService, + $InstanceActorService, + $InternalStorageService, + $MessagingService, + $MetaService, + $MfmService, + $ModerationLogService, + $NoteCreateService, + $NoteDeleteService, + $NotePiningService, + $NoteReadService, + $NotificationService, + $PollService, + $ProxyAccountService, + $PushNotificationService, + $QueryService, + $ReactionService, + $RelayService, + $S3Service, + $SignupService, + $TwoFactorAuthenticationService, + $UserBlockingService, + $UserCacheService, + $UserFollowingService, + $UserKeypairStoreService, + $UserListService, + $UserMutingService, + $UserSuspendService, + $VideoProcessingService, + $WebhookService, + $UtilityService, + $FileInfoService, + $FederationChart, + $NotesChart, + $UsersChart, + $ActiveUsersChart, + $InstanceChart, + $PerUserNotesChart, + $DriveChart, + $PerUserReactionsChart, + $HashtagChart, + $PerUserFollowingChart, + $PerUserDriveChart, + $ApRequestChart, + $ChartManagementService, + $AbuseUserReportEntityService, + $AntennaEntityService, + $AppEntityService, + $AuthSessionEntityService, + $BlockingEntityService, + $ChannelEntityService, + $ClipEntityService, + $DriveFileEntityService, + $DriveFolderEntityService, + $EmojiEntityService, + $FollowingEntityService, + $FollowRequestEntityService, + $GalleryLikeEntityService, + $GalleryPostEntityService, + $HashtagEntityService, + $InstanceEntityService, + $MessagingMessageEntityService, + $ModerationLogEntityService, + $MutingEntityService, + $NoteEntityService, + $NoteFavoriteEntityService, + $NoteReactionEntityService, + $NotificationEntityService, + $PageEntityService, + $PageLikeEntityService, + $SigninEntityService, + $UserEntityService, + $UserGroupEntityService, + $UserGroupInvitationEntityService, + $UserListEntityService, + $ApAudienceService, + $ApDbResolverService, + $ApDeliverManagerService, + $ApInboxService, + $ApLoggerService, + $ApMfmService, + $ApRendererService, + $ApRequestService, + $ApResolverService, + $LdSignatureService, + $RemoteLoggerService, + $ResolveUserService, + $WebfingerService, + $ApImageService, + $ApMentionService, + $ApNoteService, + $ApPersonService, + $ApQuestionService, + //#endregion + ], +}) +export class CoreModule {} diff --git a/packages/backend/src/core/CreateNotificationService.ts b/packages/backend/src/core/CreateNotificationService.ts new file mode 100644 index 000000000..525fac6d9 --- /dev/null +++ b/packages/backend/src/core/CreateNotificationService.ts @@ -0,0 +1,114 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { MutingsRepository, NotificationsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; +import type { User } from '@/models/entities/User.js'; +import type { Notification } from '@/models/entities/Notification.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { IdService } from '@/core/IdService.js'; +import { DI } from '@/di-symbols.js'; +import { NotificationEntityService } from './entities/NotificationEntityService.js'; +import { PushNotificationService } from './PushNotificationService.js'; + +@Injectable() +export class CreateNotificationService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.notificationsRepository) + private notificationsRepository: NotificationsRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + private notificationEntityService: NotificationEntityService, + private idService: IdService, + private globalEventServie: GlobalEventService, + private pushNotificationService: PushNotificationService, + ) { + } + + public async createNotification( + notifieeId: User['id'], + type: Notification['type'], + data: Partial, + ): Promise { + if (data.notifierId && (notifieeId === data.notifierId)) { + return null; + } + + const profile = await this.userProfilesRepository.findOneBy({ userId: notifieeId }); + + const isMuted = profile?.mutingNotificationTypes.includes(type); + + // Create notification + const notification = await this.notificationsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + notifieeId: notifieeId, + type: type, + // 相手がこの通知をミュートしているようなら、既読を予めつけておく + isRead: isMuted, + ...data, + } as Partial) + .then(x => this.notificationsRepository.findOneByOrFail(x.identifiers[0])); + + const packed = await this.notificationEntityService.pack(notification, {}); + + // Publish notification event + this.globalEventServie.publishMainStream(notifieeId, 'notification', packed); + + // 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する + setTimeout(async () => { + const fresh = await this.notificationsRepository.findOneBy({ id: notification.id }); + if (fresh == null) return; // 既に削除されているかもしれない + if (fresh.isRead) return; + + //#region ただしミュートしているユーザーからの通知なら無視 + const mutings = await this.mutingsRepository.findBy({ + muterId: notifieeId, + }); + if (data.notifierId && mutings.map(m => m.muteeId).includes(data.notifierId)) { + return; + } + //#endregion + + this.globalEventServie.publishMainStream(notifieeId, 'unreadNotification', packed); + this.pushNotificationService.pushNotification(notifieeId, 'notification', packed); + + if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); + if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); + }, 2000); + + return notification; + } + + // TODO + //const locales = await import('../../../../locales/index.js'); + + // TODO: locale ファイルをクライアント用とサーバー用で分けたい + + private async emailNotificationFollow(userId: User['id'], follower: User) { + /* + const userProfile = await UserProfiles.findOneByOrFail({ userId: userId }); + if (!userProfile.email || !userProfile.emailNotificationTypes.includes('follow')) return; + const locale = locales[userProfile.lang ?? 'ja-JP']; + const i18n = new I18n(locale); + // TODO: render user information html + sendEmail(userProfile.email, i18n.t('_email._follow.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); + */ + } + + private async emailNotificationReceiveFollowRequest(userId: User['id'], follower: User) { + /* + const userProfile = await UserProfiles.findOneByOrFail({ userId: userId }); + if (!userProfile.email || !userProfile.emailNotificationTypes.includes('receiveFollowRequest')) return; + const locale = locales[userProfile.lang ?? 'ja-JP']; + const i18n = new I18n(locale); + // TODO: render user information html + sendEmail(userProfile.email, i18n.t('_email._receiveFollowRequest.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); + */ + } +} diff --git a/packages/backend/src/core/CreateSystemUserService.ts b/packages/backend/src/core/CreateSystemUserService.ts new file mode 100644 index 000000000..71f50d7cb --- /dev/null +++ b/packages/backend/src/core/CreateSystemUserService.ts @@ -0,0 +1,80 @@ +import { Inject, Injectable } from '@nestjs/common'; +import bcrypt from 'bcryptjs'; +import { v4 as uuid } from 'uuid'; +import { IsNull, DataSource } from 'typeorm'; +import { genRsaKeyPair } from '@/misc/gen-key-pair.js'; +import { User } from '@/models/entities/User.js'; +import { UserProfile } from '@/models/entities/UserProfile.js'; +import { IdService } from '@/core/IdService.js'; +import { UserKeypair } from '@/models/entities/UserKeypair.js'; +import { UsedUsername } from '@/models/entities/UsedUsername.js'; +import { DI } from '@/di-symbols.js'; +import generateNativeUserToken from '@/misc/generate-native-user-token.js'; + +@Injectable() +export class CreateSystemUserService { + constructor( + @Inject(DI.db) + private db: DataSource, + + private idService: IdService, + ) { + } + + public async createSystemUser(username: string): Promise { + const password = uuid(); + + // Generate hash of password + const salt = await bcrypt.genSalt(8); + const hash = await bcrypt.hash(password, salt); + + // Generate secret + const secret = generateNativeUserToken(); + + const keyPair = await genRsaKeyPair(4096); + + let account!: User; + + // Start transaction + await this.db.transaction(async transactionalEntityManager => { + const exist = await transactionalEntityManager.findOneBy(User, { + usernameLower: username.toLowerCase(), + host: IsNull(), + }); + + if (exist) throw new Error('the user is already exists'); + + account = await transactionalEntityManager.insert(User, { + id: this.idService.genId(), + createdAt: new Date(), + username: username, + usernameLower: username.toLowerCase(), + host: null, + token: secret, + isAdmin: false, + isLocked: true, + isExplorable: false, + isBot: true, + }).then(x => transactionalEntityManager.findOneByOrFail(User, x.identifiers[0])); + + await transactionalEntityManager.insert(UserKeypair, { + publicKey: keyPair.publicKey, + privateKey: keyPair.privateKey, + userId: account.id, + }); + + await transactionalEntityManager.insert(UserProfile, { + userId: account.id, + autoAcceptFollowed: false, + password: hash, + }); + + await transactionalEntityManager.insert(UsedUsername, { + createdAt: new Date(), + username: username.toLowerCase(), + }); + }); + + return account; + } +} diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts new file mode 100644 index 000000000..32dad70d1 --- /dev/null +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -0,0 +1,175 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DataSource, In, IsNull } from 'typeorm'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import { IdService } from '@/core/IdService.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import type { Emoji } from '@/models/entities/Emoji.js'; +import { Cache } from '@/misc/cache.js'; +import { query } from '@/misc/prelude/url.js'; +import type { Note } from '@/models/entities/Note.js'; +import { EmojisRepository } from '@/models/index.js'; +import { UtilityService } from './UtilityService.js'; +import { ReactionService } from './ReactionService.js'; + +/** + * 添付用絵文字情報 + */ +type PopulatedEmoji = { + name: string; + url: string; +}; + +@Injectable() +export class CustomEmojiService { + private cache: Cache; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, + + private idService: IdService, + private globalEventServie: GlobalEventService, + private utilityService: UtilityService, + private reactionService: ReactionService, + ) { + this.cache = new Cache(1000 * 60 * 60 * 12); + } + + public async add(data: { + driveFile: DriveFile; + name: string; + category: string | null; + aliases: string[]; + host: string | null; + }): Promise { + const emoji = await this.emojisRepository.insert({ + id: this.idService.genId(), + updatedAt: new Date(), + name: data.name, + category: data.category, + host: data.host, + aliases: data.aliases, + originalUrl: data.driveFile.url, + publicUrl: data.driveFile.webpublicUrl ?? data.driveFile.url, + type: data.driveFile.webpublicType ?? data.driveFile.type, + }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); + + await this.db.queryResultCache!.remove(['meta_emojis']); + + return emoji; + } + + private normalizeHost(src: string | undefined, noteUserHost: string | null): string | null { + // クエリに使うホスト + let host = src === '.' ? null // .はローカルホスト (ここがマッチするのはリアクションのみ) + : src === undefined ? noteUserHost // ノートなどでホスト省略表記の場合はローカルホスト (ここがリアクションにマッチすることはない) + : this.utilityService.isSelfHost(src) ? null // 自ホスト指定 + : (src || noteUserHost); // 指定されたホスト || ノートなどの所有者のホスト (こっちがリアクションにマッチすることはない) + + host = this.utilityService.toPunyNullable(host); + + return host; + } + + private parseEmojiStr(emojiName: string, noteUserHost: string | null) { + const match = emojiName.match(/^(\w+)(?:@([\w.-]+))?$/); + if (!match) return { name: null, host: null }; + + const name = match[1]; + + // ホスト正規化 + const host = this.utilityService.toPunyNullable(this.normalizeHost(match[2], noteUserHost)); + + return { name, host }; + } + + /** + * 添付用絵文字情報を解決する + * @param emojiName ノートやユーザープロフィールに添付された、またはリアクションのカスタム絵文字名 (:は含めない, リアクションでローカルホストの場合は@.を付ける (これはdecodeReactionで可能)) + * @param noteUserHost ノートやユーザープロフィールの所有者のホスト + * @returns 絵文字情報, nullは未マッチを意味する + */ + public async populateEmoji(emojiName: string, noteUserHost: string | null): Promise { + const { name, host } = this.parseEmojiStr(emojiName, noteUserHost); + if (name == null) return null; + + const queryOrNull = async () => (await this.emojisRepository.findOneBy({ + name, + host: host ?? IsNull(), + })) ?? null; + + const emoji = await this.cache.fetch(`${name} ${host}`, queryOrNull); + + if (emoji == null) return null; + + const isLocal = emoji.host == null; + const emojiUrl = emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため + const url = isLocal ? emojiUrl : `${this.config.url}/proxy/${encodeURIComponent((new URL(emojiUrl)).pathname)}?${query({ url: emojiUrl })}`; + + return { + name: emojiName, + url, + }; + } + + /** + * 複数の添付用絵文字情報を解決する (キャシュ付き, 存在しないものは結果から除外される) + */ + public async populateEmojis(emojiNames: string[], noteUserHost: string | null): Promise { + const emojis = await Promise.all(emojiNames.map(x => this.populateEmoji(x, noteUserHost))); + return emojis.filter((x): x is PopulatedEmoji => x != null); + } + + public aggregateNoteEmojis(notes: Note[]) { + let emojis: { name: string | null; host: string | null; }[] = []; + for (const note of notes) { + emojis = emojis.concat(note.emojis + .map(e => this.parseEmojiStr(e, note.userHost))); + if (note.renote) { + emojis = emojis.concat(note.renote.emojis + .map(e => this.parseEmojiStr(e, note.renote!.userHost))); + if (note.renote.user) { + emojis = emojis.concat(note.renote.user.emojis + .map(e => this.parseEmojiStr(e, note.renote!.userHost))); + } + } + const customReactions = Object.keys(note.reactions).map(x => this.reactionService.decodeReaction(x)).filter(x => x.name != null) as typeof emojis; + emojis = emojis.concat(customReactions); + if (note.user) { + emojis = emojis.concat(note.user.emojis + .map(e => this.parseEmojiStr(e, note.userHost))); + } + } + return emojis.filter(x => x.name != null) as { name: string; host: string | null; }[]; + } + + /** + * 与えられた絵文字のリストをデータベースから取得し、キャッシュに追加します + */ + public async prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise { + const notCachedEmojis = emojis.filter(emoji => this.cache.get(`${emoji.name} ${emoji.host}`) == null); + const emojisQuery: any[] = []; + const hosts = new Set(notCachedEmojis.map(e => e.host)); + for (const host of hosts) { + emojisQuery.push({ + name: In(notCachedEmojis.filter(e => e.host === host).map(e => e.name)), + host: host ?? IsNull(), + }); + } + const _emojis = emojisQuery.length > 0 ? await this.emojisRepository.find({ + where: emojisQuery, + select: ['name', 'host', 'originalUrl', 'publicUrl'], + }) : []; + for (const emoji of _emojis) { + this.cache.set(`${emoji.name} ${emoji.host}`, emoji); + } + } +} diff --git a/packages/backend/src/core/DeleteAccountService.ts b/packages/backend/src/core/DeleteAccountService.ts new file mode 100644 index 000000000..ba67bc499 --- /dev/null +++ b/packages/backend/src/core/DeleteAccountService.ts @@ -0,0 +1,38 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { UsersRepository } from '@/models/index.js'; +import { QueueService } from '@/core/QueueService.js'; +import { UserSuspendService } from '@/core/UserSuspendService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; + +@Injectable() +export class DeleteAccountService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private userSuspendService: UserSuspendService, + private queueService: QueueService, + private globalEventServie: GlobalEventService, + ) { + } + + public async deleteAccount(user: { + id: string; + host: string | null; + }): Promise { + // 物理削除する前にDelete activityを送信する + await this.userSuspendService.doPostSuspend(user).catch(e => {}); + + this.queueService.createDeleteAccountJob(user, { + soft: false, + }); + + await this.usersRepository.update(user.id, { + isDeleted: true, + }); + + // Terminate streaming + this.globalEventServie.publishUserEvent(user.id, 'terminate', {}); + } +} diff --git a/packages/backend/src/core/DownloadService.ts b/packages/backend/src/core/DownloadService.ts new file mode 100644 index 000000000..81939d5f5 --- /dev/null +++ b/packages/backend/src/core/DownloadService.ts @@ -0,0 +1,125 @@ +import * as fs from 'node:fs'; +import * as stream from 'node:stream'; +import * as util from 'node:util'; +import { Inject, Injectable } from '@nestjs/common'; +import IPCIDR from 'ip-cidr'; +import PrivateIp from 'private-ip'; +import got, * as Got from 'got'; +import chalk from 'chalk'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { createTemp } from '@/misc/create-temp.js'; +import { StatusError } from '@/misc/status-error.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import type Logger from '@/logger.js'; + +const pipeline = util.promisify(stream.pipeline); + +@Injectable() +export class DownloadService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + private httpRequestService: HttpRequestService, + private loggerService: LoggerService, + ) { + this.logger = this.loggerService.getLogger('download'); + } + + public async downloadUrl(url: string, path: string): Promise { + this.logger.info(`Downloading ${chalk.cyan(url)} ...`); + + const timeout = 30 * 1000; + const operationTimeout = 60 * 1000; + const maxSize = this.config.maxFileSize ?? 262144000; + + const req = got.stream(url, { + headers: { + 'User-Agent': this.config.userAgent, + }, + timeout: { + lookup: timeout, + connect: timeout, + secureConnect: timeout, + socket: timeout, // read timeout + response: timeout, + send: timeout, + request: operationTimeout, // whole operation timeout + }, + agent: { + http: this.httpRequestService.httpAgent, + https: this.httpRequestService.httpsAgent, + }, + http2: false, // default + retry: { + limit: 0, + }, + }).on('response', (res: Got.Response) => { + if ((process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test') && !this.config.proxy && res.ip) { + if (this.isPrivateIp(res.ip)) { + this.logger.warn(`Blocked address: ${res.ip}`); + req.destroy(); + } + } + + const contentLength = res.headers['content-length']; + if (contentLength != null) { + const size = Number(contentLength); + if (size > maxSize) { + this.logger.warn(`maxSize exceeded (${size} > ${maxSize}) on response`); + req.destroy(); + } + } + }).on('downloadProgress', (progress: Got.Progress) => { + if (progress.transferred > maxSize) { + this.logger.warn(`maxSize exceeded (${progress.transferred} > ${maxSize}) on downloadProgress`); + req.destroy(); + } + }); + + try { + await pipeline(req, fs.createWriteStream(path)); + } catch (e) { + if (e instanceof Got.HTTPError) { + throw new StatusError(`${e.response.statusCode} ${e.response.statusMessage}`, e.response.statusCode, e.response.statusMessage); + } else { + throw e; + } + } + + this.logger.succ(`Download finished: ${chalk.cyan(url)}`); + } + + public async downloadTextFile(url: string): Promise { + // Create temp file + const [path, cleanup] = await createTemp(); + + this.logger.info(`text file: Temp file is ${path}`); + + try { + // write content at URL to temp file + await this.downloadUrl(url, path); + + const text = await util.promisify(fs.readFile)(path, 'utf8'); + + return text; + } finally { + cleanup(); + } + } + + private isPrivateIp(ip: string): boolean { + for (const net of this.config.allowedPrivateNetworks ?? []) { + const cidr = new IPCIDR(net); + if (cidr.contains(ip)) { + return false; + } + } + + return PrivateIp(ip); + } +} diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts new file mode 100644 index 000000000..e356fa000 --- /dev/null +++ b/packages/backend/src/core/DriveService.ts @@ -0,0 +1,740 @@ +import * as fs from 'node:fs'; +import { Inject, Injectable } from '@nestjs/common'; +import { v4 as uuid } from 'uuid'; +import sharp from 'sharp'; +import { IsNull } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { DriveFilesRepository, UsersRepository, DriveFoldersRepository, UserProfilesRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import Logger from '@/logger.js'; +import type { IRemoteUser, User } from '@/models/entities/User.js'; +import { MetaService } from '@/core/MetaService.js'; +import { DriveFile } from '@/models/entities/DriveFile.js'; +import { IdService } from '@/core/IdService.js'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; +import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { contentDisposition } from '@/misc/content-disposition.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { VideoProcessingService } from '@/core/VideoProcessingService.js'; +import { ImageProcessingService } from '@/core/ImageProcessingService.js'; +import type { IImage } from '@/core/ImageProcessingService.js'; +import { QueueService } from '@/core/QueueService.js'; +import type { DriveFolder } from '@/models/entities/DriveFolder.js'; +import { createTemp } from '@/misc/create-temp.js'; +import DriveChart from '@/core/chart/charts/drive.js'; +import PerUserDriveChart from '@/core/chart/charts/per-user-drive.js'; +import InstanceChart from '@/core/chart/charts/instance.js'; +import { DownloadService } from '@/core/DownloadService.js'; +import { S3Service } from '@/core/S3Service.js'; +import { InternalStorageService } from '@/core/InternalStorageService.js'; +import { DriveFileEntityService } from './entities/DriveFileEntityService.js'; +import { UserEntityService } from './entities/UserEntityService.js'; +import { FileInfoService } from './FileInfoService.js'; +import type S3 from 'aws-sdk/clients/s3.js'; + +type AddFileArgs = { + /** User who wish to add file */ + user: { id: User['id']; host: User['host']; driveCapacityOverrideMb: User['driveCapacityOverrideMb'] } | null; + /** File path */ + path: string; + /** Name */ + name?: string | null; + /** Comment */ + comment?: string | null; + /** Folder ID */ + folderId?: any; + /** If set to true, forcibly upload the file even if there is a file with the same hash. */ + force?: boolean; + /** Do not save file to local */ + isLink?: boolean; + /** URL of source (URLからアップロードされた場合(ローカル/リモート)の元URL) */ + url?: string | null; + /** URL of source (リモートインスタンスのURLからアップロードされた場合の元URL) */ + uri?: string | null; + /** Mark file as sensitive */ + sensitive?: boolean | null; + + requestIp?: string | null; + requestHeaders?: Record | null; +}; + +type UploadFromUrlArgs = { + url: string; + user: { id: User['id']; host: User['host'] } | null; + folderId?: DriveFolder['id'] | null; + uri?: string | null; + sensitive?: boolean; + force?: boolean; + isLink?: boolean; + comment?: string | null; + requestIp?: string | null; + requestHeaders?: Record | null; +}; + +@Injectable() +export class DriveService { + private registerLogger: Logger; + private downloaderLogger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + @Inject(DI.driveFoldersRepository) + private driveFoldersRepository: DriveFoldersRepository, + + private fileInfoService: FileInfoService, + private userEntityService: UserEntityService, + private driveFileEntityService: DriveFileEntityService, + private idService: IdService, + private metaService: MetaService, + private downloadService: DownloadService, + private internalStorageService: InternalStorageService, + private s3Service: S3Service, + private imageProcessingService: ImageProcessingService, + private videoProcessingService: VideoProcessingService, + private globalEventService: GlobalEventService, + private queueService: QueueService, + private driveChart: DriveChart, + private perUserDriveChart: PerUserDriveChart, + private instanceChart: InstanceChart, + ) { + const logger = new Logger('drive', 'blue'); + this.registerLogger = logger.createSubLogger('register', 'yellow'); + this.downloaderLogger = logger.createSubLogger('downloader'); + } + + /*** + * Save file + * @param path Path for original + * @param name Name for original + * @param type Content-Type for original + * @param hash Hash for original + * @param size Size for original + */ + private async save(file: DriveFile, path: string, name: string, type: string, hash: string, size: number): Promise { + // thunbnail, webpublic を必要なら生成 + const alts = await this.generateAlts(path, type, !file.uri); + + const meta = await this.metaService.fetch(); + + if (meta.useObjectStorage) { + //#region ObjectStorage params + let [ext] = (name.match(/\.([a-zA-Z0-9_-]+)$/) ?? ['']); + + if (ext === '') { + if (type === 'image/jpeg') ext = '.jpg'; + if (type === 'image/png') ext = '.png'; + if (type === 'image/webp') ext = '.webp'; + if (type === 'image/apng') ext = '.apng'; + if (type === 'image/vnd.mozilla.apng') ext = '.apng'; + } + + // 拡張子からContent-Typeを設定してそうな挙動を示すオブジェクトストレージ (upcloud?) も存在するので、 + // 許可されているファイル形式でしか拡張子をつけない + if (!FILE_TYPE_BROWSERSAFE.includes(type)) { + ext = ''; + } + + const baseUrl = meta.objectStorageBaseUrl + ?? `${ meta.objectStorageUseSSL ? 'https' : 'http' }://${ meta.objectStorageEndpoint }${ meta.objectStoragePort ? `:${meta.objectStoragePort}` : '' }/${ meta.objectStorageBucket }`; + + // for original + const key = `${meta.objectStoragePrefix}/${uuid()}${ext}`; + const url = `${ baseUrl }/${ key }`; + + // for alts + let webpublicKey: string | null = null; + let webpublicUrl: string | null = null; + let thumbnailKey: string | null = null; + let thumbnailUrl: string | null = null; + //#endregion + + //#region Uploads + this.registerLogger.info(`uploading original: ${key}`); + const uploads = [ + this.upload(key, fs.createReadStream(path), type, name), + ]; + + if (alts.webpublic) { + webpublicKey = `${meta.objectStoragePrefix}/webpublic-${uuid()}.${alts.webpublic.ext}`; + webpublicUrl = `${ baseUrl }/${ webpublicKey }`; + + this.registerLogger.info(`uploading webpublic: ${webpublicKey}`); + uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, name)); + } + + if (alts.thumbnail) { + thumbnailKey = `${meta.objectStoragePrefix}/thumbnail-${uuid()}.${alts.thumbnail.ext}`; + thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`; + + this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`); + uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type)); + } + + await Promise.all(uploads); + //#endregion + + file.url = url; + file.thumbnailUrl = thumbnailUrl; + file.webpublicUrl = webpublicUrl; + file.accessKey = key; + file.thumbnailAccessKey = thumbnailKey; + file.webpublicAccessKey = webpublicKey; + file.webpublicType = alts.webpublic?.type ?? null; + file.name = name; + file.type = type; + file.md5 = hash; + file.size = size; + file.storedInternal = false; + + return await this.driveFilesRepository.insert(file).then(x => this.driveFilesRepository.findOneByOrFail(x.identifiers[0])); + } else { // use internal storage + const accessKey = uuid(); + const thumbnailAccessKey = 'thumbnail-' + uuid(); + const webpublicAccessKey = 'webpublic-' + uuid(); + + const url = this.internalStorageService.saveFromPath(accessKey, path); + + let thumbnailUrl: string | null = null; + let webpublicUrl: string | null = null; + + if (alts.thumbnail) { + thumbnailUrl = this.internalStorageService.saveFromBuffer(thumbnailAccessKey, alts.thumbnail.data); + this.registerLogger.info(`thumbnail stored: ${thumbnailAccessKey}`); + } + + if (alts.webpublic) { + webpublicUrl = this.internalStorageService.saveFromBuffer(webpublicAccessKey, alts.webpublic.data); + this.registerLogger.info(`web stored: ${webpublicAccessKey}`); + } + + file.storedInternal = true; + file.url = url; + file.thumbnailUrl = thumbnailUrl; + file.webpublicUrl = webpublicUrl; + file.accessKey = accessKey; + file.thumbnailAccessKey = thumbnailAccessKey; + file.webpublicAccessKey = webpublicAccessKey; + file.webpublicType = alts.webpublic?.type ?? null; + file.name = name; + file.type = type; + file.md5 = hash; + file.size = size; + + return await this.driveFilesRepository.insert(file).then(x => this.driveFilesRepository.findOneByOrFail(x.identifiers[0])); + } + } + + /** + * Generate webpublic, thumbnail, etc + * @param path Path for original + * @param type Content-Type for original + * @param generateWeb Generate webpublic or not + */ + public async generateAlts(path: string, type: string, generateWeb: boolean) { + if (type.startsWith('video/')) { + try { + const thumbnail = await this.videoProcessingService.generateVideoThumbnail(path); + return { + webpublic: null, + thumbnail, + }; + } catch (err) { + this.registerLogger.warn(`GenerateVideoThumbnail failed: ${err}`); + return { + webpublic: null, + thumbnail: null, + }; + } + } + + if (!['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml'].includes(type)) { + this.registerLogger.debug('web image and thumbnail not created (not an required file)'); + return { + webpublic: null, + thumbnail: null, + }; + } + + let img: sharp.Sharp | null = null; + let satisfyWebpublic: boolean; + + try { + img = sharp(path); + const metadata = await img.metadata(); + const isAnimated = metadata.pages && metadata.pages > 1; + + // skip animated + if (isAnimated) { + return { + webpublic: null, + thumbnail: null, + }; + } + + satisfyWebpublic = !!( + type !== 'image/svg+xml' && type !== 'image/webp' && + !(metadata.exif ?? metadata.iptc ?? metadata.xmp ?? metadata.tifftagPhotoshop) && + metadata.width && metadata.width <= 2048 && + metadata.height && metadata.height <= 2048 + ); + } catch (err) { + this.registerLogger.warn(`sharp failed: ${err}`); + return { + webpublic: null, + thumbnail: null, + }; + } + + // #region webpublic + let webpublic: IImage | null = null; + + if (generateWeb && !satisfyWebpublic) { + this.registerLogger.info('creating web image'); + + try { + if (['image/jpeg', 'image/webp'].includes(type)) { + webpublic = await this.imageProcessingService.convertSharpToJpeg(img, 2048, 2048); + } else if (['image/png'].includes(type)) { + webpublic = await this.imageProcessingService.convertSharpToPng(img, 2048, 2048); + } else if (['image/svg+xml'].includes(type)) { + webpublic = await this.imageProcessingService.convertSharpToPng(img, 2048, 2048); + } else { + this.registerLogger.debug('web image not created (not an required image)'); + } + } catch (err) { + this.registerLogger.warn('web image not created (an error occured)', err as Error); + } + } else { + if (satisfyWebpublic) this.registerLogger.info('web image not created (original satisfies webpublic)'); + else this.registerLogger.info('web image not created (from remote)'); + } + // #endregion webpublic + + // #region thumbnail + let thumbnail: IImage | null = null; + + try { + if (['image/jpeg', 'image/webp', 'image/png', 'image/svg+xml'].includes(type)) { + thumbnail = await this.imageProcessingService.convertSharpToWebp(img, 498, 280); + } else { + this.registerLogger.debug('thumbnail not created (not an required file)'); + } + } catch (err) { + this.registerLogger.warn('thumbnail not created (an error occured)', err as Error); + } + // #endregion thumbnail + + return { + webpublic, + thumbnail, + }; + } + + /** + * Upload to ObjectStorage + */ + private async upload(key: string, stream: fs.ReadStream | Buffer, type: string, filename?: string) { + if (type === 'image/apng') type = 'image/png'; + if (!FILE_TYPE_BROWSERSAFE.includes(type)) type = 'application/octet-stream'; + + const meta = await this.metaService.fetch(); + + const params = { + Bucket: meta.objectStorageBucket, + Key: key, + Body: stream, + ContentType: type, + CacheControl: 'max-age=31536000, immutable', + } as S3.PutObjectRequest; + + if (filename) params.ContentDisposition = contentDisposition('inline', filename); + if (meta.objectStorageSetPublicRead) params.ACL = 'public-read'; + + const s3 = this.s3Service.getS3(meta); + + const upload = s3.upload(params, { + partSize: s3.endpoint.hostname === 'storage.googleapis.com' ? 500 * 1024 * 1024 : 8 * 1024 * 1024, + }); + + const result = await upload.promise(); + if (result) this.registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`); + } + + private async deleteOldFile(user: IRemoteUser) { + const q = this.driveFilesRepository.createQueryBuilder('file') + .where('file.userId = :userId', { userId: user.id }) + .andWhere('file.isLink = FALSE'); + + if (user.avatarId) { + q.andWhere('file.id != :avatarId', { avatarId: user.avatarId }); + } + + if (user.bannerId) { + q.andWhere('file.id != :bannerId', { bannerId: user.bannerId }); + } + + q.orderBy('file.id', 'ASC'); + + const oldFile = await q.getOne(); + + if (oldFile) { + this.deleteFile(oldFile, true); + } + } + + /** + * Add file to drive + * + */ + public async addFile({ + user, + path, + name = null, + comment = null, + folderId = null, + force = false, + isLink = false, + url = null, + uri = null, + sensitive = null, + requestIp = null, + requestHeaders = null, + }: AddFileArgs): Promise { + let skipNsfwCheck = false; + const instance = await this.metaService.fetch(); + if (user == null) skipNsfwCheck = true; + if (instance.sensitiveMediaDetection === 'none') skipNsfwCheck = true; + if (user && instance.sensitiveMediaDetection === 'local' && this.userEntityService.isRemoteUser(user)) skipNsfwCheck = true; + if (user && instance.sensitiveMediaDetection === 'remote' && this.userEntityService.isLocalUser(user)) skipNsfwCheck = true; + + const info = await this.fileInfoService.getFileInfo(path, { + skipSensitiveDetection: skipNsfwCheck, + sensitiveThreshold: // 感度が高いほどしきい値は低くすることになる + instance.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 0.1 : + instance.sensitiveMediaDetectionSensitivity === 'high' ? 0.3 : + instance.sensitiveMediaDetectionSensitivity === 'low' ? 0.7 : + instance.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0.9 : + 0.5, + sensitiveThresholdForPorn: 0.75, + enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos, + }); + this.registerLogger.info(`${JSON.stringify(info)}`); + + // 現状 false positive が多すぎて実用に耐えない + //if (info.porn && instance.disallowUploadWhenPredictedAsPorn) { + // throw new IdentifiableError('282f77bf-5816-4f72-9264-aa14d8261a21', 'Detected as porn.'); + //} + + // detect name + const detectedName = name ?? (info.type.ext ? `untitled.${info.type.ext}` : 'untitled'); + + if (user && !force) { + // Check if there is a file with the same hash + const much = await this.driveFilesRepository.findOneBy({ + md5: info.md5, + userId: user.id, + }); + + if (much) { + this.registerLogger.info(`file with same hash is found: ${much.id}`); + return much; + } + } + + //#region Check drive usage + if (user && !isLink) { + const usage = await this.driveFileEntityService.calcDriveUsageOf(user); + const u = await this.usersRepository.findOneBy({ id: user.id }); + + const instance = await this.metaService.fetch(); + let driveCapacity = 1024 * 1024 * (this.userEntityService.isLocalUser(user) ? instance.localDriveCapacityMb : instance.remoteDriveCapacityMb); + + if (this.userEntityService.isLocalUser(user) && u?.driveCapacityOverrideMb != null) { + driveCapacity = 1024 * 1024 * u.driveCapacityOverrideMb; + this.registerLogger.debug('drive capacity override applied'); + this.registerLogger.debug(`overrideCap: ${driveCapacity}bytes, usage: ${usage}bytes, u+s: ${usage + info.size}bytes`); + } + + this.registerLogger.debug(`drive usage is ${usage} (max: ${driveCapacity})`); + + // If usage limit exceeded + if (usage + info.size > driveCapacity) { + if (this.userEntityService.isLocalUser(user)) { + throw new IdentifiableError('c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6', 'No free space.'); + } else { + // (アバターまたはバナーを含まず)最も古いファイルを削除する + this.deleteOldFile(await this.usersRepository.findOneByOrFail({ id: user.id }) as IRemoteUser); + } + } + } + //#endregion + + const fetchFolder = async () => { + if (!folderId) { + return null; + } + + const driveFolder = await this.driveFoldersRepository.findOneBy({ + id: folderId, + userId: user ? user.id : IsNull(), + }); + + if (driveFolder == null) throw new Error('folder-not-found'); + + return driveFolder; + }; + + const properties: { + width?: number; + height?: number; + orientation?: number; + } = {}; + + if (info.width) { + properties['width'] = info.width; + properties['height'] = info.height; + } + if (info.orientation != null) { + properties['orientation'] = info.orientation; + } + + const profile = user ? await this.userProfilesRepository.findOneBy({ userId: user.id }) : null; + + const folder = await fetchFolder(); + + let file = new DriveFile(); + file.id = this.idService.genId(); + file.createdAt = new Date(); + file.userId = user ? user.id : null; + file.userHost = user ? user.host : null; + file.folderId = folder !== null ? folder.id : null; + file.comment = comment; + file.properties = properties; + file.blurhash = info.blurhash ?? null; + file.isLink = isLink; + file.requestIp = requestIp; + file.requestHeaders = requestHeaders; + file.maybeSensitive = info.sensitive; + file.maybePorn = info.porn; + file.isSensitive = user + ? this.userEntityService.isLocalUser(user) && profile!.alwaysMarkNsfw ? true : + (sensitive !== null && sensitive !== undefined) + ? sensitive + : false + : false; + + if (info.sensitive && profile!.autoSensitive) file.isSensitive = true; + if (info.sensitive && instance.setSensitiveFlagAutomatically) file.isSensitive = true; + + if (url !== null) { + file.src = url; + + if (isLink) { + file.url = url; + // ローカルプロキシ用 + file.accessKey = uuid(); + file.thumbnailAccessKey = 'thumbnail-' + uuid(); + file.webpublicAccessKey = 'webpublic-' + uuid(); + } + } + + if (uri !== null) { + file.uri = uri; + } + + if (isLink) { + try { + file.size = 0; + file.md5 = info.md5; + file.name = detectedName; + file.type = info.type.mime; + file.storedInternal = false; + + file = await this.driveFilesRepository.insert(file).then(x => this.driveFilesRepository.findOneByOrFail(x.identifiers[0])); + } catch (err) { + // duplicate key error (when already registered) + if (isDuplicateKeyValueError(err)) { + this.registerLogger.info(`already registered ${file.uri}`); + + file = await this.driveFilesRepository.findOneBy({ + uri: file.uri!, + userId: user ? user.id : IsNull(), + }) as DriveFile; + } else { + this.registerLogger.error(err as Error); + throw err; + } + } + } else { + file = await (this.save(file, path, detectedName, info.type.mime, info.md5, info.size)); + } + + this.registerLogger.succ(`drive file has been created ${file.id}`); + + if (user) { + this.driveFileEntityService.pack(file, { self: true }).then(packedFile => { + // Publish driveFileCreated event + this.globalEventService.publishMainStream(user.id, 'driveFileCreated', packedFile); + this.globalEventService.publishDriveStream(user.id, 'fileCreated', packedFile); + }); + } + + // 統計を更新 + this.driveChart.update(file, true); + this.perUserDriveChart.update(file, true); + if (file.userHost !== null) { + this.instanceChart.updateDrive(file, true); + } + + return file; + } + + public async deleteFile(file: DriveFile, isExpired = false) { + if (file.storedInternal) { + this.internalStorageService.del(file.accessKey!); + + if (file.thumbnailUrl) { + this.internalStorageService.del(file.thumbnailAccessKey!); + } + + if (file.webpublicUrl) { + this.internalStorageService.del(file.webpublicAccessKey!); + } + } else if (!file.isLink) { + this.queueService.createDeleteObjectStorageFileJob(file.accessKey!); + + if (file.thumbnailUrl) { + this.queueService.createDeleteObjectStorageFileJob(file.thumbnailAccessKey!); + } + + if (file.webpublicUrl) { + this.queueService.createDeleteObjectStorageFileJob(file.webpublicAccessKey!); + } + } + + this.deletePostProcess(file, isExpired); + } + + public async deleteFileSync(file: DriveFile, isExpired = false) { + if (file.storedInternal) { + this.internalStorageService.del(file.accessKey!); + + if (file.thumbnailUrl) { + this.internalStorageService.del(file.thumbnailAccessKey!); + } + + if (file.webpublicUrl) { + this.internalStorageService.del(file.webpublicAccessKey!); + } + } else if (!file.isLink) { + const promises = []; + + promises.push(this.deleteObjectStorageFile(file.accessKey!)); + + if (file.thumbnailUrl) { + promises.push(this.deleteObjectStorageFile(file.thumbnailAccessKey!)); + } + + if (file.webpublicUrl) { + promises.push(this.deleteObjectStorageFile(file.webpublicAccessKey!)); + } + + await Promise.all(promises); + } + + this.deletePostProcess(file, isExpired); + } + + private async deletePostProcess(file: DriveFile, isExpired = false) { + // リモートファイル期限切れ削除後は直リンクにする + if (isExpired && file.userHost !== null && file.uri != null) { + this.driveFilesRepository.update(file.id, { + isLink: true, + url: file.uri, + thumbnailUrl: null, + webpublicUrl: null, + storedInternal: false, + // ローカルプロキシ用 + accessKey: uuid(), + thumbnailAccessKey: 'thumbnail-' + uuid(), + webpublicAccessKey: 'webpublic-' + uuid(), + }); + } else { + this.driveFilesRepository.delete(file.id); + } + + // 統計を更新 + this.driveChart.update(file, false); + this.perUserDriveChart.update(file, false); + if (file.userHost !== null) { + this.instanceChart.updateDrive(file, false); + } + } + + public async deleteObjectStorageFile(key: string) { + const meta = await this.metaService.fetch(); + + const s3 = this.s3Service.getS3(meta); + + await s3.deleteObject({ + Bucket: meta.objectStorageBucket!, + Key: key, + }).promise(); + } + + public async uploadFromUrl({ + url, + user, + folderId = null, + uri = null, + sensitive = false, + force = false, + isLink = false, + comment = null, + requestIp = null, + requestHeaders = null, + }: UploadFromUrlArgs): Promise { + let name = new URL(url).pathname.split('/').pop() ?? null; + if (name == null || !this.driveFileEntityService.validateFileName(name)) { + name = null; + } + + // If the comment is same as the name, skip comment + // (image.name is passed in when receiving attachment) + if (comment !== null && name === comment) { + comment = null; + } + + // Create temp file + const [path, cleanup] = await createTemp(); + + try { + // write content at URL to temp file + await this.downloadService.downloadUrl(url, path); + + const driveFile = await this.addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive, requestIp, requestHeaders }); + this.downloaderLogger.succ(`Got: ${driveFile.id}`); + return driveFile!; + } catch (err) { + this.downloaderLogger.error(`Failed to create drive file: ${err}`, { + url: url, + e: err, + }); + throw err; + } finally { + cleanup(); + } + } +} diff --git a/packages/backend/src/core/EmailService.ts b/packages/backend/src/core/EmailService.ts new file mode 100644 index 000000000..521ab7fd8 --- /dev/null +++ b/packages/backend/src/core/EmailService.ts @@ -0,0 +1,177 @@ +import * as nodemailer from 'nodemailer'; +import { Inject, Injectable } from '@nestjs/common'; +import { validate as validateEmail } from 'deep-email-validator'; +import { MetaService } from '@/core/MetaService.js'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import { UserProfilesRepository } from '@/models/index.js'; +import { LoggerService } from '@/core/LoggerService.js'; + +@Injectable() +export class EmailService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + private metaService: MetaService, + private loggerService: LoggerService, + ) { + this.logger = this.loggerService.getLogger('email'); + } + + public async sendEmail(to: string, subject: string, html: string, text: string) { + const meta = await this.metaService.fetch(true); + + const iconUrl = `${this.config.url}/static-assets/mi-white.png`; + const emailSettingUrl = `${this.config.url}/settings/email`; + + const enableAuth = meta.smtpUser != null && meta.smtpUser !== ''; + + const transporter = nodemailer.createTransport({ + host: meta.smtpHost, + port: meta.smtpPort, + secure: meta.smtpSecure, + ignoreTLS: !enableAuth, + proxy: this.config.proxySmtp, + auth: enableAuth ? { + user: meta.smtpUser, + pass: meta.smtpPass, + } : undefined, + } as any); + + try { + // TODO: htmlサニタイズ + const info = await transporter.sendMail({ + from: meta.email!, + to: to, + subject: subject, + text: text, + html: ` + + + + ${ subject } + + + +
+
+ +
+
+

${ subject }

+
${ html }
+
+ +
+ + +`, + }); + + this.logger.info(`Message sent: ${info.messageId}`); + } catch (err) { + this.logger.error(err as Error); + throw err; + } + } + + public async validateEmailForAccount(emailAddress: string): Promise<{ + available: boolean; + reason: null | 'used' | 'format' | 'disposable' | 'mx' | 'smtp'; + }> { + const meta = await this.metaService.fetch(); + + const exist = await this.userProfilesRepository.countBy({ + emailVerified: true, + email: emailAddress, + }); + + const validated = meta.enableActiveEmailValidation ? await validateEmail({ + email: emailAddress, + validateRegex: true, + validateMx: true, + validateTypo: false, // TLDを見ているみたいだけどclubとか弾かれるので + validateDisposable: true, // 捨てアドかどうかチェック + validateSMTP: false, // 日本だと25ポートが殆どのプロバイダーで塞がれていてタイムアウトになるので + }) : { valid: true }; + + const available = exist === 0 && validated.valid; + + return { + available, + reason: available ? null : + exist !== 0 ? 'used' : + validated.reason === 'regex' ? 'format' : + validated.reason === 'disposable' ? 'disposable' : + validated.reason === 'mx' ? 'mx' : + validated.reason === 'smtp' ? 'smtp' : + null, + }; + } +} diff --git a/packages/backend/src/core/FederatedInstanceService.ts b/packages/backend/src/core/FederatedInstanceService.ts new file mode 100644 index 000000000..a4894a437 --- /dev/null +++ b/packages/backend/src/core/FederatedInstanceService.ts @@ -0,0 +1,46 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { InstancesRepository } from '@/models/index.js'; +import type { Instance } from '@/models/entities/Instance.js'; +import { Cache } from '@/misc/cache.js'; +import { IdService } from '@/core/IdService.js'; +import { DI } from '@/di-symbols.js'; +import { UtilityService } from './UtilityService.js'; + +@Injectable() +export class FederatedInstanceService { + private cache: Cache; + + constructor( + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + private utilityService: UtilityService, + private idService: IdService, + ) { + this.cache = new Cache(1000 * 60 * 60); + } + + public async registerOrFetchInstanceDoc(host: string): Promise { + host = this.utilityService.toPuny(host); + + const cached = this.cache.get(host); + if (cached) return cached; + + const index = await this.instancesRepository.findOneBy({ host }); + + if (index == null) { + const i = await this.instancesRepository.insert({ + id: this.idService.genId(), + host, + caughtAt: new Date(), + lastCommunicatedAt: new Date(), + }).then(x => this.instancesRepository.findOneByOrFail(x.identifiers[0])); + + this.cache.set(host, i); + return i; + } else { + this.cache.set(host, index); + return index; + } + } +} diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts new file mode 100644 index 000000000..376617914 --- /dev/null +++ b/packages/backend/src/core/FetchInstanceMetadataService.ts @@ -0,0 +1,286 @@ +import { URL } from 'node:url'; +import { Inject, Injectable } from '@nestjs/common'; +import { JSDOM } from 'jsdom'; +import fetch from 'node-fetch'; +import tinycolor from 'tinycolor2'; +import type { Instance } from '@/models/entities/Instance.js'; +import { InstancesRepository } from '@/models/index.js'; +import { AppLockService } from '@/core/AppLockService.js'; +import type Logger from '@/logger.js'; +import { DI } from '@/di-symbols.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import { HttpRequestService } from './HttpRequestService.js'; +import type { DOMWindow } from 'jsdom'; + +type NodeInfo = { + openRegistrations?: any; + software?: { + name?: any; + version?: any; + }; + metadata?: { + name?: any; + nodeName?: any; + nodeDescription?: any; + description?: any; + maintainer?: { + name?: any; + email?: any; + }; + }; +}; + +@Injectable() +export class FetchInstanceMetadataService { + private logger: Logger; + + constructor( + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + private appLockService: AppLockService, + private httpRequestService: HttpRequestService, + private loggerService: LoggerService, + ) { + this.logger = this.loggerService.getLogger('metadata', 'cyan'); + } + + public async fetchInstanceMetadata(instance: Instance, force = false): Promise { + const unlock = await this.appLockService.getFetchInstanceMetadataLock(instance.host); + + if (!force) { + const _instance = await this.instancesRepository.findOneBy({ host: instance.host }); + const now = Date.now(); + if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) { + unlock(); + return; + } + } + + this.logger.info(`Fetching metadata of ${instance.host} ...`); + + try { + const [info, dom, manifest] = await Promise.all([ + this.fetchNodeinfo(instance).catch(() => null), + this.fetchDom(instance).catch(() => null), + this.fetchManifest(instance).catch(() => null), + ]); + + const [favicon, icon, themeColor, name, description] = await Promise.all([ + this.fetchFaviconUrl(instance, dom).catch(() => null), + this.fetchIconUrl(instance, dom, manifest).catch(() => null), + this.getThemeColor(info, dom, manifest).catch(() => null), + this.getSiteName(info, dom, manifest).catch(() => null), + this.getDescription(info, dom, manifest).catch(() => null), + ]); + + this.logger.succ(`Successfuly fetched metadata of ${instance.host}`); + + const updates = { + infoUpdatedAt: new Date(), + } as Record; + + if (info) { + updates.softwareName = info.software?.name.toLowerCase(); + updates.softwareVersion = info.software?.version; + updates.openRegistrations = info.openRegistrations; + updates.maintainerName = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.name ?? null) : null : null; + updates.maintainerEmail = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.email ?? null) : null : null; + } + + if (name) updates.name = name; + if (description) updates.description = description; + if (icon || favicon) updates.iconUrl = icon ?? favicon; + if (favicon) updates.faviconUrl = favicon; + if (themeColor) updates.themeColor = themeColor; + + await this.instancesRepository.update(instance.id, updates); + + this.logger.succ(`Successfuly updated metadata of ${instance.host}`); + } catch (e) { + this.logger.error(`Failed to update metadata of ${instance.host}: ${e}`); + } finally { + unlock(); + } + } + + private async fetchNodeinfo(instance: Instance): Promise { + this.logger.info(`Fetching nodeinfo of ${instance.host} ...`); + + try { + const wellknown = await this.httpRequestService.getJson('https://' + instance.host + '/.well-known/nodeinfo') + .catch(err => { + if (err.statusCode === 404) { + throw 'No nodeinfo provided'; + } else { + throw err.statusCode ?? err.message; + } + }) as Record; + + if (wellknown.links == null || !Array.isArray(wellknown.links)) { + throw 'No wellknown links'; + } + + const links = wellknown.links as any[]; + + const lnik1_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/1.0'); + const lnik2_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.0'); + const lnik2_1 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.1'); + const link = lnik2_1 ?? lnik2_0 ?? lnik1_0; + + if (link == null) { + throw 'No nodeinfo link provided'; + } + + const info = await this.httpRequestService.getJson(link.href) + .catch(err => { + throw err.statusCode ?? err.message; + }); + + this.logger.succ(`Successfuly fetched nodeinfo of ${instance.host}`); + + return info as NodeInfo; + } catch (err) { + this.logger.error(`Failed to fetch nodeinfo of ${instance.host}: ${err}`); + + throw err; + } + } + + private async fetchDom(instance: Instance): Promise { + this.logger.info(`Fetching HTML of ${instance.host} ...`); + + const url = 'https://' + instance.host; + + const html = await this.httpRequestService.getHtml(url); + + const { window } = new JSDOM(html); + const doc = window.document; + + return doc; + } + + private async fetchManifest(instance: Instance): Promise | null> { + const url = 'https://' + instance.host; + + const manifestUrl = url + '/manifest.json'; + + const manifest = await this.httpRequestService.getJson(manifestUrl) as Record; + + return manifest; + } + + private async fetchFaviconUrl(instance: Instance, doc: DOMWindow['document'] | null): Promise { + const url = 'https://' + instance.host; + + if (doc) { + // https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043 + const href = Array.from(doc.getElementsByTagName('link')).reverse().find(link => link.relList.contains('icon'))?.href; + + if (href) { + return (new URL(href, url)).href; + } + } + + const faviconUrl = url + '/favicon.ico'; + + const favicon = await fetch(faviconUrl, { + // TODO + //timeout: 10000, + agent: url => this.httpRequestService.getAgentByUrl(url), + }); + + if (favicon.ok) { + return faviconUrl; + } + + return null; + } + + private async fetchIconUrl(instance: Instance, doc: DOMWindow['document'] | null, manifest: Record | null): Promise { + if (manifest && manifest.icons && manifest.icons.length > 0 && manifest.icons[0].src) { + const url = 'https://' + instance.host; + return (new URL(manifest.icons[0].src, url)).href; + } + + if (doc) { + const url = 'https://' + instance.host; + + // https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043 + const links = Array.from(doc.getElementsByTagName('link')).reverse(); + // https://github.com/misskey-dev/misskey/pull/8220/files/0ec4eba22a914e31b86874f12448f88b3e58dd5a#r796487559 + const href = + [ + links.find(link => link.relList.contains('apple-touch-icon-precomposed'))?.href, + links.find(link => link.relList.contains('apple-touch-icon'))?.href, + links.find(link => link.relList.contains('icon'))?.href, + ] + .find(href => href); + + if (href) { + return (new URL(href, url)).href; + } + } + + return null; + } + + private async getThemeColor(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record | null): Promise { + const themeColor = info?.metadata?.themeColor ?? doc?.querySelector('meta[name="theme-color"]')?.getAttribute('content') ?? manifest?.theme_color; + + if (themeColor) { + const color = new tinycolor(themeColor); + if (color.isValid()) return color.toHexString(); + } + + return null; + } + + private async getSiteName(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record | null): Promise { + if (info && info.metadata) { + if (info.metadata.nodeName || info.metadata.name) { + return info.metadata.nodeName ?? info.metadata.name; + } + } + + if (doc) { + const og = doc.querySelector('meta[property="og:title"]')?.getAttribute('content'); + + if (og) { + return og; + } + } + + if (manifest) { + return manifest.name ?? manifest.short_name; + } + + return null; + } + + private async getDescription(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record | null): Promise { + if (info && info.metadata) { + if (info.metadata.nodeDescription || info.metadata.description) { + return info.metadata.nodeDescription ?? info.metadata.description; + } + } + + if (doc) { + const meta = doc.querySelector('meta[name="description"]')?.getAttribute('content'); + if (meta) { + return meta; + } + + const og = doc.querySelector('meta[property="og:description"]')?.getAttribute('content'); + if (og) { + return og; + } + } + + if (manifest) { + return manifest.name ?? manifest.short_name; + } + + return null; + } +} diff --git a/packages/backend/src/core/FileInfoService.ts b/packages/backend/src/core/FileInfoService.ts new file mode 100644 index 000000000..fd8a4fdd3 --- /dev/null +++ b/packages/backend/src/core/FileInfoService.ts @@ -0,0 +1,382 @@ +import * as fs from 'node:fs'; +import * as crypto from 'node:crypto'; +import { join } from 'node:path'; +import * as stream from 'node:stream'; +import * as util from 'node:util'; +import { Inject, Injectable } from '@nestjs/common'; +import { FSWatcher } from 'chokidar'; +import { fileTypeFromFile } from 'file-type'; +import FFmpeg from 'fluent-ffmpeg'; +import isSvg from 'is-svg'; +import probeImageSize from 'probe-image-size'; +import { type predictionType } from 'nsfwjs'; +import sharp from 'sharp'; +import { encode } from 'blurhash'; +import { createTempDir } from '@/misc/create-temp.js'; +import { AiService } from '@/core/AiService.js'; + +const pipeline = util.promisify(stream.pipeline); + +export type FileInfo = { + size: number; + md5: string; + type: { + mime: string; + ext: string | null; + }; + width?: number; + height?: number; + orientation?: number; + blurhash?: string; + sensitive: boolean; + porn: boolean; + warnings: string[]; +}; + +const TYPE_OCTET_STREAM = { + mime: 'application/octet-stream', + ext: null, +}; + +const TYPE_SVG = { + mime: 'image/svg+xml', + ext: 'svg', +}; +@Injectable() +export class FileInfoService { + constructor( + private aiService: AiService, + ) { + } + + /** + * Get file information + */ + public async getFileInfo(path: string, opts: { + skipSensitiveDetection: boolean; + sensitiveThreshold?: number; + sensitiveThresholdForPorn?: number; + enableSensitiveMediaDetectionForVideos?: boolean; + }): Promise { + const warnings = [] as string[]; + + const size = await this.getFileSize(path); + const md5 = await this.calcHash(path); + + let type = await this.detectType(path); + + // image dimensions + let width: number | undefined; + let height: number | undefined; + let orientation: number | undefined; + + if (['image/jpeg', 'image/gif', 'image/png', 'image/apng', 'image/webp', 'image/bmp', 'image/tiff', 'image/svg+xml', 'image/vnd.adobe.photoshop'].includes(type.mime)) { + const imageSize = await this.detectImageSize(path).catch(e => { + warnings.push(`detectImageSize failed: ${e}`); + return undefined; + }); + + // うまく判定できない画像は octet-stream にする + if (!imageSize) { + warnings.push('cannot detect image dimensions'); + type = TYPE_OCTET_STREAM; + } else if (imageSize.wUnits === 'px') { + width = imageSize.width; + height = imageSize.height; + orientation = imageSize.orientation; + + // 制限を超えている画像は octet-stream にする + if (imageSize.width > 16383 || imageSize.height > 16383) { + warnings.push('image dimensions exceeds limits'); + type = TYPE_OCTET_STREAM; + } + } else { + warnings.push(`unsupported unit type: ${imageSize.wUnits}`); + } + } + + let blurhash: string | undefined; + + if (['image/jpeg', 'image/gif', 'image/png', 'image/apng', 'image/webp', 'image/svg+xml'].includes(type.mime)) { + blurhash = await this.getBlurhash(path).catch(e => { + warnings.push(`getBlurhash failed: ${e}`); + return undefined; + }); + } + + let sensitive = false; + let porn = false; + + if (!opts.skipSensitiveDetection) { + await this.detectSensitivity( + path, + type.mime, + opts.sensitiveThreshold ?? 0.5, + opts.sensitiveThresholdForPorn ?? 0.75, + opts.enableSensitiveMediaDetectionForVideos ?? false, + ).then(value => { + [sensitive, porn] = value; + }, error => { + warnings.push(`detectSensitivity failed: ${error}`); + }); + } + + return { + size, + md5, + type, + width, + height, + orientation, + blurhash, + sensitive, + porn, + warnings, + }; + } + + private async detectSensitivity(source: string, mime: string, sensitiveThreshold: number, sensitiveThresholdForPorn: number, analyzeVideo: boolean): Promise<[sensitive: boolean, porn: boolean]> { + let sensitive = false; + let porn = false; + + function judgePrediction(result: readonly predictionType[]): [sensitive: boolean, porn: boolean] { + let sensitive = false; + let porn = false; + + if ((result.find(x => x.className === 'Sexy')?.probability ?? 0) > sensitiveThreshold) sensitive = true; + if ((result.find(x => x.className === 'Hentai')?.probability ?? 0) > sensitiveThreshold) sensitive = true; + if ((result.find(x => x.className === 'Porn')?.probability ?? 0) > sensitiveThreshold) sensitive = true; + + if ((result.find(x => x.className === 'Porn')?.probability ?? 0) > sensitiveThresholdForPorn) porn = true; + + return [sensitive, porn]; + } + + if (['image/jpeg', 'image/png', 'image/webp'].includes(mime)) { + const result = await this.aiService.detectSensitive(source); + if (result) { + [sensitive, porn] = judgePrediction(result); + } + } else if (analyzeVideo && (mime === 'image/apng' || mime.startsWith('video/'))) { + const [outDir, disposeOutDir] = await createTempDir(); + try { + const command = FFmpeg() + .input(source) + .inputOptions([ + '-skip_frame', 'nokey', // 可能ならキーフレームのみを取得してほしいとする(そうなるとは限らない) + '-lowres', '3', // 元の画質でデコードする必要はないので 1/8 画質でデコードしてもよいとする(そうなるとは限らない) + ]) + .noAudio() + .videoFilters([ + { + filter: 'select', // フレームのフィルタリング + options: { + e: 'eq(pict_type,PICT_TYPE_I)', // I-Frame のみをフィルタする(VP9 とかはデコードしてみないとわからないっぽい) + }, + }, + { + filter: 'blackframe', // 暗いフレームの検出 + options: { + amount: '0', // 暗さに関わらず全てのフレームで測定値を取る + }, + }, + { + filter: 'metadata', + options: { + mode: 'select', // フレーム選択モード + key: 'lavfi.blackframe.pblack', // フレームにおける暗部の百分率(前のフィルタからのメタデータを参照する) + value: '50', + function: 'less', // 50% 未満のフレームを選択する(50% 以上暗部があるフレームだと誤検知を招くかもしれないので) + }, + }, + { + filter: 'scale', + options: { + w: 299, + h: 299, + }, + }, + ]) + .format('image2') + .output(join(outDir, '%d.png')) + .outputOptions(['-vsync', '0']); // 可変フレームレートにすることで穴埋めをさせない + const results: ReturnType[] = []; + let frameIndex = 0; + let targetIndex = 0; + let nextIndex = 1; + for await (const path of this.asyncIterateFrames(outDir, command)) { + try { + const index = frameIndex++; + if (index !== targetIndex) { + continue; + } + targetIndex = nextIndex; + nextIndex += index; // fibonacci sequence によってフレーム数制限を掛ける + const result = await this.aiService.detectSensitive(path); + if (result) { + results.push(judgePrediction(result)); + } + } finally { + fs.promises.unlink(path); + } + } + sensitive = results.filter(x => x[0]).length >= Math.ceil(results.length * sensitiveThreshold); + porn = results.filter(x => x[1]).length >= Math.ceil(results.length * sensitiveThresholdForPorn); + } finally { + disposeOutDir(); + } + } + + return [sensitive, porn]; + } + + private async *asyncIterateFrames(cwd: string, command: FFmpeg.FfmpegCommand): AsyncGenerator { + const watcher = new FSWatcher({ + cwd, + disableGlobbing: true, + }); + let finished = false; + command.once('end', () => { + finished = true; + watcher.close(); + }); + command.run(); + for (let i = 1; true; i++) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition + const current = `${i}.png`; + const next = `${i + 1}.png`; + const framePath = join(cwd, current); + if (await this.exists(join(cwd, next))) { + yield framePath; + } else if (!finished) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition + watcher.add(next); + await new Promise((resolve, reject) => { + watcher.on('add', function onAdd(path) { + if (path === next) { // 次フレームの書き出しが始まっているなら、現在フレームの書き出しは終わっている + watcher.unwatch(current); + watcher.off('add', onAdd); + resolve(); + } + }); + command.once('end', resolve); // 全てのフレームを処理し終わったなら、最終フレームである現在フレームの書き出しは終わっている + command.once('error', reject); + }); + yield framePath; + } else if (await this.exists(framePath)) { + yield framePath; + } else { + return; + } + } + } + + private exists(path: string): Promise { + return fs.promises.access(path).then(() => true, () => false); + } + + /** + * Detect MIME Type and extension + */ + public async detectType(path: string): Promise<{ + mime: string; + ext: string | null; +}> { + // Check 0 byte + const fileSize = await this.getFileSize(path); + if (fileSize === 0) { + return TYPE_OCTET_STREAM; + } + + const type = await fileTypeFromFile(path); + + if (type) { + // XMLはSVGかもしれない + if (type.mime === 'application/xml' && await this.checkSvg(path)) { + return TYPE_SVG; + } + + return { + mime: type.mime, + ext: type.ext, + }; + } + + // 種類が不明でもSVGかもしれない + if (await this.checkSvg(path)) { + return TYPE_SVG; + } + + // それでも種類が不明なら application/octet-stream にする + return TYPE_OCTET_STREAM; + } + + /** + * Check the file is SVG or not + */ + public async checkSvg(path: string) { + try { + const size = await this.getFileSize(path); + if (size > 1 * 1024 * 1024) return false; + return isSvg(fs.readFileSync(path)); + } catch { + return false; + } + } + + /** + * Get file size + */ + public async getFileSize(path: string): Promise { + const getStat = util.promisify(fs.stat); + return (await getStat(path)).size; + } + + /** + * Calculate MD5 hash + */ + private async calcHash(path: string): Promise { + const hash = crypto.createHash('md5').setEncoding('hex'); + await pipeline(fs.createReadStream(path), hash); + return hash.read(); + } + + /** + * Detect dimensions of image + */ + private async detectImageSize(path: string): Promise<{ + width: number; + height: number; + wUnits: string; + hUnits: string; + orientation?: number; +}> { + const readable = fs.createReadStream(path); + const imageSize = await probeImageSize(readable); + readable.destroy(); + return imageSize; + } + + /** + * Calculate average color of image + */ + private getBlurhash(path: string): Promise { + return new Promise((resolve, reject) => { + sharp(path) + .raw() + .ensureAlpha() + .resize(64, 64, { fit: 'inside' }) + .toBuffer((err, buffer, { width, height }) => { + if (err) return reject(err); + + let hash; + + try { + hash = encode(new Uint8ClampedArray(buffer), width, height, 7, 7); + } catch (e) { + return reject(e); + } + + resolve(hash); + }); + }); + } +} diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts new file mode 100644 index 000000000..c36de63fd --- /dev/null +++ b/packages/backend/src/core/GlobalEventService.ts @@ -0,0 +1,109 @@ +import { Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import type { User } from '@/models/entities/User.js'; +import type { Note } from '@/models/entities/Note.js'; +import type { UserList } from '@/models/entities/UserList.js'; +import type { UserGroup } from '@/models/entities/UserGroup.js'; +import type { Antenna } from '@/models/entities/Antenna.js'; +import type { Channel } from '@/models/entities/Channel.js'; +import type { + StreamChannels, + AdminStreamTypes, + AntennaStreamTypes, + BroadcastTypes, + ChannelStreamTypes, + DriveStreamTypes, + GroupMessagingStreamTypes, + InternalStreamTypes, + MainStreamTypes, + MessagingIndexStreamTypes, + MessagingStreamTypes, + NoteStreamTypes, + UserListStreamTypes, + UserStreamTypes, +} from '@/server/api/stream/types.js'; +import type { Packed } from '@/misc/schema.js'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; + +@Injectable() +export class GlobalEventService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.redis) + private redisClient: Redis.Redis, + ) { + } + + private publish(channel: StreamChannels, type: string | null, value?: any): void { + const message = type == null ? value : value == null ? + { type: type, body: null } : + { type: type, body: value }; + + this.redisClient.publish(this.config.host, JSON.stringify({ + channel: channel, + message: message, + })); + } + + public publishInternalEvent(type: K, value?: InternalStreamTypes[K]): void { + this.publish('internal', type, typeof value === 'undefined' ? null : value); + } + + public publishUserEvent(userId: User['id'], type: K, value?: UserStreamTypes[K]): void { + this.publish(`user:${userId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishBroadcastStream(type: K, value?: BroadcastTypes[K]): void { + this.publish('broadcast', type, typeof value === 'undefined' ? null : value); + } + + public publishMainStream(userId: User['id'], type: K, value?: MainStreamTypes[K]): void { + this.publish(`mainStream:${userId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishDriveStream(userId: User['id'], type: K, value?: DriveStreamTypes[K]): void { + this.publish(`driveStream:${userId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishNoteStream(noteId: Note['id'], type: K, value?: NoteStreamTypes[K]): void { + this.publish(`noteStream:${noteId}`, type, { + id: noteId, + body: value, + }); + } + + public publishChannelStream(channelId: Channel['id'], type: K, value?: ChannelStreamTypes[K]): void { + this.publish(`channelStream:${channelId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishUserListStream(listId: UserList['id'], type: K, value?: UserListStreamTypes[K]): void { + this.publish(`userListStream:${listId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishAntennaStream(antennaId: Antenna['id'], type: K, value?: AntennaStreamTypes[K]): void { + this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishMessagingStream(userId: User['id'], otherpartyId: User['id'], type: K, value?: MessagingStreamTypes[K]): void { + this.publish(`messagingStream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishGroupMessagingStream(groupId: UserGroup['id'], type: K, value?: GroupMessagingStreamTypes[K]): void { + this.publish(`messagingStream:${groupId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishMessagingIndexStream(userId: User['id'], type: K, value?: MessagingIndexStreamTypes[K]): void { + this.publish(`messagingIndexStream:${userId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishNotesStream(note: Packed<'Note'>): void { + this.publish('notesStream', null, note); + } + + public publishAdminStream(userId: User['id'], type: K, value?: AdminStreamTypes[K]): void { + this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value); + } +} diff --git a/packages/backend/src/core/HashtagService.ts b/packages/backend/src/core/HashtagService.ts new file mode 100644 index 000000000..f6c06d48f --- /dev/null +++ b/packages/backend/src/core/HashtagService.ts @@ -0,0 +1,147 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { User } from '@/models/entities/User.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; +import { IdService } from '@/core/IdService.js'; +import type { Hashtag } from '@/models/entities/Hashtag.js'; +import HashtagChart from '@/core/chart/charts/hashtag.js'; +import { HashtagsRepository, UsersRepository } from '@/models/index.js'; +import { UserEntityService } from './entities/UserEntityService.js'; + +@Injectable() +export class HashtagService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.hashtagsRepository) + private hashtagsRepository: HashtagsRepository, + + private userEntityService: UserEntityService, + private idService: IdService, + private hashtagChart: HashtagChart, + ) { + } + + public async updateHashtags(user: { id: User['id']; host: User['host']; }, tags: string[]) { + for (const tag of tags) { + await this.updateHashtag(user, tag); + } + } + + public async updateUsertags(user: User, tags: string[]) { + for (const tag of tags) { + await this.updateHashtag(user, tag, true, true); + } + + for (const tag of (user.tags ?? []).filter(x => !tags.includes(x))) { + await this.updateHashtag(user, tag, true, false); + } + } + + public async updateHashtag(user: { id: User['id']; host: User['host']; }, tag: string, isUserAttached = false, inc = true) { + tag = normalizeForSearch(tag); + + const index = await this.hashtagsRepository.findOneBy({ name: tag }); + + if (index == null && !inc) return; + + if (index != null) { + const q = this.hashtagsRepository.createQueryBuilder('tag').update() + .where('name = :name', { name: tag }); + + const set = {} as any; + + if (isUserAttached) { + if (inc) { + // 自分が初めてこのタグを使ったなら + if (!index.attachedUserIds.some(id => id === user.id)) { + set.attachedUserIds = () => `array_append("attachedUserIds", '${user.id}')`; + set.attachedUsersCount = () => '"attachedUsersCount" + 1'; + } + // 自分が(ローカル内で)初めてこのタグを使ったなら + if (this.userEntityService.isLocalUser(user) && !index.attachedLocalUserIds.some(id => id === user.id)) { + set.attachedLocalUserIds = () => `array_append("attachedLocalUserIds", '${user.id}')`; + set.attachedLocalUsersCount = () => '"attachedLocalUsersCount" + 1'; + } + // 自分が(リモートで)初めてこのタグを使ったなら + if (this.userEntityService.isRemoteUser(user) && !index.attachedRemoteUserIds.some(id => id === user.id)) { + set.attachedRemoteUserIds = () => `array_append("attachedRemoteUserIds", '${user.id}')`; + set.attachedRemoteUsersCount = () => '"attachedRemoteUsersCount" + 1'; + } + } else { + set.attachedUserIds = () => `array_remove("attachedUserIds", '${user.id}')`; + set.attachedUsersCount = () => '"attachedUsersCount" - 1'; + if (this.userEntityService.isLocalUser(user)) { + set.attachedLocalUserIds = () => `array_remove("attachedLocalUserIds", '${user.id}')`; + set.attachedLocalUsersCount = () => '"attachedLocalUsersCount" - 1'; + } else { + set.attachedRemoteUserIds = () => `array_remove("attachedRemoteUserIds", '${user.id}')`; + set.attachedRemoteUsersCount = () => '"attachedRemoteUsersCount" - 1'; + } + } + } else { + // 自分が初めてこのタグを使ったなら + if (!index.mentionedUserIds.some(id => id === user.id)) { + set.mentionedUserIds = () => `array_append("mentionedUserIds", '${user.id}')`; + set.mentionedUsersCount = () => '"mentionedUsersCount" + 1'; + } + // 自分が(ローカル内で)初めてこのタグを使ったなら + if (this.userEntityService.isLocalUser(user) && !index.mentionedLocalUserIds.some(id => id === user.id)) { + set.mentionedLocalUserIds = () => `array_append("mentionedLocalUserIds", '${user.id}')`; + set.mentionedLocalUsersCount = () => '"mentionedLocalUsersCount" + 1'; + } + // 自分が(リモートで)初めてこのタグを使ったなら + if (this.userEntityService.isRemoteUser(user) && !index.mentionedRemoteUserIds.some(id => id === user.id)) { + set.mentionedRemoteUserIds = () => `array_append("mentionedRemoteUserIds", '${user.id}')`; + set.mentionedRemoteUsersCount = () => '"mentionedRemoteUsersCount" + 1'; + } + } + + if (Object.keys(set).length > 0) { + q.set(set); + q.execute(); + } + } else { + if (isUserAttached) { + this.hashtagsRepository.insert({ + id: this.idService.genId(), + name: tag, + mentionedUserIds: [], + mentionedUsersCount: 0, + mentionedLocalUserIds: [], + mentionedLocalUsersCount: 0, + mentionedRemoteUserIds: [], + mentionedRemoteUsersCount: 0, + attachedUserIds: [user.id], + attachedUsersCount: 1, + attachedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [], + attachedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0, + attachedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [], + attachedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0, + } as Hashtag); + } else { + this.hashtagsRepository.insert({ + id: this.idService.genId(), + name: tag, + mentionedUserIds: [user.id], + mentionedUsersCount: 1, + mentionedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [], + mentionedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0, + mentionedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [], + mentionedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0, + attachedUserIds: [], + attachedUsersCount: 0, + attachedLocalUserIds: [], + attachedLocalUsersCount: 0, + attachedRemoteUserIds: [], + attachedRemoteUsersCount: 0, + } as Hashtag); + } + } + + if (!isUserAttached) { + this.hashtagChart.update(tag, user); + } + } +} diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts new file mode 100644 index 000000000..f4c00cd25 --- /dev/null +++ b/packages/backend/src/core/HttpRequestService.ts @@ -0,0 +1,154 @@ +import * as http from 'node:http'; +import * as https from 'node:https'; +import CacheableLookup from 'cacheable-lookup'; +import fetch from 'node-fetch'; +import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent'; +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import { StatusError } from '@/misc/status-error.js'; +import type { Response } from 'node-fetch'; +import type { URL } from 'node:url'; + +@Injectable() +export class HttpRequestService { + /** + * Get http non-proxy agent + */ + private http: http.Agent; + + /** + * Get https non-proxy agent + */ + private https: https.Agent; + + /** + * Get http proxy or non-proxy agent + */ + public httpAgent: http.Agent; + + /** + * Get https proxy or non-proxy agent + */ + public httpsAgent: https.Agent; + + constructor( + @Inject(DI.config) + private config: Config, + ) { + const cache = new CacheableLookup({ + maxTtl: 3600, // 1hours + errorTtl: 30, // 30secs + lookup: false, // nativeのdns.lookupにfallbackしない + }); + + this.http = new http.Agent({ + keepAlive: true, + keepAliveMsecs: 30 * 1000, + lookup: cache.lookup, + } as http.AgentOptions); + + this.https = new https.Agent({ + keepAlive: true, + keepAliveMsecs: 30 * 1000, + lookup: cache.lookup, + } as https.AgentOptions); + + const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128); + + this.httpAgent = config.proxy + ? new HttpProxyAgent({ + keepAlive: true, + keepAliveMsecs: 30 * 1000, + maxSockets, + maxFreeSockets: 256, + scheduling: 'lifo', + proxy: config.proxy, + }) + : this.http; + + this.httpsAgent = config.proxy + ? new HttpsProxyAgent({ + keepAlive: true, + keepAliveMsecs: 30 * 1000, + maxSockets, + maxFreeSockets: 256, + scheduling: 'lifo', + proxy: config.proxy, + }) + : this.https; + } + + /** + * Get agent by URL + * @param url URL + * @param bypassProxy Allways bypass proxy + */ + public getAgentByUrl(url: URL, bypassProxy = false): http.Agent | https.Agent { + if (bypassProxy || (this.config.proxyBypassHosts || []).includes(url.hostname)) { + return url.protocol === 'http:' ? this.http : this.https; + } else { + return url.protocol === 'http:' ? this.httpAgent : this.httpsAgent; + } + } + + public async getJson(url: string, accept = 'application/json, */*', timeout = 10000, headers?: Record): Promise { + const res = await this.getResponse({ + url, + method: 'GET', + headers: Object.assign({ + 'User-Agent': this.config.userAgent, + Accept: accept, + }, headers ?? {}), + timeout, + }); + + return await res.json(); + } + + public async getHtml(url: string, accept = 'text/html, */*', timeout = 10000, headers?: Record): Promise { + const res = await this.getResponse({ + url, + method: 'GET', + headers: Object.assign({ + 'User-Agent': this.config.userAgent, + Accept: accept, + }, headers ?? {}), + timeout, + }); + + return await res.text(); + } + + public async getResponse(args: { + url: string, + method: string, + body?: string, + headers: Record, + timeout?: number, + size?: number, + }): Promise { + const timeout = args.timeout ?? 10 * 1000; + + const controller = new AbortController(); + setTimeout(() => { + controller.abort(); + }, timeout * 6); + + const res = await fetch(args.url, { + method: args.method, + headers: args.headers, + body: args.body, + timeout, + size: args.size ?? 10 * 1024 * 1024, + agent: (url) => this.getAgentByUrl(url), + signal: controller.signal, + }); + + if (!res.ok) { + throw new StatusError(`${res.status} ${res.statusText}`, res.status, res.statusText); + } + + return res; + } +} diff --git a/packages/backend/src/core/IdService.ts b/packages/backend/src/core/IdService.ts new file mode 100644 index 000000000..345b72bac --- /dev/null +++ b/packages/backend/src/core/IdService.ts @@ -0,0 +1,33 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ulid } from 'ulid'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import { genAid } from '@/misc/id/aid.js'; +import { genMeid } from '@/misc/id/meid.js'; +import { genMeidg } from '@/misc/id/meidg.js'; +import { genObjectId } from '@/misc/id/object-id.js'; + +@Injectable() +export class IdService { + private metohd: string; + + constructor( + @Inject(DI.config) + private config: Config, + ) { + this.metohd = config.id.toLowerCase(); + } + + public genId(date?: Date): string { + if (!date || (date > new Date())) date = new Date(); + + switch (this.metohd) { + case 'aid': return genAid(date); + case 'meid': return genMeid(date); + case 'meidg': return genMeidg(date); + case 'ulid': return ulid(date.getTime()); + case 'objectid': return genObjectId(date); + default: throw new Error('unrecognized id generation method'); + } + } +} diff --git a/packages/backend/src/core/ImageProcessingService.ts b/packages/backend/src/core/ImageProcessingService.ts new file mode 100644 index 000000000..d215be213 --- /dev/null +++ b/packages/backend/src/core/ImageProcessingService.ts @@ -0,0 +1,99 @@ +import { Inject, Injectable } from '@nestjs/common'; +import sharp from 'sharp'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; + +export type IImage = { + data: Buffer; + ext: string | null; + type: string; +}; + +@Injectable() +export class ImageProcessingService { + constructor( + @Inject(DI.config) + private config: Config, + ) { + } + + /** + * Convert to JPEG + * with resize, remove metadata, resolve orientation, stop animation + */ + public async convertToJpeg(path: string, width: number, height: number): Promise { + return this.convertSharpToJpeg(await sharp(path), width, height); + } + + public async convertSharpToJpeg(sharp: sharp.Sharp, width: number, height: number): Promise { + const data = await sharp + .resize(width, height, { + fit: 'inside', + withoutEnlargement: true, + }) + .rotate() + .jpeg({ + quality: 85, + progressive: true, + }) + .toBuffer(); + + return { + data, + ext: 'jpg', + type: 'image/jpeg', + }; + } + + /** + * Convert to WebP + * with resize, remove metadata, resolve orientation, stop animation + */ + public async convertToWebp(path: string, width: number, height: number, quality = 85): Promise { + return this.convertSharpToWebp(await sharp(path), width, height, quality); + } + + public async convertSharpToWebp(sharp: sharp.Sharp, width: number, height: number, quality = 85): Promise { + const data = await sharp + .resize(width, height, { + fit: 'inside', + withoutEnlargement: true, + }) + .rotate() + .webp({ + quality, + }) + .toBuffer(); + + return { + data, + ext: 'webp', + type: 'image/webp', + }; + } + + /** + * Convert to PNG + * with resize, remove metadata, resolve orientation, stop animation + */ + public async convertToPng(path: string, width: number, height: number): Promise { + return this.convertSharpToPng(await sharp(path), width, height); + } + + public async convertSharpToPng(sharp: sharp.Sharp, width: number, height: number): Promise { + const data = await sharp + .resize(width, height, { + fit: 'inside', + withoutEnlargement: true, + }) + .rotate() + .png() + .toBuffer(); + + return { + data, + ext: 'png', + type: 'image/png', + }; + } +} diff --git a/packages/backend/src/core/InstanceActorService.ts b/packages/backend/src/core/InstanceActorService.ts new file mode 100644 index 000000000..57d55870b --- /dev/null +++ b/packages/backend/src/core/InstanceActorService.ts @@ -0,0 +1,42 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull } from 'typeorm'; +import type { ILocalUser } from '@/models/entities/User.js'; +import { UsersRepository } from '@/models/index.js'; +import { Cache } from '@/misc/cache.js'; +import { DI } from '@/di-symbols.js'; +import { CreateSystemUserService } from './CreateSystemUserService.js'; + +const ACTOR_USERNAME = 'instance.actor' as const; + +@Injectable() +export class InstanceActorService { + private cache: Cache; + + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private createSystemUserService: CreateSystemUserService, + ) { + this.cache = new Cache(Infinity); + } + + public async getInstanceActor(): Promise { + const cached = this.cache.get(null); + if (cached) return cached; + + const user = await this.usersRepository.findOneBy({ + host: IsNull(), + username: ACTOR_USERNAME, + }) as ILocalUser | undefined; + + if (user) { + this.cache.set(null, user); + return user; + } else { + const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME) as ILocalUser; + this.cache.set(null, created); + return created; + } + } +} diff --git a/packages/backend/src/core/InternalStorageService.ts b/packages/backend/src/core/InternalStorageService.ts new file mode 100644 index 000000000..9bc3597ba --- /dev/null +++ b/packages/backend/src/core/InternalStorageService.ts @@ -0,0 +1,45 @@ +import * as fs from 'node:fs'; +import * as Path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; + +const _filename = fileURLToPath(import.meta.url); +const _dirname = dirname(_filename); + +const path = Path.resolve(_dirname, '../../../../files'); + +@Injectable() +export class InternalStorageService { + constructor( + @Inject(DI.config) + private config: Config, + ) { + } + + public resolvePath(key: string) { + return Path.resolve(path, key); + } + + public read(key: string) { + return fs.createReadStream(this.resolvePath(key)); + } + + public saveFromPath(key: string, srcPath: string) { + fs.mkdirSync(path, { recursive: true }); + fs.copyFileSync(srcPath, this.resolvePath(key)); + return `${this.config.url}/files/${key}`; + } + + public saveFromBuffer(key: string, data: Buffer) { + fs.mkdirSync(path, { recursive: true }); + fs.writeFileSync(this.resolvePath(key), data); + return `${this.config.url}/files/${key}`; + } + + public del(key: string) { + fs.unlink(this.resolvePath(key), () => {}); + } +} diff --git a/packages/backend/src/core/LoggerService.ts b/packages/backend/src/core/LoggerService.ts new file mode 100644 index 000000000..558e3016d --- /dev/null +++ b/packages/backend/src/core/LoggerService.ts @@ -0,0 +1,33 @@ +import { Inject, Injectable } from '@nestjs/common'; +import * as SyslogPro from 'syslog-pro'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import Logger from '@/logger.js'; + +@Injectable() +export class LoggerService { + private syslogClient; + + constructor( + @Inject(DI.config) + private config: Config, + ) { + if (this.config.syslog) { + this.syslogClient = new SyslogPro.RFC5424({ + applacationName: 'Misskey', + timestamp: true, + encludeStructuredData: true, + color: true, + extendedColor: true, + server: { + target: config.syslog.host, + port: config.syslog.port, + }, + }); + } + } + + public getLogger(domain: string, color?: string | undefined, store?: boolean) { + return new Logger(domain, color, store, this.syslogClient); + } +} diff --git a/packages/backend/src/core/MessagingService.ts b/packages/backend/src/core/MessagingService.ts new file mode 100644 index 000000000..1819b32a4 --- /dev/null +++ b/packages/backend/src/core/MessagingService.ts @@ -0,0 +1,300 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { In, Not } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import type { MessagingMessage } from '@/models/entities/MessagingMessage.js'; +import type { Note } from '@/models/entities/Note.js'; +import type { User, CacheableUser, IRemoteUser } from '@/models/entities/User.js'; +import type { UserGroup } from '@/models/entities/UserGroup.js'; +import { QueueService } from '@/core/QueueService.js'; +import { toArray } from '@/misc/prelude/array.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { MessagingMessagesRepository, MutingsRepository, UserGroupJoiningsRepository, UsersRepository } from '@/models/index.js'; +import { IdService } from './IdService.js'; +import { GlobalEventService } from './GlobalEventService.js'; +import { UserEntityService } from './entities/UserEntityService.js'; +import { ApRendererService } from './remote/activitypub/ApRendererService.js'; +import { MessagingMessageEntityService } from './entities/MessagingMessageEntityService.js'; +import { PushNotificationService } from './PushNotificationService.js'; + +@Injectable() +export class MessagingService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + private userEntityService: UserEntityService, + private messagingMessageEntityService: MessagingMessageEntityService, + private idService: IdService, + private globalEventService: GlobalEventService, + private apRendererService: ApRendererService, + private queueService: QueueService, + private pushNotificationService: PushNotificationService, + ) { + } + + public async createMessage(user: { id: User['id']; host: User['host']; }, recipientUser: CacheableUser | undefined, recipientGroup: UserGroup | undefined, text: string | null | undefined, file: DriveFile | null, uri?: string) { + const message = { + id: this.idService.genId(), + createdAt: new Date(), + fileId: file ? file.id : null, + recipientId: recipientUser ? recipientUser.id : null, + groupId: recipientGroup ? recipientGroup.id : null, + text: text ? text.trim() : null, + userId: user.id, + isRead: false, + reads: [] as any[], + uri, + } as MessagingMessage; + + await this.messagingMessagesRepository.insert(message); + + const messageObj = await this.messagingMessageEntityService.pack(message); + + if (recipientUser) { + if (this.userEntityService.isLocalUser(user)) { + // 自分のストリーム + this.globalEventService.publishMessagingStream(message.userId, recipientUser.id, 'message', messageObj); + this.globalEventService.publishMessagingIndexStream(message.userId, 'message', messageObj); + this.globalEventService.publishMainStream(message.userId, 'messagingMessage', messageObj); + } + + if (this.userEntityService.isLocalUser(recipientUser)) { + // 相手のストリーム + this.globalEventService.publishMessagingStream(recipientUser.id, message.userId, 'message', messageObj); + this.globalEventService.publishMessagingIndexStream(recipientUser.id, 'message', messageObj); + this.globalEventService.publishMainStream(recipientUser.id, 'messagingMessage', messageObj); + } + } else if (recipientGroup) { + // グループのストリーム + this.globalEventService.publishGroupMessagingStream(recipientGroup.id, 'message', messageObj); + + // メンバーのストリーム + const joinings = await this.userGroupJoiningsRepository.findBy({ userGroupId: recipientGroup.id }); + for (const joining of joinings) { + this.globalEventService.publishMessagingIndexStream(joining.userId, 'message', messageObj); + this.globalEventService.publishMainStream(joining.userId, 'messagingMessage', messageObj); + } + } + + // 2秒経っても(今回作成した)メッセージが既読にならなかったら「未読のメッセージがありますよ」イベントを発行する + setTimeout(async () => { + const freshMessage = await this.messagingMessagesRepository.findOneBy({ id: message.id }); + if (freshMessage == null) return; // メッセージが削除されている場合もある + + if (recipientUser && this.userEntityService.isLocalUser(recipientUser)) { + if (freshMessage.isRead) return; // 既読 + + //#region ただしミュートされているなら発行しない + const mute = await this.mutingsRepository.findBy({ + muterId: recipientUser.id, + }); + if (mute.map(m => m.muteeId).includes(user.id)) return; + //#endregion + + this.globalEventService.publishMainStream(recipientUser.id, 'unreadMessagingMessage', messageObj); + this.pushNotificationService.pushNotification(recipientUser.id, 'unreadMessagingMessage', messageObj); + } else if (recipientGroup) { + const joinings = await this.userGroupJoiningsRepository.findBy({ userGroupId: recipientGroup.id, userId: Not(user.id) }); + for (const joining of joinings) { + if (freshMessage.reads.includes(joining.userId)) return; // 既読 + this.globalEventService.publishMainStream(joining.userId, 'unreadMessagingMessage', messageObj); + this.pushNotificationService.pushNotification(joining.userId, 'unreadMessagingMessage', messageObj); + } + } + }, 2000); + + if (recipientUser && this.userEntityService.isLocalUser(user) && this.userEntityService.isRemoteUser(recipientUser)) { + const note = { + id: message.id, + createdAt: message.createdAt, + fileIds: message.fileId ? [message.fileId] : [], + text: message.text, + userId: message.userId, + visibility: 'specified', + mentions: [recipientUser].map(u => u.id), + mentionedRemoteUsers: JSON.stringify([recipientUser].map(u => ({ + uri: u.uri, + username: u.username, + host: u.host, + }))), + } as Note; + + const activity = this.apRendererService.renderActivity(this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false, true), note)); + + this.queueService.deliver(user, activity, recipientUser.inbox); + } + return messageObj; + } + + public async deleteMessage(message: MessagingMessage) { + await this.messagingMessagesRepository.delete(message.id); + this.postDeleteMessage(message); + } + + private async postDeleteMessage(message: MessagingMessage) { + if (message.recipientId) { + const user = await this.usersRepository.findOneByOrFail({ id: message.userId }); + const recipient = await this.usersRepository.findOneByOrFail({ id: message.recipientId }); + + if (this.userEntityService.isLocalUser(user)) this.globalEventService.publishMessagingStream(message.userId, message.recipientId, 'deleted', message.id); + if (this.userEntityService.isLocalUser(recipient)) this.globalEventService.publishMessagingStream(message.recipientId, message.userId, 'deleted', message.id); + + if (this.userEntityService.isLocalUser(user) && this.userEntityService.isRemoteUser(recipient)) { + const activity = this.apRendererService.renderActivity(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${message.id}`), user)); + this.queueService.deliver(user, activity, recipient.inbox); + } + } else if (message.groupId) { + this.globalEventService.publishGroupMessagingStream(message.groupId, 'deleted', message.id); + } + } + + /** + * Mark messages as read + */ + public async readUserMessagingMessage( + userId: User['id'], + otherpartyId: User['id'], + messageIds: MessagingMessage['id'][], + ) { + if (messageIds.length === 0) return; + + const messages = await this.messagingMessagesRepository.findBy({ + id: In(messageIds), + }); + + for (const message of messages) { + if (message.recipientId !== userId) { + throw new IdentifiableError('e140a4bf-49ce-4fb6-b67c-b78dadf6b52f', 'Access denied (user).'); + } + } + + // Update documents + await this.messagingMessagesRepository.update({ + id: In(messageIds), + userId: otherpartyId, + recipientId: userId, + isRead: false, + }, { + isRead: true, + }); + + // Publish event + this.globalEventService.publishMessagingStream(otherpartyId, userId, 'read', messageIds); + this.globalEventService.publishMessagingIndexStream(userId, 'read', messageIds); + + if (!await this.userEntityService.getHasUnreadMessagingMessage(userId)) { + // 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行 + this.globalEventService.publishMainStream(userId, 'readAllMessagingMessages'); + this.pushNotificationService.pushNotification(userId, 'readAllMessagingMessages', undefined); + } else { + // そのユーザーとのメッセージで未読がなければイベント発行 + const count = await this.messagingMessagesRepository.count({ + where: { + userId: otherpartyId, + recipientId: userId, + isRead: false, + }, + take: 1, + }); + + if (!count) { + this.pushNotificationService.pushNotification(userId, 'readAllMessagingMessagesOfARoom', { userId: otherpartyId }); + } + } + } + + /** + * Mark messages as read + */ + public async readGroupMessagingMessage( + userId: User['id'], + groupId: UserGroup['id'], + messageIds: MessagingMessage['id'][], + ) { + if (messageIds.length === 0) return; + + // check joined + const joining = await this.userGroupJoiningsRepository.findOneBy({ + userId: userId, + userGroupId: groupId, + }); + + if (joining == null) { + throw new IdentifiableError('930a270c-714a-46b2-b776-ad27276dc569', 'Access denied (group).'); + } + + const messages = await this.messagingMessagesRepository.findBy({ + id: In(messageIds), + }); + + const reads: MessagingMessage['id'][] = []; + + for (const message of messages) { + if (message.userId === userId) continue; + if (message.reads.includes(userId)) continue; + + // Update document + await this.messagingMessagesRepository.createQueryBuilder().update() + .set({ + reads: (() => `array_append("reads", '${joining.userId}')`) as any, + }) + .where('id = :id', { id: message.id }) + .execute(); + + reads.push(message.id); + } + + // Publish event + this.globalEventService.publishGroupMessagingStream(groupId, 'read', { + ids: reads, + userId: userId, + }); + this.globalEventService.publishMessagingIndexStream(userId, 'read', reads); + + if (!await this.userEntityService.getHasUnreadMessagingMessage(userId)) { + // 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行 + this.globalEventService.publishMainStream(userId, 'readAllMessagingMessages'); + this.pushNotificationService.pushNotification(userId, 'readAllMessagingMessages', undefined); + } else { + // そのグループにおいて未読がなければイベント発行 + const unreadExist = await this.messagingMessagesRepository.createQueryBuilder('message') + .where('message.groupId = :groupId', { groupId: groupId }) + .andWhere('message.userId != :userId', { userId: userId }) + .andWhere('NOT (:userId = ANY(message.reads))', { userId: userId }) + .andWhere('message.createdAt > :joinedAt', { joinedAt: joining.createdAt }) // 自分が加入する前の会話については、未読扱いしない + .getOne().then(x => x != null); + + if (!unreadExist) { + this.pushNotificationService.pushNotification(userId, 'readAllMessagingMessagesOfARoom', { groupId }); + } + } + } + + public async deliverReadActivity(user: { id: User['id']; host: null; }, recipient: IRemoteUser, messages: MessagingMessage | MessagingMessage[]) { + messages = toArray(messages).filter(x => x.uri); + const contents = messages.map(x => this.apRendererService.renderRead(user, x)); + + if (contents.length > 1) { + const collection = this.apRendererService.renderOrderedCollection(null, contents.length, undefined, undefined, contents); + this.queueService.deliver(user, this.apRendererService.renderActivity(collection), recipient.inbox); + } else { + for (const content of contents) { + this.queueService.deliver(user, this.apRendererService.renderActivity(content), recipient.inbox); + } + } + } +} diff --git a/packages/backend/src/core/MetaService.ts b/packages/backend/src/core/MetaService.ts new file mode 100644 index 000000000..4099e340b --- /dev/null +++ b/packages/backend/src/core/MetaService.ts @@ -0,0 +1,63 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import type { UsersRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { Meta } from '@/models/entities/Meta.js'; +import type { OnApplicationShutdown } from '@nestjs/common'; + +@Injectable() +export class MetaService implements OnApplicationShutdown { + private cache: Meta | undefined; + private intervalId: NodeJS.Timer; + + constructor( + @Inject(DI.db) + private db: DataSource, + ) { + if (process.env.NODE_ENV !== 'test') { + this.intervalId = setInterval(() => { + this.fetch(true).then(meta => { + this.cache = meta; + }); + }, 1000 * 10); + } + } + + async fetch(noCache = false): Promise { + if (!noCache && this.cache) return this.cache; + + return await this.db.transaction(async transactionalEntityManager => { + // 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する + const metas = await transactionalEntityManager.find(Meta, { + order: { + id: 'DESC', + }, + }); + + const meta = metas[0]; + + if (meta) { + this.cache = meta; + return meta; + } else { + // metaが空のときfetchMetaが同時に呼ばれるとここが同時に呼ばれてしまうことがあるのでフェイルセーフなupsertを使う + const saved = await transactionalEntityManager + .upsert( + Meta, + { + id: 'x', + }, + ['id'], + ) + .then((x) => transactionalEntityManager.findOneByOrFail(Meta, x.identifiers[0])); + + this.cache = saved; + return saved; + } + }); + } + + public onApplicationShutdown(signal?: string | undefined) { + clearInterval(this.intervalId); + } +} diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts new file mode 100644 index 000000000..236be4bbf --- /dev/null +++ b/packages/backend/src/core/MfmService.ts @@ -0,0 +1,384 @@ +import { URL } from 'node:url'; +import { Inject, Injectable } from '@nestjs/common'; +import * as parse5 from 'parse5'; +import { JSDOM } from 'jsdom'; +import { DI } from '@/di-symbols.js'; +import type { UsersRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import { intersperse } from '@/misc/prelude/array.js'; +import type { IMentionedRemoteUsers } from '@/models/entities/Note.js'; +import * as TreeAdapter from '../../node_modules/parse5/dist/tree-adapters/default.js'; +import type * as mfm from 'mfm-js'; + +const treeAdapter = TreeAdapter.defaultTreeAdapter; + +const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/; +const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/; + +@Injectable() +export class MfmService { + constructor( + @Inject(DI.config) + private config: Config, + ) { + } + + public fromHtml(html: string, hashtagNames?: string[]): string { + // some AP servers like Pixelfed use br tags as well as newlines + html = html.replace(/\r?\n/gi, '\n'); + + const dom = parse5.parseFragment(html); + + let text = ''; + + for (const n of dom.childNodes) { + analyze(n); + } + + return text.trim(); + + function getText(node: TreeAdapter.Node): string { + if (treeAdapter.isTextNode(node)) return node.value; + if (!treeAdapter.isElementNode(node)) return ''; + if (node.nodeName === 'br') return '\n'; + + if (node.childNodes) { + return node.childNodes.map(n => getText(n)).join(''); + } + + return ''; + } + + function appendChildren(childNodes: TreeAdapter.ChildNode[]): void { + if (childNodes) { + for (const n of childNodes) { + analyze(n); + } + } + } + + function analyze(node: TreeAdapter.Node) { + if (treeAdapter.isTextNode(node)) { + text += node.value; + return; + } + + // Skip comment or document type node + if (!treeAdapter.isElementNode(node)) return; + + switch (node.nodeName) { + case 'br': { + text += '\n'; + break; + } + + case 'a': + { + const txt = getText(node); + const rel = node.attrs.find(x => x.name === 'rel'); + const href = node.attrs.find(x => x.name === 'href'); + + // ハッシュタグ + if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) { + text += txt; + // メンション + } else if (txt.startsWith('@') && !(rel && rel.value.match(/^me /))) { + const part = txt.split('@'); + + if (part.length === 2 && href) { + //#region ホスト名部分が省略されているので復元する + const acct = `${txt}@${(new URL(href.value)).hostname}`; + text += acct; + //#endregion + } else if (part.length === 3) { + text += txt; + } + // その他 + } else { + const generateLink = () => { + if (!href && !txt) { + return ''; + } + if (!href) { + return txt; + } + if (!txt || txt === href.value) { // #6383: Missing text node + if (href.value.match(urlRegexFull)) { + return href.value; + } else { + return `<${href.value}>`; + } + } + if (href.value.match(urlRegex) && !href.value.match(urlRegexFull)) { + return `[${txt}](<${href.value}>)`; // #6846 + } else { + return `[${txt}](${href.value})`; + } + }; + + text += generateLink(); + } + break; + } + + case 'h1': + { + text += '【'; + appendChildren(node.childNodes); + text += '】\n'; + break; + } + + case 'b': + case 'strong': + { + text += '**'; + appendChildren(node.childNodes); + text += '**'; + break; + } + + case 'small': + { + text += ''; + appendChildren(node.childNodes); + text += ''; + break; + } + + case 's': + case 'del': + { + text += '~~'; + appendChildren(node.childNodes); + text += '~~'; + break; + } + + case 'i': + case 'em': + { + text += ''; + appendChildren(node.childNodes); + text += ''; + break; + } + + // block code (
)
+				case 'pre': {
+					if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') {
+						text += '\n```\n';
+						text += getText(node.childNodes[0]);
+						text += '\n```\n';
+					} else {
+						appendChildren(node.childNodes);
+					}
+					break;
+				}
+	
+				// inline code ()
+				case 'code': {
+					text += '`';
+					appendChildren(node.childNodes);
+					text += '`';
+					break;
+				}
+	
+				case 'blockquote': {
+					const t = getText(node);
+					if (t) {
+						text += '\n> ';
+						text += t.split('\n').join('\n> ');
+					}
+					break;
+				}
+	
+				case 'p':
+				case 'h2':
+				case 'h3':
+				case 'h4':
+				case 'h5':
+				case 'h6':
+				{
+					text += '\n\n';
+					appendChildren(node.childNodes);
+					break;
+				}
+	
+				// other block elements
+				case 'div':
+				case 'header':
+				case 'footer':
+				case 'article':
+				case 'li':
+				case 'dt':
+				case 'dd':
+				{
+					text += '\n';
+					appendChildren(node.childNodes);
+					break;
+				}
+	
+				default:	// includes inline elements
+				{
+					appendChildren(node.childNodes);
+					break;
+				}
+			}
+		}
+	}
+
+	public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = []) {
+		if (nodes == null) {
+			return null;
+		}
+	
+		const { window } = new JSDOM('');
+	
+		const doc = window.document;
+	
+		function appendChildren(children: mfm.MfmNode[], targetElement: any): void {
+			if (children) {
+				for (const child of children.map(x => (handlers as any)[x.type](x))) targetElement.appendChild(child);
+			}
+		}
+	
+		const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType) => any } = {
+			bold: (node) => {
+				const el = doc.createElement('b');
+				appendChildren(node.children, el);
+				return el;
+			},
+	
+			small: (node) => {
+				const el = doc.createElement('small');
+				appendChildren(node.children, el);
+				return el;
+			},
+	
+			strike: (node) => {
+				const el = doc.createElement('del');
+				appendChildren(node.children, el);
+				return el;
+			},
+	
+			italic: (node) => {
+				const el = doc.createElement('i');
+				appendChildren(node.children, el);
+				return el;
+			},
+	
+			fn: (node) => {
+				const el = doc.createElement('i');
+				appendChildren(node.children, el);
+				return el;
+			},
+	
+			blockCode: (node) => {
+				const pre = doc.createElement('pre');
+				const inner = doc.createElement('code');
+				inner.textContent = node.props.code;
+				pre.appendChild(inner);
+				return pre;
+			},
+	
+			center: (node) => {
+				const el = doc.createElement('div');
+				appendChildren(node.children, el);
+				return el;
+			},
+	
+			emojiCode: (node) => {
+				return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
+			},
+	
+			unicodeEmoji: (node) => {
+				return doc.createTextNode(node.props.emoji);
+			},
+	
+			hashtag: (node) => {
+				const a = doc.createElement('a');
+				a.href = `${this.config.url}/tags/${node.props.hashtag}`;
+				a.textContent = `#${node.props.hashtag}`;
+				a.setAttribute('rel', 'tag');
+				return a;
+			},
+	
+			inlineCode: (node) => {
+				const el = doc.createElement('code');
+				el.textContent = node.props.code;
+				return el;
+			},
+	
+			mathInline: (node) => {
+				const el = doc.createElement('code');
+				el.textContent = node.props.formula;
+				return el;
+			},
+	
+			mathBlock: (node) => {
+				const el = doc.createElement('code');
+				el.textContent = node.props.formula;
+				return el;
+			},
+	
+			link: (node) => {
+				const a = doc.createElement('a');
+				a.href = node.props.url;
+				appendChildren(node.children, a);
+				return a;
+			},
+	
+			mention: (node) => {
+				const a = doc.createElement('a');
+				const { username, host, acct } = node.props;
+				const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
+				a.href = remoteUserInfo ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri) : `${this.config.url}/${acct}`;
+				a.className = 'u-url mention';
+				a.textContent = acct;
+				return a;
+			},
+	
+			quote: (node) => {
+				const el = doc.createElement('blockquote');
+				appendChildren(node.children, el);
+				return el;
+			},
+	
+			text: (node) => {
+				const el = doc.createElement('span');
+				const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => doc.createTextNode(x));
+	
+				for (const x of intersperse('br', nodes)) {
+					el.appendChild(x === 'br' ? doc.createElement('br') : x);
+				}
+	
+				return el;
+			},
+	
+			url: (node) => {
+				const a = doc.createElement('a');
+				a.href = node.props.url;
+				a.textContent = node.props.url;
+				return a;
+			},
+	
+			search: (node) => {
+				const a = doc.createElement('a');
+				a.href = `https://www.google.com/search?q=${node.props.query}`;
+				a.textContent = node.props.content;
+				return a;
+			},
+	
+			plain: (node) => {
+				const el = doc.createElement('span');
+				appendChildren(node.children, el);
+				return el;
+			},
+		};
+	
+		appendChildren(nodes, doc.body);
+	
+		return `

${doc.body.innerHTML}

`; + } +} diff --git a/packages/backend/src/core/ModerationLogService.ts b/packages/backend/src/core/ModerationLogService.ts new file mode 100644 index 000000000..191148ac2 --- /dev/null +++ b/packages/backend/src/core/ModerationLogService.ts @@ -0,0 +1,26 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { ModerationLogsRepository } from '@/models/index.js'; +import type { User } from '@/models/entities/User.js'; +import { IdService } from '@/core/IdService.js'; + +@Injectable() +export class ModerationLogService { + constructor( + @Inject(DI.moderationLogsRepository) + private moderationLogsRepository: ModerationLogsRepository, + + private idService: IdService, + ) { + } + + public async insertModerationLog(moderator: { id: User['id'] }, type: string, info?: Record) { + await this.moderationLogsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: moderator.id, + type: type, + info: info ?? {}, + }); + } +} diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts new file mode 100644 index 000000000..5acc07fba --- /dev/null +++ b/packages/backend/src/core/NoteCreateService.ts @@ -0,0 +1,742 @@ +import * as mfm from 'mfm-js'; +import { Not, In, DataSource } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; +import { extractMentions } from '@/misc/extract-mentions.js'; +import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; +import { extractHashtags } from '@/misc/extract-hashtags.js'; +import type { IMentionedRemoteUsers } from '@/models/entities/Note.js'; +import { Note } from '@/models/entities/Note.js'; +import { ChannelFollowingsRepository, ChannelsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import type { App } from '@/models/entities/App.js'; +import { concat } from '@/misc/prelude/array.js'; +import { IdService } from '@/core/IdService.js'; +import type { User, ILocalUser, IRemoteUser } from '@/models/entities/User.js'; +import type { IPoll } from '@/models/entities/Poll.js'; +import { Poll } from '@/models/entities/Poll.js'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; +import { checkWordMute } from '@/misc/check-word-mute.js'; +import type { Channel } from '@/models/entities/Channel.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; +import { Cache } from '@/misc/cache.js'; +import type { UserProfile } from '@/models/entities/UserProfile.js'; +import { RelayService } from '@/core/RelayService.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import NotesChart from '@/core/chart/charts/notes.js'; +import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js'; +import InstanceChart from '@/core/chart/charts/instance.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +import { WebhookService } from '@/core/WebhookService.js'; +import { HashtagService } from '@/core/HashtagService.js'; +import { AntennaService } from '@/core/AntennaService.js'; +import { QueueService } from '@/core/QueueService.js'; +import { NoteEntityService } from './entities/NoteEntityService.js'; +import { UserEntityService } from './entities/UserEntityService.js'; +import { NoteReadService } from './NoteReadService.js'; +import { ApRendererService } from './remote/activitypub/ApRendererService.js'; +import { ResolveUserService } from './remote/ResolveUserService.js'; +import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js'; + +const mutedWordsCache = new Cache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5); + +type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; + +class NotificationManager { + private notifier: { id: User['id']; }; + private note: Note; + private queue: { + target: ILocalUser['id']; + reason: NotificationType; + }[]; + + constructor( + private mutingsRepository: MutingsRepository, + private createNotificationService: CreateNotificationService, + notifier: { id: User['id']; }, + note: Note, + ) { + this.notifier = notifier; + this.note = note; + this.queue = []; + } + + public push(notifiee: ILocalUser['id'], reason: NotificationType) { + // 自分自身へは通知しない + if (this.notifier.id === notifiee) return; + + const exist = this.queue.find(x => x.target === notifiee); + + if (exist) { + // 「メンションされているかつ返信されている」場合は、メンションとしての通知ではなく返信としての通知にする + if (reason !== 'mention') { + exist.reason = reason; + } + } else { + this.queue.push({ + reason: reason, + target: notifiee, + }); + } + } + + public async deliver() { + for (const x of this.queue) { + // ミュート情報を取得 + const mentioneeMutes = await this.mutingsRepository.findBy({ + muterId: x.target, + }); + + const mentioneesMutedUserIds = mentioneeMutes.map(m => m.muteeId); + + // 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する + if (!mentioneesMutedUserIds.includes(this.notifier.id)) { + this.createNotificationService.createNotification(x.target, x.reason, { + notifierId: this.notifier.id, + noteId: this.note.id, + }); + } + } + } +} + +type MinimumUser = { + id: User['id']; + host: User['host']; + username: User['username']; + uri: User['uri']; +}; + +type Option = { + createdAt?: Date | null; + name?: string | null; + text?: string | null; + reply?: Note | null; + renote?: Note | null; + files?: DriveFile[] | null; + poll?: IPoll | null; + localOnly?: boolean | null; + cw?: string | null; + visibility?: string; + visibleUsers?: MinimumUser[] | null; + channel?: Channel | null; + apMentions?: MinimumUser[] | null; + apHashtags?: string[] | null; + apEmojis?: string[] | null; + uri?: string | null; + url?: string | null; + app?: App | null; +}; + +@Injectable() +export class NoteCreateService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.mutedNotesRepository) + private mutedNotesRepository: MutedNotesRepository, + + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + + @Inject(DI.noteThreadMutingsRepository) + private noteThreadMutingsRepository: NoteThreadMutingsRepository, + + private userEntityService: UserEntityService, + private noteEntityService: NoteEntityService, + private idService: IdService, + private globalEventServie: GlobalEventService, + private queueService: QueueService, + private noteReadService: NoteReadService, + private createNotificationService: CreateNotificationService, + private relayService: RelayService, + private federatedInstanceService: FederatedInstanceService, + private hashtagService: HashtagService, + private antennaService: AntennaService, + private webhookService: WebhookService, + private resolveUserService: ResolveUserService, + private apDeliverManagerService: ApDeliverManagerService, + private apRendererService: ApRendererService, + private notesChart: NotesChart, + private perUserNotesChart: PerUserNotesChart, + private activeUsersChart: ActiveUsersChart, + private instanceChart: InstanceChart, + ) {} + + public async create(user: { + id: User['id']; + username: User['username']; + host: User['host']; + isSilenced: User['isSilenced']; + createdAt: User['createdAt']; + }, data: Option, silent = false): Promise { + // チャンネル外にリプライしたら対象のスコープに合わせる + // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) + if (data.reply && data.channel && data.reply.channelId !== data.channel.id) { + if (data.reply.channelId) { + data.channel = await this.channelsRepository.findOneBy({ id: data.reply.channelId }); + } else { + data.channel = null; + } + } + + // チャンネル内にリプライしたら対象のスコープに合わせる + // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) + if (data.reply && (data.channel == null) && data.reply.channelId) { + data.channel = await this.channelsRepository.findOneBy({ id: data.reply.channelId }); + } + + if (data.createdAt == null) data.createdAt = new Date(); + if (data.visibility == null) data.visibility = 'public'; + if (data.localOnly == null) data.localOnly = false; + if (data.channel != null) data.visibility = 'public'; + if (data.channel != null) data.visibleUsers = []; + if (data.channel != null) data.localOnly = true; + + // サイレンス + if (user.isSilenced && data.visibility === 'public' && data.channel == null) { + data.visibility = 'home'; + } + + // Renote対象が「ホームまたは全体」以外の公開範囲ならreject + if (data.renote && data.renote.visibility !== 'public' && data.renote.visibility !== 'home' && data.renote.userId !== user.id) { + throw new Error('Renote target is not public or home'); + } + + // Renote対象がpublicではないならhomeにする + if (data.renote && data.renote.visibility !== 'public' && data.visibility === 'public') { + data.visibility = 'home'; + } + + // Renote対象がfollowersならfollowersにする + if (data.renote && data.renote.visibility === 'followers') { + data.visibility = 'followers'; + } + + // 返信対象がpublicではないならhomeにする + if (data.reply && data.reply.visibility !== 'public' && data.visibility === 'public') { + data.visibility = 'home'; + } + + // ローカルのみをRenoteしたらローカルのみにする + if (data.renote && data.renote.localOnly && data.channel == null) { + data.localOnly = true; + } + + // ローカルのみにリプライしたらローカルのみにする + if (data.reply && data.reply.localOnly && data.channel == null) { + data.localOnly = true; + } + + if (data.text) { + data.text = data.text.trim(); + } else { + data.text = null; + } + + let tags = data.apHashtags; + let emojis = data.apEmojis; + let mentionedUsers = data.apMentions; + + // Parse MFM if needed + if (!tags || !emojis || !mentionedUsers) { + 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); + + mentionedUsers = data.apMentions ?? await this.extractMentionedUsers(user, combinedTokens); + } + + 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)) { + mentionedUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId })); + } + + if (data.visibility === 'specified') { + if (data.visibleUsers == null) throw new Error('invalid param'); + + for (const u of data.visibleUsers) { + if (!mentionedUsers.some(x => x.id === u.id)) { + mentionedUsers.push(u); + } + } + + if (data.reply && !data.visibleUsers.some(x => x.id === data.reply!.userId)) { + data.visibleUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId })); + } + } + + const note = await this.insertNote(user, data, tags, emojis, mentionedUsers); + + setImmediate(() => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!)); + + return note; + } + + private async insertNote(user: { id: User['id']; host: User['host']; }, data: Option, tags: string[], emojis: string[], mentionedUsers: MinimumUser[]) { + const insert = new Note({ + id: this.idService.genId(data.createdAt!), + createdAt: data.createdAt!, + fileIds: data.files ? data.files.map(file => file.id) : [], + replyId: data.reply ? data.reply.id : null, + renoteId: data.renote ? data.renote.id : null, + channelId: data.channel ? data.channel.id : null, + threadId: data.reply + ? data.reply.threadId + ? data.reply.threadId + : data.reply.id + : null, + name: data.name, + text: data.text, + hasPoll: data.poll != null, + cw: data.cw == null ? null : data.cw, + tags: tags.map(tag => normalizeForSearch(tag)), + emojis, + userId: user.id, + localOnly: data.localOnly!, + visibility: data.visibility as any, + visibleUserIds: data.visibility === 'specified' + ? data.visibleUsers + ? data.visibleUsers.map(u => u.id) + : [] + : [], + + attachedFileTypes: data.files ? data.files.map(file => file.type) : [], + + // 以下非正規化データ + replyUserId: data.reply ? data.reply.userId : null, + replyUserHost: data.reply ? data.reply.userHost : null, + renoteUserId: data.renote ? data.renote.userId : null, + renoteUserHost: data.renote ? data.renote.userHost : null, + userHost: user.host, + }); + + if (data.uri != null) insert.uri = data.uri; + if (data.url != null) insert.url = data.url; + + // Append mentions data + if (mentionedUsers.length > 0) { + insert.mentions = mentionedUsers.map(u => u.id); + const profiles = await this.userProfilesRepository.findBy({ userId: In(insert.mentions) }); + insert.mentionedRemoteUsers = JSON.stringify(mentionedUsers.filter(u => this.userEntityService.isRemoteUser(u)).map(u => { + const profile = profiles.find(p => p.userId === u.id); + const url = profile != null ? profile.url : null; + return { + uri: u.uri, + url: url == null ? undefined : url, + username: u.username, + host: u.host, + } as IMentionedRemoteUsers[0]; + })); + } + + // 投稿を作成 + try { + if (insert.hasPoll) { + // Start transaction + await this.db.transaction(async transactionalEntityManager => { + await transactionalEntityManager.insert(Note, insert); + + const poll = new Poll({ + noteId: insert.id, + choices: data.poll!.choices, + expiresAt: data.poll!.expiresAt, + multiple: data.poll!.multiple, + votes: new Array(data.poll!.choices.length).fill(0), + noteVisibility: insert.visibility, + userId: user.id, + userHost: user.host, + }); + + await transactionalEntityManager.insert(Poll, poll); + }); + } else { + await this.notesRepository.insert(insert); + } + + return insert; + } catch (e) { + // duplicate key error + if (isDuplicateKeyValueError(e)) { + const err = new Error('Duplicated note'); + err.name = 'duplicated'; + throw err; + } + + console.error(e); + + throw e; + } + } + + private async postNoteCreated(note: Note, user: { + id: User['id']; + username: User['username']; + host: User['host']; + isSilenced: User['isSilenced']; + createdAt: User['createdAt']; + }, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) { + // 統計を更新 + this.notesChart.update(note, true); + this.perUserNotesChart.update(user, note, true); + + // Register host + if (this.userEntityService.isRemoteUser(user)) { + this.federatedInstanceService.registerOrFetchInstanceDoc(user.host).then(i => { + this.instancesRepository.increment({ id: i.id }, 'notesCount', 1); + this.instanceChart.updateNote(i.host, note, true); + }); + } + + // ハッシュタグ更新 + if (data.visibility === 'public' || data.visibility === 'home') { + this.hashtagService.updateHashtags(user, tags); + } + + // Increment notes count (user) + this.incNotesCountOfUser(user); + + // Word mute + mutedWordsCache.fetch(null, () => this.userProfilesRepository.find({ + where: { + enableWordMute: true, + }, + select: ['userId', 'mutedWords'], + })).then(us => { + for (const u of us) { + checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => { + if (shouldMute) { + this.mutedNotesRepository.insert({ + id: this.idService.genId(), + userId: u.userId, + noteId: note.id, + reason: 'word', + }); + } + }); + } + }); + + // Antenna + for (const antenna of (await this.antennaService.getAntennas())) { + this.antennaService.checkHitAntenna(antenna, note, user).then(hit => { + if (hit) { + this.antennaService.addNoteToAntenna(antenna, note, user); + } + }); + } + + // Channel + if (note.channelId) { + this.channelFollowingsRepository.findBy({ followeeId: note.channelId }).then(followings => { + for (const following of followings) { + this.noteReadService.insertNoteUnread(following.followerId, note, { + isSpecified: false, + isMentioned: false, + }); + } + }); + } + + if (data.reply) { + this.saveReply(data.reply, note); + } + + // この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき + if (data.renote && (await this.noteEntityService.countSameRenotes(user.id, data.renote.id, note.id) === 0)) { + this.incRenoteCount(data.renote); + } + + if (data.poll && data.poll.expiresAt) { + const delay = data.poll.expiresAt.getTime() - Date.now(); + this.queueService.endedPollNotificationQueue.add({ + noteId: note.id, + }, { + delay, + removeOnComplete: true, + }); + } + + if (!silent) { + if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user); + + // 未読通知を作成 + if (data.visibility === 'specified') { + if (data.visibleUsers == null) throw new Error('invalid param'); + + for (const u of data.visibleUsers) { + // ローカルユーザーのみ + if (!this.userEntityService.isLocalUser(u)) continue; + + this.noteReadService.insertNoteUnread(u.id, note, { + isSpecified: true, + isMentioned: false, + }); + } + } else { + for (const u of mentionedUsers) { + // ローカルユーザーのみ + if (!this.userEntityService.isLocalUser(u)) continue; + + this.noteReadService.insertNoteUnread(u.id, note, { + isSpecified: false, + isMentioned: true, + }); + } + } + + // Pack the note + const noteObj = await this.noteEntityService.pack(note); + + this.globalEventServie.publishNotesStream(noteObj); + + this.webhookService.getActiveWebhooks().then(webhooks => { + webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note')); + for (const webhook of webhooks) { + this.queueService.webhookDeliver(webhook, 'note', { + note: noteObj, + }); + } + }); + + const nm = new NotificationManager(this.mutingsRepository, this.createNotificationService, user, note); + const nmRelatedPromises = []; + + await this.createMentionedEvents(mentionedUsers, note, nm); + + // If has in reply to note + if (data.reply) { + // 通知 + if (data.reply.userHost === null) { + const threadMuted = await this.noteThreadMutingsRepository.findOneBy({ + userId: data.reply.userId, + threadId: data.reply.threadId ?? data.reply.id, + }); + + if (!threadMuted) { + nm.push(data.reply.userId, 'reply'); + this.globalEventServie.publishMainStream(data.reply.userId, 'reply', noteObj); + + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.reply!.userId && x.on.includes('reply')); + for (const webhook of webhooks) { + this.queueService.webhookDeliver(webhook, 'reply', { + note: noteObj, + }); + } + } + } + } + + // If it is renote + if (data.renote) { + const type = data.text ? 'quote' : 'renote'; + + // Notify + if (data.renote.userHost === null) { + nm.push(data.renote.userId, type); + } + + // Publish event + if ((user.id !== data.renote.userId) && data.renote.userHost === null) { + this.globalEventServie.publishMainStream(data.renote.userId, 'renote', noteObj); + + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.renote!.userId && x.on.includes('renote')); + for (const webhook of webhooks) { + this.queueService.webhookDeliver(webhook, 'renote', { + note: noteObj, + }); + } + } + } + + Promise.all(nmRelatedPromises).then(() => { + nm.deliver(); + }); + + //#region AP deliver + if (this.userEntityService.isLocalUser(user)) { + (async () => { + const noteActivity = await this.renderNoteOrRenoteActivity(data, note); + const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity); + + // メンションされたリモートユーザーに配送 + for (const u of mentionedUsers.filter(u => this.userEntityService.isRemoteUser(u))) { + dm.addDirectRecipe(u as IRemoteUser); + } + + // 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送 + if (data.reply && data.reply.userHost !== null) { + const u = await this.usersRepository.findOneBy({ id: data.reply.userId }); + if (u && this.userEntityService.isRemoteUser(u)) dm.addDirectRecipe(u); + } + + // 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送 + if (data.renote && data.renote.userHost !== null) { + const u = await this.usersRepository.findOneBy({ id: data.renote.userId }); + if (u && this.userEntityService.isRemoteUser(u)) dm.addDirectRecipe(u); + } + + // フォロワーに配送 + if (['public', 'home', 'followers'].includes(note.visibility)) { + dm.addFollowersRecipe(); + } + + if (['public'].includes(note.visibility)) { + this.relayService.deliverToRelays(user, noteActivity); + } + + dm.execute(); + })(); + } + //#endregion + } + + if (data.channel) { + this.channelsRepository.increment({ id: data.channel.id }, 'notesCount', 1); + this.channelsRepository.update(data.channel.id, { + lastNotedAt: new Date(), + }); + + this.notesRepository.countBy({ + userId: user.id, + channelId: data.channel.id, + }).then(count => { + // この処理が行われるのはノート作成後なので、ノートが一つしかなかったら最初の投稿だと判断できる + // TODO: とはいえノートを削除して何回も投稿すればその分だけインクリメントされる雑さもあるのでどうにかしたい + if (count === 1) { + this.channelsRepository.increment({ id: data.channel!.id }, 'usersCount', 1); + } + }); + } + + // Register to search database + this.index(note); + } + + private incRenoteCount(renote: Note) { + this.notesRepository.createQueryBuilder().update() + .set({ + renoteCount: () => '"renoteCount" + 1', + score: () => '"score" + 1', + }) + .where('id = :id', { id: renote.id }) + .execute(); + } + + private async createMentionedEvents(mentionedUsers: MinimumUser[], note: Note, nm: NotificationManager) { + for (const u of mentionedUsers.filter(u => this.userEntityService.isLocalUser(u))) { + const threadMuted = await this.noteThreadMutingsRepository.findOneBy({ + userId: u.id, + threadId: note.threadId ?? note.id, + }); + + if (threadMuted) { + continue; + } + + const detailPackedNote = await this.noteEntityService.pack(note, u, { + detail: true, + }); + + this.globalEventServie.publishMainStream(u.id, 'mention', detailPackedNote); + + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('mention')); + for (const webhook of webhooks) { + this.queueService.webhookDeliver(webhook, 'mention', { + note: detailPackedNote, + }); + } + + // Create notification + nm.push(u.id, 'mention'); + } + } + + private saveReply(reply: Note, note: Note) { + this.notesRepository.increment({ id: reply.id }, 'repliesCount', 1); + } + + private async renderNoteOrRenoteActivity(data: Option, note: Note) { + if (data.localOnly) return null; + + const content = data.renote && data.text == null && data.poll == null && (data.files == null || data.files.length === 0) + ? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note) + : this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false), note); + + return this.apRendererService.renderActivity(content); + } + + private index(note: Note) { + if (note.text == null || this.config.elasticsearch == null) return; + /* + es!.index({ + index: this.config.elasticsearch.index ?? 'misskey_note', + id: note.id.toString(), + body: { + text: normalizeForSearch(note.text), + userId: note.userId, + userHost: note.userHost, + }, + });*/ + } + + private incNotesCountOfUser(user: { id: User['id']; }) { + this.usersRepository.createQueryBuilder().update() + .set({ + updatedAt: new Date(), + notesCount: () => '"notesCount" + 1', + }) + .where('id = :id', { id: user.id }) + .execute(); + } + + private async extractMentionedUsers(user: { host: User['host']; }, tokens: mfm.MfmNode[]): Promise { + if (tokens == null) return []; + + const mentions = extractMentions(tokens); + let mentionedUsers = (await Promise.all(mentions.map(m => + this.resolveUserService.resolveUser(m.username, m.host ?? user.host).catch(() => null), + ))).filter(x => x != null) as User[]; + + // Drop duplicate users + mentionedUsers = mentionedUsers.filter((u, i, self) => + i === self.findIndex(u2 => u.id === u2.id), + ); + + return mentionedUsers; + } +} diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts new file mode 100644 index 000000000..6365286f8 --- /dev/null +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -0,0 +1,168 @@ +import { Brackets, In } from 'typeorm'; +import { Injectable, Inject } from '@nestjs/common'; +import type { User, ILocalUser, IRemoteUser } from '@/models/entities/User.js'; +import type { Note, IMentionedRemoteUsers } from '@/models/entities/Note.js'; +import { InstancesRepository, NotesRepository, UsersRepository } from '@/models/index.js'; +import { RelayService } from '@/core/RelayService.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import NotesChart from '@/core/chart/charts/notes.js'; +import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js'; +import InstanceChart from '@/core/chart/charts/instance.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { ApRendererService } from './remote/activitypub/ApRendererService.js'; +import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js'; +import { UserEntityService } from './entities/UserEntityService.js'; + +@Injectable() +export class NoteDeleteService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + private userEntityService: UserEntityService, + private globalEventServie: GlobalEventService, + private relayService: RelayService, + private federatedInstanceService: FederatedInstanceService, + private apRendererService: ApRendererService, + private apDeliverManagerService: ApDeliverManagerService, + private notesChart: NotesChart, + private perUserNotesChart: PerUserNotesChart, + private instanceChart: InstanceChart, + ) {} + + /** + * 投稿を削除します。 + * @param user 投稿者 + * @param note 投稿 + */ + async delete(user: { id: User['id']; uri: User['uri']; host: User['host']; }, note: Note, quiet = false) { + const deletedAt = new Date(); + + // この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき + if (note.renoteId && (await this.noteEntityService.countSameRenotes(user.id, note.renoteId, note.id)) === 0) { + this.notesRepository.decrement({ id: note.renoteId }, 'renoteCount', 1); + this.notesRepository.decrement({ id: note.renoteId }, 'score', 1); + } + + if (note.replyId) { + await this.notesRepository.decrement({ id: note.replyId }, 'repliesCount', 1); + } + + if (!quiet) { + this.globalEventServie.publishNoteStream(note.id, 'deleted', { + deletedAt: deletedAt, + }); + + //#region ローカルの投稿なら削除アクティビティを配送 + if (this.userEntityService.isLocalUser(user) && !note.localOnly) { + let renote: Note | null = null; + + // if deletd note is renote + if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) { + renote = await this.notesRepository.findOneBy({ + id: note.renoteId, + }); + } + + const content = this.apRendererService.renderActivity(renote + ? this.apRendererService.renderUndo(this.apRendererService.renderAnnounce(renote.uri ?? `${this.config.url}/notes/${renote.id}`, note), user) + : this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${note.id}`), user)); + + this.deliverToConcerned(user, note, content); + } + + // also deliever delete activity to cascaded notes + const cascadingNotes = (await this.findCascadingNotes(note)).filter(note => !note.localOnly); // filter out local-only notes + for (const cascadingNote of cascadingNotes) { + if (!cascadingNote.user) continue; + if (!this.userEntityService.isLocalUser(cascadingNote.user)) continue; + const content = this.apRendererService.renderActivity(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${cascadingNote.id}`), cascadingNote.user)); + this.deliverToConcerned(cascadingNote.user, cascadingNote, content); + } + //#endregion + + // 統計を更新 + this.notesChart.update(note, false); + this.perUserNotesChart.update(user, note, false); + + if (this.userEntityService.isRemoteUser(user)) { + this.federatedInstanceService.registerOrFetchInstanceDoc(user.host).then(i => { + this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1); + this.instanceChart.updateNote(i.host, note, false); + }); + } + } + + await this.notesRepository.delete({ + id: note.id, + userId: user.id, + }); + } + + private async findCascadingNotes(note: Note) { + const cascadingNotes: Note[] = []; + + const recursive = async (noteId: string) => { + const query = this.notesRepository.createQueryBuilder('note') + .where('note.replyId = :noteId', { noteId }) + .orWhere(new Brackets(q => { + q.where('note.renoteId = :noteId', { noteId }) + .andWhere('note.text IS NOT NULL'); + })) + .leftJoinAndSelect('note.user', 'user'); + const replies = await query.getMany(); + for (const reply of replies) { + cascadingNotes.push(reply); + await recursive(reply.id); + } + }; + await recursive(note.id); + + return cascadingNotes.filter(note => note.userHost === null); // filter out non-local users + } + + private async getMentionedRemoteUsers(note: Note) { + 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 IRemoteUser[]; + } + + private async deliverToConcerned(user: { id: ILocalUser['id']; host: null; }, note: Note, content: any) { + this.apDeliverManagerService.deliverToFollowers(user, content); + this.relayService.deliverToRelays(user, content); + const remoteUsers = await this.getMentionedRemoteUsers(note); + for (const remoteUser of remoteUsers) { + this.apDeliverManagerService.deliverToUser(user, content, remoteUser); + } + } +} diff --git a/packages/backend/src/core/NotePiningService.ts b/packages/backend/src/core/NotePiningService.ts new file mode 100644 index 000000000..576e90bd4 --- /dev/null +++ b/packages/backend/src/core/NotePiningService.ts @@ -0,0 +1,117 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { UsersRepository } from '@/models/index.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import type { User } from '@/models/entities/User.js'; +import type { Note } from '@/models/entities/Note.js'; +import { IdService } from '@/core/IdService.js'; +import type { UserNotePining } from '@/models/entities/UserNotePining.js'; +import { RelayService } from '@/core/RelayService.js'; +import { Config } from '@/config.js'; +import { UserEntityService } from './entities/UserEntityService.js'; +import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js'; +import { ApRendererService } from './remote/activitypub/ApRendererService.js'; + +@Injectable() +export class NotePiningService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.userNotePiningsRepository) + private userNotePiningsRepository: UserNotePiningsRepository, + + private userEntityService: UserEntityService, + private idService: IdService, + private relayService: RelayService, + private apDeliverManagerService: ApDeliverManagerService, + private apRendererService: ApRendererService, + ) { + } + + /** + * 指定した投稿をピン留めします + * @param user + * @param noteId + */ + public async addPinned(user: { id: User['id']; host: User['host']; }, noteId: Note['id']) { + // Fetch pinee + const note = await this.notesRepository.findOneBy({ + id: noteId, + userId: user.id, + }); + + if (note == null) { + throw new IdentifiableError('70c4e51f-5bea-449c-a030-53bee3cce202', 'No such note.'); + } + + const pinings = await this.userNotePiningsRepository.findBy({ userId: user.id }); + + if (pinings.length >= 5) { + throw new IdentifiableError('15a018eb-58e5-4da1-93be-330fcc5e4e1a', 'You can not pin notes any more.'); + } + + if (pinings.some(pining => pining.noteId === note.id)) { + throw new IdentifiableError('23f0cf4e-59a3-4276-a91d-61a5891c1514', 'That note has already been pinned.'); + } + + await this.userNotePiningsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: user.id, + noteId: note.id, + } as UserNotePining); + + // Deliver to remote followers + if (this.userEntityService.isLocalUser(user)) { + this.deliverPinnedChange(user.id, note.id, true); + } + } + + /** + * 指定した投稿のピン留めを解除します + * @param user + * @param noteId + */ + public async removePinned(user: { id: User['id']; host: User['host']; }, noteId: Note['id']) { + // Fetch unpinee + const note = await this.notesRepository.findOneBy({ + id: noteId, + userId: user.id, + }); + + if (note == null) { + throw new IdentifiableError('b302d4cf-c050-400a-bbb3-be208681f40c', 'No such note.'); + } + + this.userNotePiningsRepository.delete({ + userId: user.id, + noteId: note.id, + }); + + // Deliver to remote followers + if (this.userEntityService.isLocalUser(user)) { + this.deliverPinnedChange(user.id, noteId, false); + } + } + + public async deliverPinnedChange(userId: User['id'], noteId: Note['id'], isAddition: boolean) { + const user = await this.usersRepository.findOneBy({ id: userId }); + if (user == null) throw new Error('user not found'); + + if (!this.userEntityService.isLocalUser(user)) return; + + const target = `${this.config.url}/users/${user.id}/collections/featured`; + const item = `${this.config.url}/notes/${noteId}`; + const content = this.apRendererService.renderActivity(isAddition ? this.apRendererService.renderAdd(user, target, item) : this.apRendererService.renderRemove(user, target, item)); + + this.apDeliverManagerService.deliverToFollowers(user, content); + this.relayService.deliverToRelays(user, content); + } +} diff --git a/packages/backend/src/core/NoteReadService.ts b/packages/backend/src/core/NoteReadService.ts new file mode 100644 index 000000000..b1572c631 --- /dev/null +++ b/packages/backend/src/core/NoteReadService.ts @@ -0,0 +1,214 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { In, IsNull, Not } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { User } from '@/models/entities/User.js'; +import type { Channel } from '@/models/entities/Channel.js'; +import type { Packed } from '@/misc/schema.js'; +import type { Note } from '@/models/entities/Note.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { UserEntityService } from './entities/UserEntityService.js'; +import { NotificationService } from './NotificationService.js'; +import { AntennaService } from './AntennaService.js'; + +@Injectable() +export class NoteReadService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.noteUnreadsRepository) + private noteUnreadsRepository: NoteUnreadsRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + @Inject(DI.noteThreadMutingsRepository) + private noteThreadMutingsRepository: NoteThreadMutingsRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + + @Inject(DI.antennaNotesRepository) + private antennaNotesRepository: AntennaNotesRepository, + + private userEntityService: UserEntityService, + private idService: IdService, + private globalEventServie: GlobalEventService, + private notificationService: NotificationService, + private antennaService: AntennaService, + ) { + } + + public async insertNoteUnread(userId: User['id'], note: Note, params: { + // NOTE: isSpecifiedがtrueならisMentionedは必ずfalse + isSpecified: boolean; + isMentioned: boolean; + }): Promise { + //#region ミュートしているなら無視 + // TODO: 現在の仕様ではChannelにミュートは適用されないのでよしなにケアする + const mute = await this.mutingsRepository.findBy({ + muterId: userId, + }); + if (mute.map(m => m.muteeId).includes(note.userId)) return; + //#endregion + + // スレッドミュート + const threadMute = await this.noteThreadMutingsRepository.findOneBy({ + userId: userId, + threadId: note.threadId ?? note.id, + }); + if (threadMute) return; + + const unread = { + id: this.idService.genId(), + noteId: note.id, + userId: userId, + isSpecified: params.isSpecified, + isMentioned: params.isMentioned, + noteChannelId: note.channelId, + noteUserId: note.userId, + }; + + await this.noteUnreadsRepository.insert(unread); + + // 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する + setTimeout(async () => { + const exist = await this.noteUnreadsRepository.findOneBy({ id: unread.id }); + + if (exist == null) return; + + if (params.isMentioned) { + this.globalEventServie.publishMainStream(userId, 'unreadMention', note.id); + } + if (params.isSpecified) { + this.globalEventServie.publishMainStream(userId, 'unreadSpecifiedNote', note.id); + } + if (note.channelId) { + this.globalEventServie.publishMainStream(userId, 'unreadChannel', note.id); + } + }, 2000); + } + + public async read( + userId: User['id'], + notes: (Note | Packed<'Note'>)[], + info?: { + following: Set; + followingChannels: Set; + }, + ): Promise { + const following = info?.following ? info.following : new Set((await this.followingsRepository.find({ + where: { + followerId: userId, + }, + select: ['followeeId'], + })).map(x => x.followeeId)); + const followingChannels = info?.followingChannels ? info.followingChannels : new Set((await this.channelFollowingsRepository.find({ + where: { + followerId: userId, + }, + select: ['followeeId'], + })).map(x => x.followeeId)); + + const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId); + const readMentions: (Note | Packed<'Note'>)[] = []; + const readSpecifiedNotes: (Note | Packed<'Note'>)[] = []; + const readChannelNotes: (Note | Packed<'Note'>)[] = []; + const readAntennaNotes: (Note | Packed<'Note'>)[] = []; + + for (const note of notes) { + if (note.mentions && note.mentions.includes(userId)) { + readMentions.push(note); + } else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) { + readSpecifiedNotes.push(note); + } + + if (note.channelId && followingChannels.has(note.channelId)) { + readChannelNotes.push(note); + } + + if (note.user != null) { // たぶんnullになることは無いはずだけど一応 + for (const antenna of myAntennas) { + if (await this.antennaService.checkHitAntenna(antenna, note, note.user, undefined, Array.from(following))) { + readAntennaNotes.push(note); + } + } + } + } + + if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0) || (readChannelNotes.length > 0)) { + // Remove the record + await this.noteUnreadsRepository.delete({ + userId: userId, + noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id), ...readChannelNotes.map(n => n.id)]), + }); + + // TODO: ↓まとめてクエリしたい + + this.noteUnreadsRepository.countBy({ + userId: userId, + isMentioned: true, + }).then(mentionsCount => { + if (mentionsCount === 0) { + // 全て既読になったイベントを発行 + this.globalEventServie.publishMainStream(userId, 'readAllUnreadMentions'); + } + }); + + this.noteUnreadsRepository.countBy({ + userId: userId, + isSpecified: true, + }).then(specifiedCount => { + if (specifiedCount === 0) { + // 全て既読になったイベントを発行 + this.globalEventServie.publishMainStream(userId, 'readAllUnreadSpecifiedNotes'); + } + }); + + this.noteUnreadsRepository.countBy({ + userId: userId, + noteChannelId: Not(IsNull()), + }).then(channelNoteCount => { + if (channelNoteCount === 0) { + // 全て既読になったイベントを発行 + this.globalEventServie.publishMainStream(userId, 'readAllChannels'); + } + }); + + this.notificationService.readNotificationByQuery(userId, { + noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]), + }); + } + + if (readAntennaNotes.length > 0) { + await this.antennaNotesRepository.update({ + antennaId: In(myAntennas.map(a => a.id)), + noteId: In(readAntennaNotes.map(n => n.id)), + }, { + read: true, + }); + + // TODO: まとめてクエリしたい + for (const antenna of myAntennas) { + const count = await this.antennaNotesRepository.countBy({ + antennaId: antenna.id, + read: false, + }); + + if (count === 0) { + this.globalEventServie.publishMainStream(userId, 'readAntenna', antenna); + } + } + + this.userEntityService.getHasUnreadAntenna(userId).then(unread => { + if (!unread) { + this.globalEventServie.publishMainStream(userId, 'readAllAntennas'); + } + }); + } + } +} diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts new file mode 100644 index 000000000..ca9e60889 --- /dev/null +++ b/packages/backend/src/core/NotificationService.ts @@ -0,0 +1,67 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { NotificationsRepository } from '@/models/index.js'; +import type { UsersRepository } from '@/models/index.js'; +import type { User } from '@/models/entities/User.js'; +import type { Notification } from '@/models/entities/Notification.js'; +import { UserEntityService } from './entities/UserEntityService.js'; +import { GlobalEventService } from './GlobalEventService.js'; +import { PushNotificationService } from './PushNotificationService.js'; + +@Injectable() +export class NotificationService { + constructor( + @Inject(DI.notificationsRepository) + private notificationsRepository: NotificationsRepository, + + private userEntityService: UserEntityService, + private globalEventService: GlobalEventService, + private pushNotificationService: PushNotificationService, + ) { + } + + public async readNotification( + userId: User['id'], + notificationIds: Notification['id'][], + ) { + if (notificationIds.length === 0) return; + + // Update documents + const result = await this.notificationsRepository.update({ + notifieeId: userId, + id: In(notificationIds), + isRead: false, + }, { + isRead: true, + }); + + if (result.affected === 0) return; + + if (!await this.userEntityService.getHasUnreadNotification(userId)) return this.postReadAllNotifications(userId); + else return this.postReadNotifications(userId, notificationIds); + } + + public async readNotificationByQuery( + userId: User['id'], + query: Record, + ) { + const notificationIds = await this.notificationsRepository.findBy({ + ...query, + notifieeId: userId, + isRead: false, + }).then(notifications => notifications.map(notification => notification.id)); + + return this.readNotification(userId, notificationIds); + } + + private postReadAllNotifications(userId: User['id']) { + this.globalEventService.publishMainStream(userId, 'readAllNotifications'); + return this.pushNotificationService.pushNotification(userId, 'readAllNotifications', undefined); + } + + private postReadNotifications(userId: User['id'], notificationIds: Notification['id'][]) { + this.globalEventService.publishMainStream(userId, 'readNotifications', notificationIds); + return this.pushNotificationService.pushNotification(userId, 'readNotifications', { notificationIds }); + } +} diff --git a/packages/backend/src/core/PollService.ts b/packages/backend/src/core/PollService.ts new file mode 100644 index 000000000..8bc94c8a8 --- /dev/null +++ b/packages/backend/src/core/PollService.ts @@ -0,0 +1,115 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Not } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { NotesRepository, UsersRepository, BlockingsRepository } from '@/models/index.js'; +import type { Note } from '@/models/entities/Note.js'; +import { RelayService } from '@/core/RelayService.js'; +import type { CacheableUser } from '@/models/entities/User.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +import { ApRendererService } from './remote/activitypub/ApRendererService.js'; +import { UserEntityService } from './entities/UserEntityService.js'; +import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js'; + +@Injectable() +export class PollService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.pollsRepository) + private pollsRepository: PollsRepository, + + @Inject(DI.pollVotesRepository) + private pollVotesRepository: PollVotesRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + private userEntityService: UserEntityService, + private idService: IdService, + private relayService: RelayService, + private globalEventServie: GlobalEventService, + private createNotificationService: CreateNotificationService, + private apRendererService: ApRendererService, + private apDeliverManagerService: ApDeliverManagerService, + ) { + } + + public async vote(user: CacheableUser, note: Note, choice: number) { + const poll = await this.pollsRepository.findOneBy({ noteId: note.id }); + + if (poll == null) throw new Error('poll not found'); + + // Check whether is valid choice + if (poll.choices[choice] == null) throw new Error('invalid choice param'); + + // Check blocking + if (note.userId !== user.id) { + const block = await this.blockingsRepository.findOneBy({ + blockerId: note.userId, + blockeeId: user.id, + }); + if (block) { + throw new Error('blocked'); + } + } + + // if already voted + const exist = await this.pollVotesRepository.findBy({ + noteId: note.id, + userId: user.id, + }); + + if (poll.multiple) { + if (exist.some(x => x.choice === choice)) { + throw new Error('already voted'); + } + } else if (exist.length !== 0) { + throw new Error('already voted'); + } + + // Create vote + await this.pollVotesRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + noteId: note.id, + userId: user.id, + choice: choice, + }); + + // Increment votes count + const index = choice + 1; // In SQL, array index is 1 based + await this.pollsRepository.query(`UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`); + + this.globalEventServie.publishNoteStream(note.id, 'pollVoted', { + choice: choice, + userId: user.id, + }); + + // Notify + this.createNotificationService.createNotification(note.userId, 'pollVote', { + notifierId: user.id, + noteId: note.id, + choice: choice, + }); + } + + public async deliverQuestionUpdate(noteId: Note['id']) { + const note = await this.notesRepository.findOneBy({ id: noteId }); + if (note == null) throw new Error('note not found'); + + const user = await this.usersRepository.findOneBy({ id: note.userId }); + if (user == null) throw new Error('note not found'); + + if (this.userEntityService.isLocalUser(user)) { + const content = this.apRendererService.renderActivity(this.apRendererService.renderUpdate(await this.apRendererService.renderNote(note, false), user)); + this.apDeliverManagerService.deliverToFollowers(user, content); + this.relayService.deliverToRelays(user, content); + } + } +} diff --git a/packages/backend/src/core/ProxyAccountService.ts b/packages/backend/src/core/ProxyAccountService.ts new file mode 100644 index 000000000..40ccc8226 --- /dev/null +++ b/packages/backend/src/core/ProxyAccountService.ts @@ -0,0 +1,22 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { UsersRepository } from '@/models/index.js'; +import type { ILocalUser, User } from '@/models/entities/User.js'; +import { DI } from '@/di-symbols.js'; +import { MetaService } from './MetaService.js'; + +@Injectable() +export class ProxyAccountService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private metaService: MetaService, + ) { + } + + public async fetch(): Promise { + const meta = await this.metaService.fetch(); + if (meta.proxyAccountId == null) return null; + return await this.usersRepository.findOneByOrFail({ id: meta.proxyAccountId }) as ILocalUser; + } +} diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts new file mode 100644 index 000000000..31d29bed9 --- /dev/null +++ b/packages/backend/src/core/PushNotificationService.ts @@ -0,0 +1,101 @@ +import { Inject, Injectable } from '@nestjs/common'; +import push from 'web-push'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import type { Packed } from '@/misc/schema'; +import { getNoteSummary } from '@/misc/get-note-summary.js'; +import { SwSubscriptionsRepository } from '@/models/index.js'; +import { MetaService } from './MetaService.js'; + +// Defined also packages/sw/types.ts#L14-L21 +type pushNotificationsTypes = { + 'notification': Packed<'Notification'>; + 'unreadMessagingMessage': Packed<'MessagingMessage'>; + 'readNotifications': { notificationIds: string[] }; + 'readAllNotifications': undefined; + 'readAllMessagingMessages': undefined; + 'readAllMessagingMessagesOfARoom': { userId: string } | { groupId: string }; +}; + +// プッシュメッセージサーバーには文字数制限があるため、内容を削減します +function truncateNotification(notification: Packed<'Notification'>): any { + if (notification.note) { + return { + ...notification, + note: { + ...notification.note, + // textをgetNoteSummaryしたものに置き換える + text: getNoteSummary(notification.type === 'renote' ? notification.note.renote as Packed<'Note'> : notification.note), + + cw: undefined, + reply: undefined, + renote: undefined, + user: undefined as any, // 通知を受け取ったユーザーである場合が多いのでこれも捨てる + }, + }; + } + + return notification; +} + +@Injectable() +export class PushNotificationService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.swSubscriptionsRepository) + private swSubscriptionsRepository: SwSubscriptionsRepository, + + private metaService: MetaService, + ) { + } + + public async pushNotification(userId: string, type: T, body: pushNotificationsTypes[T]) { + const meta = await this.metaService.fetch(); + + if (!meta.enableServiceWorker || meta.swPublicKey == null || meta.swPrivateKey == null) return; + + // アプリケーションの連絡先と、サーバーサイドの鍵ペアの情報を登録 + push.setVapidDetails(this.config.url, + meta.swPublicKey, + meta.swPrivateKey); + + // Fetch + const subscriptions = await this.swSubscriptionsRepository.findBy({ + userId: userId, + }); + + for (const subscription of subscriptions) { + const pushSubscription = { + endpoint: subscription.endpoint, + keys: { + auth: subscription.auth, + p256dh: subscription.publickey, + }, + }; + + push.sendNotification(pushSubscription, JSON.stringify({ + type, + body: type === 'notification' ? truncateNotification(body as Packed<'Notification'>) : body, + userId, + dateTime: (new Date()).getTime(), + }), { + proxy: this.config.proxy, + }).catch((err: any) => { + //swLogger.info(err.statusCode); + //swLogger.info(err.headers); + //swLogger.info(err.body); + + if (err.statusCode === 410) { + this.swSubscriptionsRepository.delete({ + userId: userId, + endpoint: subscription.endpoint, + auth: subscription.auth, + publickey: subscription.publickey, + }); + } + }); + } + } +} diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts new file mode 100644 index 000000000..1613f70c8 --- /dev/null +++ b/packages/backend/src/core/QueryService.ts @@ -0,0 +1,262 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { User } from '@/models/entities/User.js'; +import type { SelectQueryBuilder } from 'typeorm'; + +@Injectable() +export class QueryService { + constructor( + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + + @Inject(DI.mutedNotesRepository) + private mutedNotesRepository: MutedNotesRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.noteThreadMutingsRepository) + private noteThreadMutingsRepository: NoteThreadMutingsRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + ) { + } + + public makePaginationQuery(q: SelectQueryBuilder, sinceId?: string, untilId?: string, sinceDate?: number, untilDate?: number): SelectQueryBuilder { + if (sinceId && untilId) { + q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); + q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId }); + q.orderBy(`${q.alias}.id`, 'DESC'); + } else if (sinceId) { + q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); + q.orderBy(`${q.alias}.id`, 'ASC'); + } else if (untilId) { + q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId }); + q.orderBy(`${q.alias}.id`, 'DESC'); + } else if (sinceDate && untilDate) { + q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) }); + q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) }); + q.orderBy(`${q.alias}.createdAt`, 'DESC'); + } else if (sinceDate) { + q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) }); + q.orderBy(`${q.alias}.createdAt`, 'ASC'); + } else if (untilDate) { + q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) }); + q.orderBy(`${q.alias}.createdAt`, 'DESC'); + } else { + q.orderBy(`${q.alias}.id`, 'DESC'); + } + return q; + } + + // ここでいうBlockedは被Blockedの意 + public generateBlockedUserQuery(q: SelectQueryBuilder, me: { id: User['id'] }): void { + const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking') + .select('blocking.blockerId') + .where('blocking.blockeeId = :blockeeId', { blockeeId: me.id }); + + // 投稿の作者にブロックされていない かつ + // 投稿の返信先の作者にブロックされていない かつ + // 投稿の引用元の作者にブロックされていない + q + .andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`) + .andWhere(new Brackets(qb => { qb + .where('note.replyUserId IS NULL') + .orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`); + })) + .andWhere(new Brackets(qb => { qb + .where('note.renoteUserId IS NULL') + .orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`); + })); + + q.setParameters(blockingQuery.getParameters()); + } + + public generateBlockQueryForUsers(q: SelectQueryBuilder, me: { id: User['id'] }): void { + const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking') + .select('blocking.blockeeId') + .where('blocking.blockerId = :blockerId', { blockerId: me.id }); + + const blockedQuery = this.blockingsRepository.createQueryBuilder('blocking') + .select('blocking.blockerId') + .where('blocking.blockeeId = :blockeeId', { blockeeId: me.id }); + + q.andWhere(`user.id NOT IN (${ blockingQuery.getQuery() })`); + q.setParameters(blockingQuery.getParameters()); + + q.andWhere(`user.id NOT IN (${ blockedQuery.getQuery() })`); + q.setParameters(blockedQuery.getParameters()); + } + + public generateChannelQuery(q: SelectQueryBuilder, me?: { id: User['id'] } | null): void { + if (me == null) { + q.andWhere('note.channelId IS NULL'); + } else { + q.leftJoinAndSelect('note.channel', 'channel'); + + const channelFollowingQuery = this.channelFollowingsRepository.createQueryBuilder('channelFollowing') + .select('channelFollowing.followeeId') + .where('channelFollowing.followerId = :followerId', { followerId: me.id }); + + q.andWhere(new Brackets(qb => { qb + // チャンネルのノートではない + .where('note.channelId IS NULL') + // または自分がフォローしているチャンネルのノート + .orWhere(`note.channelId IN (${ channelFollowingQuery.getQuery() })`); + })); + + q.setParameters(channelFollowingQuery.getParameters()); + } + } + + public generateMutedNoteQuery(q: SelectQueryBuilder, me: { id: User['id'] }): void { + const mutedQuery = this.mutedNotesRepository.createQueryBuilder('muted') + .select('muted.noteId') + .where('muted.userId = :userId', { userId: me.id }); + + q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`); + + q.setParameters(mutedQuery.getParameters()); + } + + public generateMutedNoteThreadQuery(q: SelectQueryBuilder, me: { id: User['id'] }): void { + const mutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted') + .select('threadMuted.threadId') + .where('threadMuted.userId = :userId', { userId: me.id }); + + q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`); + q.andWhere(new Brackets(qb => { qb + .where('note.threadId IS NULL') + .orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`); + })); + + q.setParameters(mutedQuery.getParameters()); + } + + public generateMutedUserQuery(q: SelectQueryBuilder, me: { id: User['id'] }, exclude?: User): void { + const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') + .select('muting.muteeId') + .where('muting.muterId = :muterId', { muterId: me.id }); + + if (exclude) { + mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: exclude.id }); + } + + const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile') + .select('user_profile.mutedInstances') + .where('user_profile.userId = :muterId', { muterId: me.id }); + + // 投稿の作者をミュートしていない かつ + // 投稿の返信先の作者をミュートしていない かつ + // 投稿の引用元の作者をミュートしていない + q + .andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`) + .andWhere(new Brackets(qb => { qb + .where('note.replyUserId IS NULL') + .orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`); + })) + .andWhere(new Brackets(qb => { qb + .where('note.renoteUserId IS NULL') + .orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`); + })) + // mute instances + .andWhere(new Brackets(qb => { qb + .andWhere('note.userHost IS NULL') + .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`); + })) + .andWhere(new Brackets(qb => { qb + .where('note.replyUserHost IS NULL') + .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`); + })) + .andWhere(new Brackets(qb => { qb + .where('note.renoteUserHost IS NULL') + .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`); + })); + + q.setParameters(mutingQuery.getParameters()); + q.setParameters(mutingInstanceQuery.getParameters()); + } + + public generateMutedUserQueryForUsers(q: SelectQueryBuilder, me: { id: User['id'] }): void { + const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') + .select('muting.muteeId') + .where('muting.muterId = :muterId', { muterId: me.id }); + + q.andWhere(`user.id NOT IN (${ mutingQuery.getQuery() })`); + + q.setParameters(mutingQuery.getParameters()); + } + + public generateRepliesQuery(q: SelectQueryBuilder, me?: Pick | null): void { + if (me == null) { + q.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'); + })); + })); + } else if (!me.showTimelineReplies) { + q.andWhere(new Brackets(qb => { qb + .where('note.replyId IS NULL') // 返信ではない + .orWhere('note.replyUserId = :meId', { meId: me.id }) // 返信だけど自分のノートへの返信 + .orWhere(new Brackets(qb => { qb // 返信だけど自分の行った返信 + .where('note.replyId IS NOT NULL') + .andWhere('note.userId = :meId', { meId: me.id }); + })) + .orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信 + .where('note.replyId IS NOT NULL') + .andWhere('note.replyUserId = note.userId'); + })); + })); + } + } + + public generateVisibilityQuery(q: SelectQueryBuilder, me?: { id: User['id'] } | null): void { + // This code must always be synchronized with the checks in Notes.isVisibleForMe. + if (me == null) { + q.andWhere(new Brackets(qb => { qb + .where('note.visibility = \'public\'') + .orWhere('note.visibility = \'home\''); + })); + } else { + const followingQuery = this.followingsRepository.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :meId'); + + q.andWhere(new Brackets(qb => { qb + // 公開投稿である + .where(new Brackets(qb => { qb + .where('note.visibility = \'public\'') + .orWhere('note.visibility = \'home\''); + })) + // または 自分自身 + .orWhere('note.userId = :meId') + // または 自分宛て + .orWhere(':meId = ANY(note.visibleUserIds)') + .orWhere(':meId = ANY(note.mentions)') + .orWhere(new Brackets(qb => { qb + // または フォロワー宛ての投稿であり、 + .where('note.visibility = \'followers\'') + .andWhere(new Brackets(qb => { qb + // 自分がフォロワーである + .where(`note.userId IN (${ followingQuery.getQuery() })`) + // または 自分の投稿へのリプライ + .orWhere('note.replyUserId = :meId'); + })); + })); + })); + + q.setParameters({ meId: me.id }); + } + } +} + diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts new file mode 100644 index 000000000..7e771c100 --- /dev/null +++ b/packages/backend/src/core/QueueService.ts @@ -0,0 +1,242 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { v4 as uuid } from 'uuid'; +import type { IActivity } from '@/core/remote/activitypub/type.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import type { Webhook, webhookEventTypes } from '@/models/entities/Webhook.js'; +import { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; +import { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue } from './queue/QueueModule.js'; +import type { ThinUser } from '../queue/types.js'; +import type httpSignature from '@peertube/http-signature'; + +@Injectable() +export class QueueService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject('queue:system') public systemQueue: SystemQueue, + @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, + @Inject('queue:deliver') public deliverQueue: DeliverQueue, + @Inject('queue:inbox') public inboxQueue: InboxQueue, + @Inject('queue:db') public dbQueue: DbQueue, + @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, + @Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, + ) {} + + public deliver(user: ThinUser, content: IActivity, to: string | null) { + if (content == null) return null; + if (to == null) return null; + + const data = { + user: { + id: user.id, + }, + content, + to, + }; + + return this.deliverQueue.add(data, { + attempts: this.config.deliverJobMaxAttempts ?? 12, + timeout: 1 * 60 * 1000, // 1min + backoff: { + type: 'apBackoff', + }, + removeOnComplete: true, + removeOnFail: true, + }); + } + + public inbox(activity: IActivity, signature: httpSignature.IParsedSignature) { + const data = { + activity: activity, + signature, + }; + + return this.inboxQueue.add(data, { + attempts: this.config.inboxJobMaxAttempts ?? 8, + timeout: 5 * 60 * 1000, // 5min + backoff: { + type: 'apBackoff', + }, + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createDeleteDriveFilesJob(user: ThinUser) { + return this.dbQueue.add('deleteDriveFiles', { + user: user, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createExportCustomEmojisJob(user: ThinUser) { + return this.dbQueue.add('exportCustomEmojis', { + user: user, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createExportNotesJob(user: ThinUser) { + return this.dbQueue.add('exportNotes', { + user: user, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createExportFollowingJob(user: ThinUser, excludeMuting = false, excludeInactive = false) { + return this.dbQueue.add('exportFollowing', { + user: user, + excludeMuting, + excludeInactive, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createExportMuteJob(user: ThinUser) { + return this.dbQueue.add('exportMuting', { + user: user, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createExportBlockingJob(user: ThinUser) { + return this.dbQueue.add('exportBlocking', { + user: user, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createExportUserListsJob(user: ThinUser) { + return this.dbQueue.add('exportUserLists', { + user: user, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createImportFollowingJob(user: ThinUser, fileId: DriveFile['id']) { + return this.dbQueue.add('importFollowing', { + user: user, + fileId: fileId, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createImportMutingJob(user: ThinUser, fileId: DriveFile['id']) { + return this.dbQueue.add('importMuting', { + user: user, + fileId: fileId, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createImportBlockingJob(user: ThinUser, fileId: DriveFile['id']) { + return this.dbQueue.add('importBlocking', { + user: user, + fileId: fileId, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createImportUserListsJob(user: ThinUser, fileId: DriveFile['id']) { + return this.dbQueue.add('importUserLists', { + user: user, + fileId: fileId, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createImportCustomEmojisJob(user: ThinUser, fileId: DriveFile['id']) { + return this.dbQueue.add('importCustomEmojis', { + user: user, + fileId: fileId, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createDeleteAccountJob(user: ThinUser, opts: { soft?: boolean; } = {}) { + return this.dbQueue.add('deleteAccount', { + user: user, + soft: opts.soft, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createDeleteObjectStorageFileJob(key: string) { + return this.objectStorageQueue.add('deleteFile', { + key: key, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createCleanRemoteFilesJob() { + return this.objectStorageQueue.add('cleanRemoteFiles', {}, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public webhookDeliver(webhook: Webhook, type: typeof webhookEventTypes[number], content: unknown) { + const data = { + type, + content, + webhookId: webhook.id, + userId: webhook.userId, + to: webhook.url, + secret: webhook.secret, + createdAt: Date.now(), + eventId: uuid(), + }; + + return this.webhookDeliverQueue.add(data, { + attempts: 4, + timeout: 1 * 60 * 1000, // 1min + backoff: { + type: 'apBackoff', + }, + removeOnComplete: true, + removeOnFail: true, + }); + } + + public destroy() { + this.deliverQueue.once('cleaned', (jobs, status) => { + //deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); + }); + this.deliverQueue.clean(0, 'delayed'); + + this.inboxQueue.once('cleaned', (jobs, status) => { + //inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); + }); + this.inboxQueue.clean(0, 'delayed'); + } +} diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts new file mode 100644 index 000000000..300645657 --- /dev/null +++ b/packages/backend/src/core/ReactionService.ts @@ -0,0 +1,340 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { EmojisRepository, BlockingsRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/index.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import type { IRemoteUser, User } from '@/models/entities/User.js'; +import type { Note } from '@/models/entities/Note.js'; +import { IdService } from '@/core/IdService.js'; +import type { NoteReaction } from '@/models/entities/NoteReaction.js'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +import PerUserReactionsChart from '@/core/chart/charts/per-user-reactions.js'; +import { emojiRegex } from '@/misc/emoji-regex.js'; +import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js'; +import { NoteEntityService } from './entities/NoteEntityService.js'; +import { UserEntityService } from './entities/UserEntityService.js'; +import { ApRendererService } from './remote/activitypub/ApRendererService.js'; +import { MetaService } from './MetaService.js'; +import { UtilityService } from './UtilityService.js'; + +const legacies: Record = { + 'like': '👍', + 'love': '❤', // ここに記述する場合は異体字セレクタを入れない + 'laugh': '😆', + 'hmm': '🤔', + 'surprise': '😮', + 'congrats': '🎉', + 'angry': '💢', + 'confused': '😥', + 'rip': '😇', + 'pudding': '🍮', + 'star': '⭐', +}; + +type DecodedReaction = { + /** + * リアクション名 (Unicode Emoji or ':name@hostname' or ':name@.') + */ + reaction: string; + + /** + * name (カスタム絵文字の場合name, Emojiクエリに使う) + */ + name?: string; + + /** + * host (カスタム絵文字の場合host, Emojiクエリに使う) + */ + host?: string | null; +}; + +@Injectable() +export class ReactionService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.noteReactionsRepository) + private noteReactionsRepository: NoteReactionsRepository, + + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, + + private utilityService: UtilityService, + private metaService: MetaService, + private userEntityService: UserEntityService, + private noteEntityService: NoteEntityService, + private idService: IdService, + private globalEventServie: GlobalEventService, + private apRendererService: ApRendererService, + private apDeliverManagerService: ApDeliverManagerService, + private createNotificationService: CreateNotificationService, + private perUserReactionsChart: PerUserReactionsChart, + ) { + } + + public async create(user: { id: User['id']; host: User['host']; }, note: Note, reaction?: string) { + // Check blocking + if (note.userId !== user.id) { + const block = await this.blockingsRepository.findOneBy({ + blockerId: note.userId, + blockeeId: user.id, + }); + if (block) { + throw new IdentifiableError('e70412a4-7197-4726-8e74-f3e0deb92aa7'); + } + } + + // check visibility + if (!await this.noteEntityService.isVisibleForMe(note, user.id)) { + throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.'); + } + + // TODO: cache + reaction = await this.toDbReaction(reaction, user.host); + + const record: NoteReaction = { + id: this.idService.genId(), + createdAt: new Date(), + noteId: note.id, + userId: user.id, + 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, + }); + + 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; + } + } + + // Increment reactions count + const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`; + await this.notesRepository.createQueryBuilder().update() + .set({ + reactions: () => sql, + score: () => '"score" + 1', + }) + .where('id = :id', { id: note.id }) + .execute(); + + this.perUserReactionsChart.update(user, note); + + // カスタム絵文字リアクションだったら絵文字情報も送る + const decodedReaction = this.decodeReaction(reaction); + + const emoji = await this.emojisRepository.findOne({ + where: { + name: decodedReaction.name, + host: decodedReaction.host ?? IsNull(), + }, + select: ['name', 'host', 'originalUrl', 'publicUrl'], + }); + + this.globalEventServie.publishNoteStream(note.id, 'reacted', { + reaction: decodedReaction.reaction, + emoji: emoji != null ? { + name: emoji.host ? `${emoji.name}@${emoji.host}` : `${emoji.name}@.`, + url: emoji.publicUrl ?? emoji.originalUrl, // || emoji.originalUrl してるのは後方互換性のため + } : null, + userId: user.id, + }); + + // リアクションされたユーザーがローカルユーザーなら通知を作成 + if (note.userHost === null) { + this.createNotificationService.createNotification(note.userId, 'reaction', { + notifierId: user.id, + noteId: note.id, + reaction: reaction, + }); + } + + //#region 配信 + if (this.userEntityService.isLocalUser(user) && !note.localOnly) { + const content = this.apRendererService.renderActivity(await this.apRendererService.renderLike(record, note)); + const dm = this.apDeliverManagerService.createDeliverManager(user, content); + if (note.userHost !== null) { + const reactee = await this.usersRepository.findOneBy({ id: note.userId }); + dm.addDirectRecipe(reactee as IRemoteUser); + } + + if (['public', 'home', 'followers'].includes(note.visibility)) { + dm.addFollowersRecipe(); + } else if (note.visibility === 'specified') { + const visibleUsers = await Promise.all(note.visibleUserIds.map(id => this.usersRepository.findOneBy({ id }))); + for (const u of visibleUsers.filter(u => u && this.userEntityService.isRemoteUser(u))) { + dm.addDirectRecipe(u as IRemoteUser); + } + } + + dm.execute(); + } + //#endregion + } + + public async delete(user: { id: User['id']; host: User['host']; }, note: Note) { + // if already unreacted + const exist = await this.noteReactionsRepository.findOneBy({ + noteId: note.id, + userId: user.id, + }); + + if (exist == null) { + throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted'); + } + + // Delete reaction + const result = await this.noteReactionsRepository.delete(exist.id); + + if (result.affected !== 1) { + throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted'); + } + + // Decrement reactions count + const sql = `jsonb_set("reactions", '{${exist.reaction}}', (COALESCE("reactions"->>'${exist.reaction}', '0')::int - 1)::text::jsonb)`; + await this.notesRepository.createQueryBuilder().update() + .set({ + reactions: () => sql, + }) + .where('id = :id', { id: note.id }) + .execute(); + + this.notesRepository.decrement({ id: note.id }, 'score', 1); + + this.globalEventServie.publishNoteStream(note.id, 'unreacted', { + reaction: this.decodeReaction(exist.reaction).reaction, + userId: user.id, + }); + + //#region 配信 + if (this.userEntityService.isLocalUser(user) && !note.localOnly) { + const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(await this.apRendererService.renderLike(exist, note), user)); + const dm = this.apDeliverManagerService.createDeliverManager(user, content); + if (note.userHost !== null) { + const reactee = await this.usersRepository.findOneBy({ id: note.userId }); + dm.addDirectRecipe(reactee as IRemoteUser); + } + dm.addFollowersRecipe(); + dm.execute(); + } + //#endregion + } + + public async getFallbackReaction(): Promise { + const meta = await this.metaService.fetch(); + return meta.useStarForReactionFallback ? '⭐' : '👍'; + } + + public convertLegacyReactions(reactions: Record) { + const _reactions = {} as Record; + + for (const reaction of Object.keys(reactions)) { + if (reactions[reaction] <= 0) continue; + + if (Object.keys(legacies).includes(reaction)) { + if (_reactions[legacies[reaction]]) { + _reactions[legacies[reaction]] += reactions[reaction]; + } else { + _reactions[legacies[reaction]] = reactions[reaction]; + } + } else { + if (_reactions[reaction]) { + _reactions[reaction] += reactions[reaction]; + } else { + _reactions[reaction] = reactions[reaction]; + } + } + } + + const _reactions2 = {} as Record; + + for (const reaction of Object.keys(_reactions)) { + _reactions2[this.decodeReaction(reaction).reaction] = _reactions[reaction]; + } + + return _reactions2; + } + + public async toDbReaction(reaction?: string | null, reacterHost?: string | null): Promise { + if (reaction == null) return await this.getFallbackReaction(); + + reacterHost = this.utilityService.toPunyNullable(reacterHost); + + // 文字列タイプのリアクションを絵文字に変換 + if (Object.keys(legacies).includes(reaction)) return legacies[reaction]; + + // Unicode絵文字 + const match = emojiRegex.exec(reaction); + if (match) { + // 合字を含む1つの絵文字 + const unicode = match[0]; + + // 異体字セレクタ除去 + return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, ''); + } + + const custom = reaction.match(/^:([\w+-]+)(?:@\.)?:$/); + if (custom) { + const name = custom[1]; + const emoji = await this.emojisRepository.findOneBy({ + host: reacterHost ?? IsNull(), + name, + }); + + if (emoji) return reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`; + } + + return await this.getFallbackReaction(); + } + + public decodeReaction(str: string): DecodedReaction { + const custom = str.match(/^:([\w+-]+)(?:@([\w.-]+))?:$/); + + if (custom) { + const name = custom[1]; + const host = custom[2] ?? null; + + return { + reaction: `:${name}@${host ?? '.'}:`, // ローカル分は@以降を省略するのではなく.にする + name, + host, + }; + } + + return { + reaction: str, + name: undefined, + host: undefined, + }; + } + + public convertLegacyReaction(reaction: string): string { + reaction = this.decodeReaction(reaction).reaction; + if (Object.keys(legacies).includes(reaction)) return legacies[reaction]; + return reaction; + } +} diff --git a/packages/backend/src/core/RelayService.ts b/packages/backend/src/core/RelayService.ts new file mode 100644 index 000000000..688ea03d3 --- /dev/null +++ b/packages/backend/src/core/RelayService.ts @@ -0,0 +1,119 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull } from 'typeorm'; +import type { ILocalUser, User } from '@/models/entities/User.js'; +import { RelaysRepository, UsersRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { Cache } from '@/misc/cache.js'; +import type { Relay } from '@/models/entities/Relay.js'; +import { QueueService } from '@/core/QueueService.js'; +import { CreateSystemUserService } from '@/core/CreateSystemUserService.js'; +import { ApRendererService } from '@/core/remote/activitypub/ApRendererService.js'; +import { DI } from '@/di-symbols.js'; + +const ACTOR_USERNAME = 'relay.actor' as const; + +@Injectable() +export class RelayService { + private relaysCache: Cache; + + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.relaysRepository) + private relaysRepository: RelaysRepository, + + private idService: IdService, + private queueService: QueueService, + private createSystemUserService: CreateSystemUserService, + private apRendererService: ApRendererService, + ) { + this.relaysCache = new Cache(1000 * 60 * 10); + } + + private async getRelayActor(): Promise { + const user = await this.usersRepository.findOneBy({ + host: IsNull(), + username: ACTOR_USERNAME, + }); + + if (user) return user as ILocalUser; + + const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME); + return created as ILocalUser; + } + + public async addRelay(inbox: string): Promise { + const relay = await this.relaysRepository.insert({ + id: this.idService.genId(), + inbox, + status: 'requesting', + }).then(x => this.relaysRepository.findOneByOrFail(x.identifiers[0])); + + const relayActor = await this.getRelayActor(); + const follow = await this.apRendererService.renderFollowRelay(relay, relayActor); + const activity = this.apRendererService.renderActivity(follow); + this.queueService.deliver(relayActor, activity, relay.inbox); + + return relay; + } + + public async removeRelay(inbox: string): Promise { + const relay = await this.relaysRepository.findOneBy({ + inbox, + }); + + if (relay == null) { + throw new Error('relay not found'); + } + + const relayActor = await this.getRelayActor(); + const follow = this.apRendererService.renderFollowRelay(relay, relayActor); + const undo = this.apRendererService.renderUndo(follow, relayActor); + const activity = this.apRendererService.renderActivity(undo); + this.queueService.deliver(relayActor, activity, relay.inbox); + + await this.relaysRepository.delete(relay.id); + } + + public async listRelay(): Promise { + const relays = await this.relaysRepository.find(); + return relays; + } + + public async relayAccepted(id: string): Promise { + const result = await this.relaysRepository.update(id, { + status: 'accepted', + }); + + return JSON.stringify(result); + } + + public async relayRejected(id: string): Promise { + const result = await this.relaysRepository.update(id, { + status: 'rejected', + }); + + return JSON.stringify(result); + } + + public async deliverToRelays(user: { id: User['id']; host: null; }, activity: any): Promise { + if (activity == null) return; + + const relays = await this.relaysCache.fetch(null, () => this.relaysRepository.findBy({ + status: 'accepted', + })); + if (relays.length === 0) return; + + // TODO + //const copy = structuredClone(activity); + const copy = JSON.parse(JSON.stringify(activity)); + if (!copy.to) copy.to = ['https://www.w3.org/ns/activitystreams#Public']; + + const signed = await this.apRendererService.attachLdSignature(copy, user); + + for (const relay of relays) { + this.queueService.deliver(user, signed, relay.inbox); + } + } +} diff --git a/packages/backend/src/core/S3Service.ts b/packages/backend/src/core/S3Service.ts new file mode 100644 index 000000000..9549e1999 --- /dev/null +++ b/packages/backend/src/core/S3Service.ts @@ -0,0 +1,38 @@ +import { URL } from 'node:url'; +import { Inject, Injectable } from '@nestjs/common'; +import S3 from 'aws-sdk/clients/s3.js'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import type { Meta } from '@/models/entities/Meta.js'; +import { HttpRequestService } from './HttpRequestService.js'; + +@Injectable() +export class S3Service { + constructor( + @Inject(DI.config) + private config: Config, + + private httpRequestService: HttpRequestService, + ) { + } + + public getS3(meta: Meta) { + const u = meta.objectStorageEndpoint != null + ? `${meta.objectStorageUseSSL ? 'https://' : 'http://'}${meta.objectStorageEndpoint}` + : `${meta.objectStorageUseSSL ? 'https://' : 'http://'}example.net`; + + return new S3({ + endpoint: meta.objectStorageEndpoint ?? undefined, + accessKeyId: meta.objectStorageAccessKey!, + secretAccessKey: meta.objectStorageSecretKey!, + region: meta.objectStorageRegion ?? undefined, + sslEnabled: meta.objectStorageUseSSL, + s3ForcePathStyle: !meta.objectStorageEndpoint // AWS with endPoint omitted + ? false + : meta.objectStorageS3ForcePathStyle, + httpOptions: { + agent: this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy), + }, + }); + } +} diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts new file mode 100644 index 000000000..a876668b9 --- /dev/null +++ b/packages/backend/src/core/SignupService.ts @@ -0,0 +1,141 @@ +import { generateKeyPair } from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import bcrypt from 'bcryptjs'; +import { DataSource, IsNull } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { UsedUsernamesRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import { User } from '@/models/entities/User.js'; +import { UserProfile } from '@/models/entities/UserProfile.js'; +import { IdService } from '@/core/IdService.js'; +import { UserKeypair } from '@/models/entities/UserKeypair.js'; +import { UsedUsername } from '@/models/entities/UsedUsername.js'; +import generateUserToken from '@/misc/generate-native-user-token.js'; +import UsersChart from './chart/charts/users.js'; +import { UserEntityService } from './entities/UserEntityService.js'; +import { UtilityService } from './UtilityService.js'; + +@Injectable() +export class SignupService { + constructor( + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.usedUsernamesRepository) + private usedUsernamesRepository: UsedUsernamesRepository, + + private utilityService: UtilityService, + private userEntityService: UserEntityService, + private idService: IdService, + private usersChart: UsersChart, + ) { + } + + public async signup(opts: { + username: User['username']; + password?: string | null; + passwordHash?: UserProfile['password'] | null; + host?: string | null; + }) { + const { username, password, passwordHash, host } = opts; + let hash = passwordHash; + + // Validate username + if (!this.userEntityService.validateLocalUsername(username)) { + throw new Error('INVALID_USERNAME'); + } + + if (password != null && passwordHash == null) { + // Validate password + if (!this.userEntityService.validatePassword(password)) { + throw new Error('INVALID_PASSWORD'); + } + + // Generate hash of password + const salt = await bcrypt.genSalt(8); + hash = await bcrypt.hash(password, salt); + } + + // Generate secret + const secret = generateUserToken(); + + // Check username duplication + if (await this.usersRepository.findOneBy({ usernameLower: username.toLowerCase(), host: IsNull() })) { + throw new Error('DUPLICATED_USERNAME'); + } + + // Check deleted username duplication + if (await this.usedUsernamesRepository.findOneBy({ username: username.toLowerCase() })) { + throw new Error('USED_USERNAME'); + } + + const keyPair = await new Promise((res, rej) => + generateKeyPair('rsa', { + modulusLength: 4096, + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + cipher: undefined, + passphrase: undefined, + }, + } as any, (err, publicKey, privateKey) => + err ? rej(err) : res([publicKey, privateKey]), + )); + + let account!: User; + + // Start transaction + await this.db.transaction(async transactionalEntityManager => { + const exist = await transactionalEntityManager.findOneBy(User, { + usernameLower: username.toLowerCase(), + host: IsNull(), + }); + + if (exist) throw new Error(' the username is already used'); + + account = await transactionalEntityManager.save(new User({ + id: this.idService.genId(), + createdAt: new Date(), + username: username, + usernameLower: username.toLowerCase(), + host: this.utilityService.toPunyNullable(host), + token: secret, + isAdmin: (await this.usersRepository.countBy({ + host: IsNull(), + })) === 0, + })); + + await transactionalEntityManager.save(new UserKeypair({ + publicKey: keyPair[0], + privateKey: keyPair[1], + userId: account.id, + })); + + await transactionalEntityManager.save(new UserProfile({ + userId: account.id, + autoAcceptFollowed: true, + password: hash, + })); + + await transactionalEntityManager.save(new UsedUsername({ + createdAt: new Date(), + username: username.toLowerCase(), + })); + }); + + this.usersChart.update(account, true); + + return { account, secret }; + } +} + diff --git a/packages/backend/src/core/TwoFactorAuthenticationService.ts b/packages/backend/src/core/TwoFactorAuthenticationService.ts new file mode 100644 index 000000000..be31534c0 --- /dev/null +++ b/packages/backend/src/core/TwoFactorAuthenticationService.ts @@ -0,0 +1,439 @@ +import * as crypto from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import * as jsrsasign from 'jsrsasign'; +import { DI } from '@/di-symbols.js'; +import { UsersRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; + +const ECC_PRELUDE = Buffer.from([0x04]); +const NULL_BYTE = Buffer.from([0]); +const PEM_PRELUDE = Buffer.from( + '3059301306072a8648ce3d020106082a8648ce3d030107034200', + 'hex', +); + +// Android Safetynet attestations are signed with this cert: +const GSR2 = `-----BEGIN CERTIFICATE----- +MIIDujCCAqKgAwIBAgILBAAAAAABD4Ym5g0wDQYJKoZIhvcNAQEFBQAwTDEgMB4G +A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjIxEzARBgNVBAoTCkdsb2JhbFNp +Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDYxMjE1MDgwMDAwWhcNMjExMjE1 +MDgwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMjETMBEG +A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAKbPJA6+Lm8omUVCxKs+IVSbC9N/hHD6ErPL +v4dfxn+G07IwXNb9rfF73OX4YJYJkhD10FPe+3t+c4isUoh7SqbKSaZeqKeMWhG8 +eoLrvozps6yWJQeXSpkqBy+0Hne/ig+1AnwblrjFuTosvNYSuetZfeLQBoZfXklq +tTleiDTsvHgMCJiEbKjNS7SgfQx5TfC4LcshytVsW33hoCmEofnTlEnLJGKRILzd +C9XZzPnqJworc5HGnRusyMvo4KD0L5CLTfuwNhv2GXqF4G3yYROIXJ/gkwpRl4pa +zq+r1feqCapgvdzZX99yqWATXgAByUr6P6TqBwMhAo6CygPCm48CAwEAAaOBnDCB +mTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUm+IH +V2ccHsBqBt5ZtJot39wZhi4wNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2NybC5n +bG9iYWxzaWduLm5ldC9yb290LXIyLmNybDAfBgNVHSMEGDAWgBSb4gdXZxwewGoG +3lm0mi3f3BmGLjANBgkqhkiG9w0BAQUFAAOCAQEAmYFThxxol4aR7OBKuEQLq4Gs +J0/WwbgcQ3izDJr86iw8bmEbTUsp9Z8FHSbBuOmDAGJFtqkIk7mpM0sYmsL4h4hO +291xNBrBVNpGP+DTKqttVCL1OmLNIG+6KYnX3ZHu01yiPqFbQfXf5WRDLenVOavS +ot+3i9DAgBkcRcAtjOj4LaR0VknFBbVPFd5uRHg5h6h+u/N5GJG79G+dwfCMNYxd +AfvDbbnvRG15RjF+Cv6pgsH/76tuIMRQyV+dTZsXjAzlAcmgQWpzU/qlULRuJQ/7 +TBj0/VLZjmmx6BEP3ojY+x1J96relc8geMJgEtslQIxq/H5COEBkEveegeGTLg== +-----END CERTIFICATE-----\n`; + +function base64URLDecode(source: string) { + return Buffer.from(source.replace(/\-/g, '+').replace(/_/g, '/'), 'base64'); +} + +function getCertSubject(certificate: string) { + const subjectCert = new jsrsasign.X509(); + subjectCert.readCertPEM(certificate); + + const subjectString = subjectCert.getSubjectString(); + const subjectFields = subjectString.slice(1).split('/'); + + const fields = {} as Record; + for (const field of subjectFields) { + const eqIndex = field.indexOf('='); + fields[field.substring(0, eqIndex)] = field.substring(eqIndex + 1); + } + + return fields; +} + +function verifyCertificateChain(certificates: string[]) { + let valid = true; + + for (let i = 0; i < certificates.length; i++) { + const Cert = certificates[i]; + const certificate = new jsrsasign.X509(); + certificate.readCertPEM(Cert); + + const CACert = i + 1 >= certificates.length ? Cert : certificates[i + 1]; + + const certStruct = jsrsasign.ASN1HEX.getTLVbyList(certificate.hex!, 0, [0]); + const algorithm = certificate.getSignatureAlgorithmField(); + const signatureHex = certificate.getSignatureValueHex(); + + // Verify against CA + const Signature = new jsrsasign.KJUR.crypto.Signature({ alg: algorithm }); + Signature.init(CACert); + Signature.updateHex(certStruct); + valid = valid && !!Signature.verify(signatureHex); // true if CA signed the certificate + } + + return valid; +} + +function PEMString(pemBuffer: Buffer, type = 'CERTIFICATE') { + if (pemBuffer.length === 65 && pemBuffer[0] === 0x04) { + pemBuffer = Buffer.concat([PEM_PRELUDE, pemBuffer], 91); + type = 'PUBLIC KEY'; + } + const cert = pemBuffer.toString('base64'); + + const keyParts = []; + const max = Math.ceil(cert.length / 64); + let start = 0; + for (let i = 0; i < max; i++) { + keyParts.push(cert.substring(start, start + 64)); + start += 64; + } + + return ( + `-----BEGIN ${type}-----\n` + + keyParts.join('\n') + + `\n-----END ${type}-----\n` + ); +} + +@Injectable() +export class TwoFactorAuthenticationService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + ) { + } + + public hash(data: Buffer) { + return crypto + .createHash('sha256') + .update(data) + .digest(); + } + + public verifySignin({ + publicKey, + authenticatorData, + clientDataJSON, + clientData, + signature, + challenge, + }: { + publicKey: Buffer, + authenticatorData: Buffer, + clientDataJSON: Buffer, + clientData: any, + signature: Buffer, + challenge: string + }) { + if (clientData.type !== 'webauthn.get') { + throw new Error('type is not webauthn.get'); + } + + if (this.hash(clientData.challenge).toString('hex') !== challenge) { + throw new Error('challenge mismatch'); + } + if (clientData.origin !== this.config.scheme + '://' + this.config.host) { + throw new Error('origin mismatch'); + } + + const verificationData = Buffer.concat( + [authenticatorData, this.hash(clientDataJSON)], + 32 + authenticatorData.length, + ); + + return crypto + .createVerify('SHA256') + .update(verificationData) + .verify(PEMString(publicKey), signature); + } + + public getProcedures() { + return { + none: { + verify({ publicKey }: { publicKey: Map }) { + const negTwo = publicKey.get(-2); + + if (!negTwo || negTwo.length !== 32) { + throw new Error('invalid or no -2 key given'); + } + const negThree = publicKey.get(-3); + if (!negThree || negThree.length !== 32) { + throw new Error('invalid or no -3 key given'); + } + + const publicKeyU2F = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32, + ); + + return { + publicKey: publicKeyU2F, + valid: true, + }; + }, + }, + 'android-key': { + verify({ + attStmt, + authenticatorData, + clientDataHash, + publicKey, + rpIdHash, + credentialId, + }: { + attStmt: any, + authenticatorData: Buffer, + clientDataHash: Buffer, + publicKey: Map; + rpIdHash: Buffer, + credentialId: Buffer, + }) { + if (attStmt.alg !== -7) { + throw new Error('alg mismatch'); + } + + const verificationData = Buffer.concat([ + authenticatorData, + clientDataHash, + ]); + + const attCert: Buffer = attStmt.x5c[0]; + + const negTwo = publicKey.get(-2); + + if (!negTwo || negTwo.length !== 32) { + throw new Error('invalid or no -2 key given'); + } + const negThree = publicKey.get(-3); + if (!negThree || negThree.length !== 32) { + throw new Error('invalid or no -3 key given'); + } + + const publicKeyData = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32, + ); + + if (!attCert.equals(publicKeyData)) { + throw new Error('public key mismatch'); + } + + const isValid = crypto + .createVerify('SHA256') + .update(verificationData) + .verify(PEMString(attCert), attStmt.sig); + + // TODO: Check 'attestationChallenge' field in extension of cert matches hash(clientDataJSON) + + return { + valid: isValid, + publicKey: publicKeyData, + }; + }, + }, + // what a stupid attestation + 'android-safetynet': { + verify: ({ + attStmt, + authenticatorData, + clientDataHash, + publicKey, + rpIdHash, + credentialId, + }: { + attStmt: any, + authenticatorData: Buffer, + clientDataHash: Buffer, + publicKey: Map; + rpIdHash: Buffer, + credentialId: Buffer, + }) => { + const verificationData = this.hash( + Buffer.concat([authenticatorData, clientDataHash]), + ); + + const jwsParts = attStmt.response.toString('utf-8').split('.'); + + const header = JSON.parse(base64URLDecode(jwsParts[0]).toString('utf-8')); + const response = JSON.parse( + base64URLDecode(jwsParts[1]).toString('utf-8'), + ); + const signature = jwsParts[2]; + + if (!verificationData.equals(Buffer.from(response.nonce, 'base64'))) { + throw new Error('invalid nonce'); + } + + const certificateChain = header.x5c + .map((key: any) => PEMString(key)) + .concat([GSR2]); + + if (getCertSubject(certificateChain[0]).CN !== 'attest.android.com') { + throw new Error('invalid common name'); + } + + if (!verifyCertificateChain(certificateChain)) { + throw new Error('Invalid certificate chain!'); + } + + const signatureBase = Buffer.from( + jwsParts[0] + '.' + jwsParts[1], + 'utf-8', + ); + + const valid = crypto + .createVerify('sha256') + .update(signatureBase) + .verify(certificateChain[0], base64URLDecode(signature)); + + const negTwo = publicKey.get(-2); + + if (!negTwo || negTwo.length !== 32) { + throw new Error('invalid or no -2 key given'); + } + const negThree = publicKey.get(-3); + if (!negThree || negThree.length !== 32) { + throw new Error('invalid or no -3 key given'); + } + + const publicKeyData = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32, + ); + return { + valid, + publicKey: publicKeyData, + }; + }, + }, + packed: { + verify({ + attStmt, + authenticatorData, + clientDataHash, + publicKey, + rpIdHash, + credentialId, + }: { + attStmt: any, + authenticatorData: Buffer, + clientDataHash: Buffer, + publicKey: Map; + rpIdHash: Buffer, + credentialId: Buffer, + }) { + const verificationData = Buffer.concat([ + authenticatorData, + clientDataHash, + ]); + + if (attStmt.x5c) { + const attCert = attStmt.x5c[0]; + + const validSignature = crypto + .createVerify('SHA256') + .update(verificationData) + .verify(PEMString(attCert), attStmt.sig); + + const negTwo = publicKey.get(-2); + + if (!negTwo || negTwo.length !== 32) { + throw new Error('invalid or no -2 key given'); + } + const negThree = publicKey.get(-3); + if (!negThree || negThree.length !== 32) { + throw new Error('invalid or no -3 key given'); + } + + const publicKeyData = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32, + ); + + return { + valid: validSignature, + publicKey: publicKeyData, + }; + } else if (attStmt.ecdaaKeyId) { + // https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-ecdaa-algorithm-v2.0-id-20180227.html#ecdaa-verify-operation + throw new Error('ECDAA-Verify is not supported'); + } else { + if (attStmt.alg !== -7) throw new Error('alg mismatch'); + + throw new Error('self attestation is not supported'); + } + }, + }, + + 'fido-u2f': { + verify({ + attStmt, + authenticatorData, + clientDataHash, + publicKey, + rpIdHash, + credentialId, + }: { + attStmt: any, + authenticatorData: Buffer, + clientDataHash: Buffer, + publicKey: Map, + rpIdHash: Buffer, + credentialId: Buffer + }) { + const x5c: Buffer[] = attStmt.x5c; + if (x5c.length !== 1) { + throw new Error('x5c length does not match expectation'); + } + + const attCert = x5c[0]; + + // TODO: make sure attCert is an Elliptic Curve (EC) public key over the P-256 curve + + const negTwo: Buffer = publicKey.get(-2); + + if (!negTwo || negTwo.length !== 32) { + throw new Error('invalid or no -2 key given'); + } + const negThree: Buffer = publicKey.get(-3); + if (!negThree || negThree.length !== 32) { + throw new Error('invalid or no -3 key given'); + } + + const publicKeyU2F = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32, + ); + + const verificationData = Buffer.concat([ + NULL_BYTE, + rpIdHash, + clientDataHash, + credentialId, + publicKeyU2F, + ]); + + const validSignature = crypto + .createVerify('SHA256') + .update(verificationData) + .verify(PEMString(attCert), attStmt.sig); + + return { + valid: validSignature, + publicKey: publicKeyU2F, + }; + }, + }, + }; + } +} diff --git a/packages/backend/src/core/UserBlockingService.ts b/packages/backend/src/core/UserBlockingService.ts new file mode 100644 index 000000000..9efb021f6 --- /dev/null +++ b/packages/backend/src/core/UserBlockingService.ts @@ -0,0 +1,199 @@ + +import { Inject, Injectable } from '@nestjs/common'; +import { IdService } from '@/core/IdService.js'; +import type { CacheableUser, User } from '@/models/entities/User.js'; +import type { Blocking } from '@/models/entities/Blocking.js'; +import { QueueService } from '@/core/QueueService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; +import { DI } from '@/di-symbols.js'; +import { UserEntityService } from './entities/UserEntityService.js'; +import { WebhookService } from './WebhookService.js'; +import { ApRendererService } from './remote/activitypub/ApRendererService.js'; + +@Injectable() +export class UserBlockingService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.followRequestsRepository) + private followRequestsRepository: FollowRequestsRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.userListsRepository) + private userListsRepository: UserListsRepository, + + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, + + private userEntityService: UserEntityService, + private idService: IdService, + private queueService: QueueService, + private globalEventServie: GlobalEventService, + private webhookService: WebhookService, + private apRendererService: ApRendererService, + private perUserFollowingChart: PerUserFollowingChart, + ) { + } + + public async block(blocker: User, blockee: User) { + await Promise.all([ + this.cancelRequest(blocker, blockee), + this.cancelRequest(blockee, blocker), + this.unFollow(blocker, blockee), + this.unFollow(blockee, blocker), + this.removeFromList(blockee, blocker), + ]); + + const blocking = { + id: this.idService.genId(), + createdAt: new Date(), + blocker, + blockerId: blocker.id, + blockee, + blockeeId: blockee.id, + } as Blocking; + + await this.blockingsRepository.insert(blocking); + + if (this.userEntityService.isLocalUser(blocker) && this.userEntityService.isRemoteUser(blockee)) { + const content = this.apRendererService.renderActivity(this.apRendererService.renderBlock(blocking)); + this.queueService.deliver(blocker, content, blockee.inbox); + } + } + + private async cancelRequest(follower: User, followee: User) { + const request = await this.followRequestsRepository.findOneBy({ + followeeId: followee.id, + followerId: follower.id, + }); + + if (request == null) { + return; + } + + await this.followRequestsRepository.delete({ + followeeId: followee.id, + followerId: follower.id, + }); + + if (this.userEntityService.isLocalUser(followee)) { + this.userEntityService.pack(followee, followee, { + detail: true, + }).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed)); + } + + if (this.userEntityService.isLocalUser(follower)) { + this.userEntityService.pack(followee, follower, { + detail: true, + }).then(async packed => { + this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packed); + this.globalEventServie.publishMainStream(follower.id, 'unfollow', packed); + + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); + for (const webhook of webhooks) { + this.queueService.webhookDeliver(webhook, 'unfollow', { + user: packed, + }); + } + }); + } + + // リモートにフォローリクエストをしていたらUndoFollow送信 + if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { + const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); + this.queueService.deliver(follower, content, followee.inbox); + } + + // リモートからフォローリクエストを受けていたらReject送信 + if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { + const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, request.requestId!), followee)); + this.queueService.deliver(followee, content, follower.inbox); + } + } + + private async unFollow(follower: User, followee: User) { + const following = await this.followingsRepository.findOneBy({ + followerId: follower.id, + followeeId: followee.id, + }); + + if (following == null) { + return; + } + + await Promise.all([ + this.followingsRepository.delete(following.id), + this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1), + this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1), + this.perUserFollowingChart.update(follower, followee, false), + ]); + + // Publish unfollow event + if (this.userEntityService.isLocalUser(follower)) { + this.userEntityService.pack(followee, follower, { + detail: true, + }).then(async packed => { + this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packed); + this.globalEventServie.publishMainStream(follower.id, 'unfollow', packed); + + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); + for (const webhook of webhooks) { + this.queueService.webhookDeliver(webhook, 'unfollow', { + user: packed, + }); + } + }); + } + + // リモートにフォローをしていたらUndoFollow送信 + if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { + const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); + this.queueService.deliver(follower, content, followee.inbox); + } + } + + private async removeFromList(listOwner: User, user: User) { + const userLists = await this.userListsRepository.findBy({ + userId: listOwner.id, + }); + + for (const userList of userLists) { + await this.userListJoiningsRepository.delete({ + userListId: userList.id, + userId: user.id, + }); + } + } + + public async unblock(blocker: CacheableUser, blockee: CacheableUser) { + const blocking = await this.blockingsRepository.findOneBy({ + blockerId: blocker.id, + blockeeId: blockee.id, + }); + + if (blocking == null) { + logger.warn('ブロック解除がリクエストされましたがブロックしていませんでした'); + return; + } + + // Since we already have the blocker and blockee, we do not need to fetch + // them in the query above and can just manually insert them here. + blocking.blocker = blocker; + blocking.blockee = blockee; + + await this.blockingsRepository.delete(blocking.id); + + // deliver if remote bloking + if (this.userEntityService.isLocalUser(blocker) && this.userEntityService.isRemoteUser(blockee)) { + const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderBlock(blocking), blocker)); + this.queueService.deliver(blocker, content, blockee.inbox); + } + } +} diff --git a/packages/backend/src/core/UserCacheService.ts b/packages/backend/src/core/UserCacheService.ts new file mode 100644 index 000000000..8212abf7b --- /dev/null +++ b/packages/backend/src/core/UserCacheService.ts @@ -0,0 +1,74 @@ +import { Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import { UsersRepository } from '@/models/index.js'; +import { Cache } from '@/misc/cache.js'; +import type { CacheableLocalUser, CacheableUser, ILocalUser } from '@/models/entities/User.js'; +import { DI } from '@/di-symbols.js'; +import { UserEntityService } from './entities/UserEntityService.js'; +import type { OnApplicationShutdown } from '@nestjs/common'; + +@Injectable() +export class UserCacheService implements OnApplicationShutdown { + public userByIdCache: Cache; + public localUserByNativeTokenCache: Cache; + public localUserByIdCache: Cache; + public uriPersonCache: Cache; + + constructor( + @Inject(DI.redisSubscriber) + private redisSubscriber: Redis.Redis, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private userEntityService: UserEntityService, + ) { + this.onMessage = this.onMessage.bind(this); + + this.userByIdCache = new Cache(Infinity); + this.localUserByNativeTokenCache = new Cache(Infinity); + this.localUserByIdCache = new Cache(Infinity); + this.uriPersonCache = new Cache(Infinity); + + this.redisSubscriber.on('message', this.onMessage); + } + + private async onMessage(_, data) { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message; + switch (type) { + case 'userChangeSuspendedState': + case 'userChangeSilencedState': + case 'userChangeModeratorState': + case 'remoteUserUpdated': { + const user = await this.usersRepository.findOneByOrFail({ id: body.id }); + this.userByIdCache.set(user.id, user); + for (const [k, v] of this.uriPersonCache.cache.entries()) { + if (v.value?.id === user.id) { + this.uriPersonCache.set(k, user); + } + } + if (this.userEntityService.isLocalUser(user)) { + this.localUserByNativeTokenCache.set(user.token, user); + this.localUserByIdCache.set(user.id, user); + } + break; + } + case 'userTokenRegenerated': { + const user = await this.usersRepository.findOneByOrFail({ id: body.id }) as ILocalUser; + this.localUserByNativeTokenCache.delete(body.oldToken); + this.localUserByNativeTokenCache.set(body.newToken, user); + break; + } + default: + break; + } + } + } + + public onApplicationShutdown(signal?: string | undefined) { + this.redisSubscriber.off('message', this.onMessage); + } +} diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts new file mode 100644 index 000000000..ff86d4343 --- /dev/null +++ b/packages/backend/src/core/UserFollowingService.ts @@ -0,0 +1,574 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { CacheableUser, ILocalUser, IRemoteUser, User } from '@/models/entities/User.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { QueueService } from '@/core/QueueService.js'; +import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { IdService } from '@/core/IdService.js'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; +import type { Packed } from '@/misc/schema.js'; +import InstanceChart from '@/core/chart/charts/instance.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; +import { WebhookService } from '@/core/WebhookService.js'; +import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +import { DI } from '@/di-symbols.js'; +import Logger from '../logger.js'; +import { UserEntityService } from './entities/UserEntityService.js'; +import { ApRendererService } from './remote/activitypub/ApRendererService.js'; + +const logger = new Logger('following/create'); + +type Local = ILocalUser | { + id: ILocalUser['id']; + host: ILocalUser['host']; + uri: ILocalUser['uri'] +}; +type Remote = IRemoteUser | { + id: IRemoteUser['id']; + host: IRemoteUser['host']; + uri: IRemoteUser['uri']; + inbox: IRemoteUser['inbox']; +}; +type Both = Local | Remote; + +@Injectable() +export class UserFollowingService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.followRequestsRepository) + private followRequestsRepository: FollowRequestsRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + private userEntityService: UserEntityService, + private idService: IdService, + private queueService: QueueService, + private globalEventServie: GlobalEventService, + private createNotificationService: CreateNotificationService, + private federatedInstanceService: FederatedInstanceService, + private webhookService: WebhookService, + private apRendererService: ApRendererService, + private perUserFollowingChart: PerUserFollowingChart, + private instanceChart: InstanceChart, + ) { + } + + public async follow(_follower: { id: User['id'] }, _followee: { id: User['id'] }, requestId?: string): Promise { + const [follower, followee] = await Promise.all([ + this.usersRepository.findOneByOrFail({ id: _follower.id }), + this.usersRepository.findOneByOrFail({ id: _followee.id }), + ]); + + // check blocking + const [blocking, blocked] = await Promise.all([ + this.blockingsRepository.findOneBy({ + blockerId: follower.id, + blockeeId: followee.id, + }), + this.blockingsRepository.findOneBy({ + blockerId: followee.id, + blockeeId: follower.id, + }), + ]); + + if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee) && blocked) { + // リモートフォローを受けてブロックしていた場合は、エラーにするのではなくRejectを送り返しておしまい。 + const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, requestId), followee)); + this.queueService.deliver(followee, content, follower.inbox); + return; + } else if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee) && blocking) { + // リモートフォローを受けてブロックされているはずの場合だったら、ブロック解除しておく。 + await this.blockingsRepository.delete(blocking.id); + } else { + // それ以外は単純に例外 + if (blocking != null) throw new IdentifiableError('710e8fb0-b8c3-4922-be49-d5d93d8e6a6e', 'blocking'); + if (blocked != null) throw new IdentifiableError('3338392a-f764-498d-8855-db939dcf8c48', 'blocked'); + } + + const followeeProfile = await this.userProfilesRepository.findOneByOrFail({ userId: followee.id }); + + // フォロー対象が鍵アカウントである or + // フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or + // フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである + // 上記のいずれかに当てはまる場合はすぐフォローせずにフォローリクエストを発行しておく + if (followee.isLocked || (followeeProfile.carefulBot && follower.isBot) || (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee))) { + let autoAccept = false; + + // 鍵アカウントであっても、既にフォローされていた場合はスルー + const following = await this.followingsRepository.findOneBy({ + followerId: follower.id, + followeeId: followee.id, + }); + if (following) { + autoAccept = true; + } + + // フォローしているユーザーは自動承認オプション + if (!autoAccept && (this.userEntityService.isLocalUser(followee) && followeeProfile.autoAcceptFollowed)) { + const followed = await this.followingsRepository.findOneBy({ + followerId: followee.id, + followeeId: follower.id, + }); + + if (followed) autoAccept = true; + } + + if (!autoAccept) { + await this.createFollowRequest(follower, followee, requestId); + return; + } + } + + await this.insertFollowingDoc(followee, follower); + + if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { + const content = this.apRendererService.renderActivity(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee)); + this.queueService.deliver(followee, content, follower.inbox); + } + } + + private async insertFollowingDoc( + followee: { + id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'] + }, + follower: { + id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'] + }, + ): Promise { + if (follower.id === followee.id) return; + + let alreadyFollowed = false as boolean; + + await this.followingsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + followerId: follower.id, + followeeId: followee.id, + + // 非正規化 + followerHost: follower.host, + followerInbox: this.userEntityService.isRemoteUser(follower) ? follower.inbox : null, + followerSharedInbox: this.userEntityService.isRemoteUser(follower) ? follower.sharedInbox : null, + followeeHost: followee.host, + followeeInbox: this.userEntityService.isRemoteUser(followee) ? followee.inbox : null, + followeeSharedInbox: this.userEntityService.isRemoteUser(followee) ? followee.sharedInbox : null, + }).catch(err => { + if (isDuplicateKeyValueError(err) && this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { + logger.info(`Insert duplicated ignore. ${follower.id} => ${followee.id}`); + alreadyFollowed = true; + } else { + throw err; + } + }); + + const req = await this.followRequestsRepository.findOneBy({ + followeeId: followee.id, + followerId: follower.id, + }); + + if (req) { + await this.followRequestsRepository.delete({ + followeeId: followee.id, + followerId: follower.id, + }); + + // 通知を作成 + this.createNotificationService.createNotification(follower.id, 'followRequestAccepted', { + notifierId: followee.id, + }); + } + + if (alreadyFollowed) return; + + //#region Increment counts + await Promise.all([ + this.usersRepository.increment({ id: follower.id }, 'followingCount', 1), + this.usersRepository.increment({ id: followee.id }, 'followersCount', 1), + ]); + //#endregion + + //#region Update instance stats + if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { + this.federatedInstanceService.registerOrFetchInstanceDoc(follower.host).then(i => { + this.instancesRepository.increment({ id: i.id }, 'followingCount', 1); + this.instanceChart.updateFollowing(i.host, true); + }); + } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { + this.federatedInstanceService.registerOrFetchInstanceDoc(followee.host).then(i => { + this.instancesRepository.increment({ id: i.id }, 'followersCount', 1); + this.instanceChart.updateFollowers(i.host, true); + }); + } + //#endregion + + this.perUserFollowingChart.update(follower, followee, true); + + // Publish follow event + if (this.userEntityService.isLocalUser(follower)) { + this.userEntityService.pack(followee.id, follower, { + detail: true, + }).then(async packed => { + this.globalEventServie.publishUserEvent(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>); + this.globalEventServie.publishMainStream(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>); + + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('follow')); + for (const webhook of webhooks) { + this.queueService.webhookDeliver(webhook, 'follow', { + user: packed, + }); + } + }); + } + + // Publish followed event + if (this.userEntityService.isLocalUser(followee)) { + this.userEntityService.pack(follower.id, followee).then(async packed => { + this.globalEventServie.publishMainStream(followee.id, 'followed', packed); + + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === followee.id && x.on.includes('followed')); + for (const webhook of webhooks) { + this.queueService.webhookDeliver(webhook, 'followed', { + user: packed, + }); + } + }); + + // 通知を作成 + this.createNotificationService.createNotification(followee.id, 'follow', { + notifierId: follower.id, + }); + } + } + + public async unfollow( + follower: { + id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; + }, + followee: { + id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; + }, + silent = false, + ): Promise { + const following = await this.followingsRepository.findOneBy({ + followerId: follower.id, + followeeId: followee.id, + }); + + if (following == null) { + logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした'); + return; + } + + await this.followingsRepository.delete(following.id); + + this.decrementFollowing(follower, followee); + + // Publish unfollow event + if (!silent && this.userEntityService.isLocalUser(follower)) { + this.userEntityService.pack(followee.id, follower, { + detail: true, + }).then(async packed => { + this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packed); + this.globalEventServie.publishMainStream(follower.id, 'unfollow', packed); + + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); + for (const webhook of webhooks) { + this.queueService.webhookDeliver(webhook, 'unfollow', { + user: packed, + }); + } + }); + } + + if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { + const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); + this.queueService.deliver(follower, content, followee.inbox); + } + + if (this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower)) { + // local user has null host + const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee), followee)); + this.queueService.deliver(followee, content, follower.inbox); + } + } + + private async decrementFollowing( + follower: {id: User['id']; host: User['host']; }, + followee: { id: User['id']; host: User['host']; }, + ): Promise { + //#region Decrement following / followers counts + await Promise.all([ + this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1), + this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1), + ]); + //#endregion + + //#region Update instance stats + if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { + this.federatedInstanceService.registerOrFetchInstanceDoc(follower.host).then(i => { + this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1); + this.instanceChart.updateFollowing(i.host, false); + }); + } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { + this.federatedInstanceService.registerOrFetchInstanceDoc(followee.host).then(i => { + this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1); + this.instanceChart.updateFollowers(i.host, false); + }); + } + //#endregion + + this.perUserFollowingChart.update(follower, followee, false); + } + + public async createFollowRequest( + follower: { + id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; + }, + followee: { + id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; + }, + requestId?: string, + ): Promise { + if (follower.id === followee.id) return; + + // check blocking + const [blocking, blocked] = await Promise.all([ + this.blockingsRepository.findOneBy({ + blockerId: follower.id, + blockeeId: followee.id, + }), + this.blockingsRepository.findOneBy({ + blockerId: followee.id, + blockeeId: follower.id, + }), + ]); + + if (blocking != null) throw new Error('blocking'); + if (blocked != null) throw new Error('blocked'); + + const followRequest = await this.followRequestsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + followerId: follower.id, + followeeId: followee.id, + requestId, + + // 非正規化 + followerHost: follower.host, + followerInbox: this.userEntityService.isRemoteUser(follower) ? follower.inbox : undefined, + followerSharedInbox: this.userEntityService.isRemoteUser(follower) ? follower.sharedInbox : undefined, + followeeHost: followee.host, + followeeInbox: this.userEntityService.isRemoteUser(followee) ? followee.inbox : undefined, + followeeSharedInbox: this.userEntityService.isRemoteUser(followee) ? followee.sharedInbox : undefined, + }).then(x => this.followRequestsRepository.findOneByOrFail(x.identifiers[0])); + + // Publish receiveRequest event + if (this.userEntityService.isLocalUser(followee)) { + this.userEntityService.pack(follower.id, followee).then(packed => this.globalEventServie.publishMainStream(followee.id, 'receiveFollowRequest', packed)); + + this.userEntityService.pack(followee.id, followee, { + detail: true, + }).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed)); + + // 通知を作成 + this.createNotificationService.createNotification(followee.id, 'receiveFollowRequest', { + notifierId: follower.id, + followRequestId: followRequest.id, + }); + } + + if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { + const content = this.apRendererService.renderActivity(this.apRendererService.renderFollow(follower, followee)); + this.queueService.deliver(follower, content, followee.inbox); + } + } + + public async cancelFollowRequest( + followee: { + id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox'] + }, + follower: { + id: User['id']; host: User['host']; uri: User['host'] + }, + ): Promise { + if (this.userEntityService.isRemoteUser(followee)) { + const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); + + if (this.userEntityService.isLocalUser(follower)) { // 本来このチェックは不要だけどTSに怒られるので + this.queueService.deliver(follower, content, followee.inbox); + } + } + + const request = await this.followRequestsRepository.findOneBy({ + followeeId: followee.id, + followerId: follower.id, + }); + + if (request == null) { + throw new IdentifiableError('17447091-ce07-46dd-b331-c1fd4f15b1e7', 'request not found'); + } + + await this.followRequestsRepository.delete({ + followeeId: followee.id, + followerId: follower.id, + }); + + this.userEntityService.pack(followee.id, followee, { + detail: true, + }).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed)); + } + + public async acceptFollowRequest( + followee: { + id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; + }, + follower: CacheableUser, + ): Promise { + const request = await this.followRequestsRepository.findOneBy({ + followeeId: followee.id, + followerId: follower.id, + }); + + if (request == null) { + throw new IdentifiableError('8884c2dd-5795-4ac9-b27e-6a01d38190f9', 'No follow request.'); + } + + await this.insertFollowingDoc(followee, follower); + + if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { + const content = this.apRendererService.renderActivity(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, request.requestId!), followee)); + this.queueService.deliver(followee, content, follower.inbox); + } + + this.userEntityService.pack(followee.id, followee, { + detail: true, + }).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed)); + } + + public async acceptAllFollowRequests( + user: { + id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; + }, + ): Promise { + const requests = await this.followRequestsRepository.findBy({ + followeeId: user.id, + }); + + for (const request of requests) { + const follower = await this.usersRepository.findOneByOrFail({ id: request.followerId }); + this.acceptFollowRequest(user, follower); + } + } + + /** + * API following/request/reject + */ + public async rejectFollowRequest(user: Local, follower: Both): Promise { + if (this.userEntityService.isRemoteUser(follower)) { + this.deliverReject(user, follower); + } + + await this.removeFollowRequest(user, follower); + + if (this.userEntityService.isLocalUser(follower)) { + this.publishUnfollow(user, follower); + } + } + + /** + * API following/reject + */ + public async rejectFollow(user: Local, follower: Both): Promise { + if (this.userEntityService.isRemoteUser(follower)) { + this.deliverReject(user, follower); + } + + await this.removeFollow(user, follower); + + if (this.userEntityService.isLocalUser(follower)) { + this.publishUnfollow(user, follower); + } + } + + /** + * AP Reject/Follow + */ + public async remoteReject(actor: Remote, follower: Local): Promise { + await this.removeFollowRequest(actor, follower); + await this.removeFollow(actor, follower); + this.publishUnfollow(actor, follower); + } + + /** + * Remove follow request record + */ + private async removeFollowRequest(followee: Both, follower: Both): Promise { + const request = await this.followRequestsRepository.findOneBy({ + followeeId: followee.id, + followerId: follower.id, + }); + + if (!request) return; + + await this.followRequestsRepository.delete(request.id); + } + + /** + * Remove follow record + */ + private async removeFollow(followee: Both, follower: Both): Promise { + const following = await this.followingsRepository.findOneBy({ + followeeId: followee.id, + followerId: follower.id, + }); + + if (!following) return; + + await this.followingsRepository.delete(following.id); + this.decrementFollowing(follower, followee); + } + + /** + * Deliver Reject to remote + */ + private async deliverReject(followee: Local, follower: Remote): Promise { + const request = await this.followRequestsRepository.findOneBy({ + followeeId: followee.id, + followerId: follower.id, + }); + + const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, request?.requestId ?? undefined), followee)); + this.queueService.deliver(followee, content, follower.inbox); + } + + /** + * Publish unfollow to local + */ + private async publishUnfollow(followee: Both, follower: Local): Promise { + const packedFollowee = await this.userEntityService.pack(followee.id, follower, { + detail: true, + }); + + this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packedFollowee); + this.globalEventServie.publishMainStream(follower.id, 'unfollow', packedFollowee); + + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); + for (const webhook of webhooks) { + this.queueService.webhookDeliver(webhook, 'unfollow', { + user: packedFollowee, + }); + } + } +} diff --git a/packages/backend/src/core/UserKeypairStoreService.ts b/packages/backend/src/core/UserKeypairStoreService.ts new file mode 100644 index 000000000..e53f37b71 --- /dev/null +++ b/packages/backend/src/core/UserKeypairStoreService.ts @@ -0,0 +1,22 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { User } from '@/models/entities/User.js'; +import { UserKeypairsRepository } from '@/models/index.js'; +import { Cache } from '@/misc/cache.js'; +import type { UserKeypair } from '@/models/entities/UserKeypair.js'; +import { DI } from '@/di-symbols.js'; + +@Injectable() +export class UserKeypairStoreService { + private cache: Cache; + + constructor( + @Inject(DI.userKeypairsRepository) + private userKeypairsRepository: UserKeypairsRepository, + ) { + this.cache = new Cache(Infinity); + } + + public async getUserKeypair(userId: User['id']): Promise { + return await this.cache.fetch(userId, () => this.userKeypairsRepository.findOneByOrFail({ userId: userId })); + } +} diff --git a/packages/backend/src/core/UserListService.ts b/packages/backend/src/core/UserListService.ts new file mode 100644 index 000000000..03113f042 --- /dev/null +++ b/packages/backend/src/core/UserListService.ts @@ -0,0 +1,48 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { UserListJoiningsRepository, UsersRepository } from '@/models/index.js'; +import type { User } from '@/models/entities/User.js'; +import type { UserList } from '@/models/entities/UserList.js'; +import type { UserListJoining } from '@/models/entities/UserListJoining.js'; +import { IdService } from '@/core/IdService.js'; +import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; +import { UserEntityService } from './entities/UserEntityService.js'; +import { ProxyAccountService } from './ProxyAccountService.js'; + +@Injectable() +export class UserListService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, + + private userEntityService: UserEntityService, + private idService: IdService, + private userFollowingService: UserFollowingService, + private globalEventServie: GlobalEventService, + private proxyAccountService: ProxyAccountService, + ) { + } + + public async push(target: User, list: UserList) { + await this.userListJoiningsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: target.id, + userListId: list.id, + } as UserListJoining); + + this.globalEventServie.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target)); + + // このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする + if (this.userEntityService.isRemoteUser(target)) { + const proxy = await this.proxyAccountService.fetch(); + if (proxy) { + this.userFollowingService.follow(proxy, target); + } + } + } +} diff --git a/packages/backend/src/core/UserMutingService.ts b/packages/backend/src/core/UserMutingService.ts new file mode 100644 index 000000000..9146360df --- /dev/null +++ b/packages/backend/src/core/UserMutingService.ts @@ -0,0 +1,32 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { UsersRepository, MutingsRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { QueueService } from '@/core/QueueService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import type { User } from '@/models/entities/User.js'; +import { DI } from '@/di-symbols.js'; + +@Injectable() +export class UserMutingService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + private idService: IdService, + private queueService: QueueService, + private globalEventServie: GlobalEventService, + ) { + } + + public async mute(user: User, target: User): Promise { + await this.mutingsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + muterId: user.id, + muteeId: target.id, + }); + } +} diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts new file mode 100644 index 000000000..068341cb2 --- /dev/null +++ b/packages/backend/src/core/UserSuspendService.ts @@ -0,0 +1,88 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Not, IsNull } from 'typeorm'; +import { FollowingsRepository, UsersRepository } from '@/models/index.js'; +import type { User } from '@/models/entities/User.js'; +import { QueueService } from '@/core/QueueService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import { ApRendererService } from './remote/activitypub/ApRendererService.js'; +import { UserEntityService } from './entities/UserEntityService.js'; + +@Injectable() +export class UserSuspendService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + private userEntityService: UserEntityService, + private queueService: QueueService, + private globalEventService: GlobalEventService, + private apRendererService: ApRendererService, + ) { + } + + public async doPostSuspend(user: { id: User['id']; host: User['host'] }): Promise { + this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true }); + + if (this.userEntityService.isLocalUser(user)) { + // 知り得る全SharedInboxにDelete配信 + const content = this.apRendererService.renderActivity(this.apRendererService.renderDelete(`${this.config.url}/users/${user.id}`, user)); + + const queue: string[] = []; + + const followings = await this.followingsRepository.find({ + where: [ + { followerSharedInbox: Not(IsNull()) }, + { followeeSharedInbox: Not(IsNull()) }, + ], + select: ['followerSharedInbox', 'followeeSharedInbox'], + }); + + const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox); + + for (const inbox of inboxes) { + if (inbox != null && !queue.includes(inbox)) queue.push(inbox); + } + + for (const inbox of queue) { + this.queueService.deliver(user, content, inbox); + } + } + } + + public async doPostUnsuspend(user: User): Promise { + this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false }); + + if (this.userEntityService.isLocalUser(user)) { + // 知り得る全SharedInboxにUndo Delete配信 + const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderDelete(`${this.config.url}/users/${user.id}`, user), user)); + + const queue: string[] = []; + + const followings = await this.followingsRepository.find({ + where: [ + { followerSharedInbox: Not(IsNull()) }, + { followeeSharedInbox: Not(IsNull()) }, + ], + select: ['followerSharedInbox', 'followeeSharedInbox'], + }); + + const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox); + + for (const inbox of inboxes) { + if (inbox != null && !queue.includes(inbox)) queue.push(inbox); + } + + for (const inbox of queue) { + this.queueService.deliver(user as any, content, inbox); + } + } + } +} diff --git a/packages/backend/src/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts new file mode 100644 index 000000000..ba03dfc06 --- /dev/null +++ b/packages/backend/src/core/UtilityService.ts @@ -0,0 +1,37 @@ +import { URL } from 'node:url'; +import { toASCII } from 'punycode'; +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; + +@Injectable() +export class UtilityService { + constructor( + @Inject(DI.config) + private config: Config, + ) { + } + + public getFullApAccount(username: string, host: string | null): string { + return host ? `${username}@${this.toPuny(host)}` : `${username}@${this.toPuny(this.config.host)}`; + } + + public isSelfHost(host: string | null): boolean { + if (host == null) return true; + return this.toPuny(this.config.host) === this.toPuny(host); + } + + public extractDbHost(uri: string): string { + const url = new URL(uri); + return this.toPuny(url.hostname); + } + + public toPuny(host: string): string { + return toASCII(host.toLowerCase()); + } + + public toPunyNullable(host: string | null | undefined): string | null { + if (host == null) return null; + return toASCII(host.toLowerCase()); + } +} diff --git a/packages/backend/src/core/VideoProcessingService.ts b/packages/backend/src/core/VideoProcessingService.ts new file mode 100644 index 000000000..70b9664c7 --- /dev/null +++ b/packages/backend/src/core/VideoProcessingService.ts @@ -0,0 +1,44 @@ +import { Inject, Injectable } from '@nestjs/common'; +import FFmpeg from 'fluent-ffmpeg'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import { ImageProcessingService } from '@/core/ImageProcessingService.js'; +import type { IImage } from '@/core/ImageProcessingService.js'; +import { createTempDir } from '@/misc/create-temp.js'; + +@Injectable() +export class VideoProcessingService { + constructor( + @Inject(DI.config) + private config: Config, + + private imageProcessingService: ImageProcessingService, + ) { + } + + public async generateVideoThumbnail(source: string): Promise { + const [dir, cleanup] = await createTempDir(); + + try { + await new Promise((res, rej) => { + FFmpeg({ + source, + }) + .on('end', res) + .on('error', rej) + .screenshot({ + folder: dir, + filename: 'out.png', // must have .png extension + count: 1, + timestamps: ['5%'], + }); + }); + + // JPEGに変換 (Webpでもいいが、MastodonはWebpをサポートせず表示できなくなる) + return await this.imageProcessingService.convertToJpeg(`${dir}/out.png`, 498, 280); + } finally { + cleanup(); + } + } +} + diff --git a/packages/backend/src/core/WebhookService.ts b/packages/backend/src/core/WebhookService.ts new file mode 100644 index 000000000..1d74290dd --- /dev/null +++ b/packages/backend/src/core/WebhookService.ts @@ -0,0 +1,70 @@ +import { Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import { WebhooksRepository } from '@/models/index.js'; +import type { Webhook } from '@/models/entities/Webhook.js'; +import { DI } from '@/di-symbols.js'; +import type { OnApplicationShutdown } from '@nestjs/common'; + +@Injectable() +export class WebhookService implements OnApplicationShutdown { + private webhooksFetched = false; + private webhooks: Webhook[] = []; + + constructor( + @Inject(DI.redisSubscriber) + private redisSubscriber: Redis.Redis, + + @Inject(DI.webhooksRepository) + private webhooksRepository: WebhooksRepository, + ) { + this.onMessage = this.onMessage.bind(this); + this.redisSubscriber.on('message', this.onMessage); + } + + public async getActiveWebhooks() { + if (!this.webhooksFetched) { + this.webhooks = await this.webhooksRepository.findBy({ + active: true, + }); + this.webhooksFetched = true; + } + + return this.webhooks; + } + + private async onMessage(_, data) { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message; + switch (type) { + case 'webhookCreated': + if (body.active) { + this.webhooks.push(body); + } + break; + case 'webhookUpdated': + if (body.active) { + const i = this.webhooks.findIndex(a => a.id === body.id); + if (i > -1) { + this.webhooks[i] = body; + } else { + this.webhooks.push(body); + } + } else { + this.webhooks = this.webhooks.filter(a => a.id !== body.id); + } + break; + case 'webhookDeleted': + this.webhooks = this.webhooks.filter(a => a.id !== body.id); + break; + default: + break; + } + } + } + + public onApplicationShutdown(signal?: string | undefined) { + this.redisSubscriber.off('message', this.onMessage); + } +} diff --git a/packages/backend/src/core/chart/ChartLoggerService.ts b/packages/backend/src/core/chart/ChartLoggerService.ts new file mode 100644 index 000000000..544a006ac --- /dev/null +++ b/packages/backend/src/core/chart/ChartLoggerService.ts @@ -0,0 +1,14 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type Logger from '@/logger.js'; +import { LoggerService } from '@/core/LoggerService.js'; + +@Injectable() +export class ChartLoggerService { + public logger: Logger; + + constructor( + private loggerService: LoggerService, + ) { + this.logger = this.loggerService.getLogger('chart', 'white', process.env.NODE_ENV !== 'test'); + } +} diff --git a/packages/backend/src/core/chart/ChartManagementService.ts b/packages/backend/src/core/chart/ChartManagementService.ts new file mode 100644 index 000000000..6476cd684 --- /dev/null +++ b/packages/backend/src/core/chart/ChartManagementService.ts @@ -0,0 +1,67 @@ +import { Injectable, Inject } from '@nestjs/common'; + +import FederationChart from './charts/federation.js'; +import NotesChart from './charts/notes.js'; +import UsersChart from './charts/users.js'; +import ActiveUsersChart from './charts/active-users.js'; +import InstanceChart from './charts/instance.js'; +import PerUserNotesChart from './charts/per-user-notes.js'; +import DriveChart from './charts/drive.js'; +import PerUserReactionsChart from './charts/per-user-reactions.js'; +import HashtagChart from './charts/hashtag.js'; +import PerUserFollowingChart from './charts/per-user-following.js'; +import PerUserDriveChart from './charts/per-user-drive.js'; +import ApRequestChart from './charts/ap-request.js'; +import type { OnApplicationShutdown } from '@nestjs/common'; + +@Injectable() +export class ChartManagementService implements OnApplicationShutdown { + private charts; + private saveIntervalId: NodeJS.Timer; + + constructor( + private federationChart: FederationChart, + private notesChart: NotesChart, + private usersChart: UsersChart, + private activeUsersChart: ActiveUsersChart, + private instanceChart: InstanceChart, + private perUserNotesChart: PerUserNotesChart, + private driveChart: DriveChart, + private perUserReactionsChart: PerUserReactionsChart, + private hashtagChart: HashtagChart, + private perUserFollowingChart: PerUserFollowingChart, + private perUserDriveChart: PerUserDriveChart, + private apRequestChart: ApRequestChart, + ) { + this.charts = [ + this.federationChart, + this.notesChart, + this.usersChart, + this.activeUsersChart, + this.instanceChart, + this.perUserNotesChart, + this.driveChart, + this.perUserReactionsChart, + this.hashtagChart, + this.perUserFollowingChart, + this.perUserDriveChart, + this.apRequestChart, + ]; + } + + public async run() { + // 20分おきにメモリ情報をDBに書き込み + this.saveIntervalId = setInterval(() => { + for (const chart of this.charts) { + chart.save(); + } + }, 1000 * 60 * 20); + } + + async onApplicationShutdown(signal: string): Promise { + clearInterval(this.saveIntervalId); + await Promise.all( + this.charts.map(chart => chart.save()), + ); + } +} diff --git a/packages/backend/src/services/chart/charts/active-users.ts b/packages/backend/src/core/chart/charts/active-users.ts similarity index 68% rename from packages/backend/src/services/chart/charts/active-users.ts rename to packages/backend/src/core/chart/charts/active-users.ts index d952ea53b..40c60910e 100644 --- a/packages/backend/src/services/chart/charts/active-users.ts +++ b/packages/backend/src/core/chart/charts/active-users.ts @@ -1,7 +1,12 @@ -import Chart, { KVs } from '../core.js'; -import { User } from '@/models/entities/user.js'; -import { Users } from '@/models/index.js'; +import { Injectable, Inject } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { AppLockService } from '@/core/AppLockService.js'; +import type { User } from '@/models/entities/User.js'; +import { DI } from '@/di-symbols.js'; +import Chart from '../core.js'; +import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/active-users.js'; +import type { KVs } from '../core.js'; const week = 1000 * 60 * 60 * 24 * 7; const month = 1000 * 60 * 60 * 24 * 30; @@ -11,9 +16,16 @@ const year = 1000 * 60 * 60 * 24 * 365; * アクティブユーザーに関するチャート */ // eslint-disable-next-line import/no-default-export +@Injectable() export default class ActiveUsersChart extends Chart { - constructor() { - super(name, schema); + constructor( + @Inject(DI.db) + private db: DataSource, + + private appLockService: AppLockService, + private chartLoggerService: ChartLoggerService, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/services/chart/charts/ap-request.ts b/packages/backend/src/core/chart/charts/ap-request.ts similarity index 54% rename from packages/backend/src/services/chart/charts/ap-request.ts rename to packages/backend/src/core/chart/charts/ap-request.ts index e9e42ade7..4b91fbbf1 100644 --- a/packages/backend/src/services/chart/charts/ap-request.ts +++ b/packages/backend/src/core/chart/charts/ap-request.ts @@ -1,13 +1,26 @@ -import Chart, { KVs } from '../core.js'; +import { Injectable, Inject } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import Chart from '../core.js'; +import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/ap-request.js'; +import type { KVs } from '../core.js'; /** * Chart about ActivityPub requests */ // eslint-disable-next-line import/no-default-export +@Injectable() export default class ApRequestChart extends Chart { - constructor() { - super(name, schema); + constructor( + @Inject(DI.db) + private db: DataSource, + + private appLockService: AppLockService, + private chartLoggerService: ChartLoggerService, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/services/chart/charts/drive.ts b/packages/backend/src/core/chart/charts/drive.ts similarity index 58% rename from packages/backend/src/services/chart/charts/drive.ts rename to packages/backend/src/core/chart/charts/drive.ts index 0eeba90dd..494dfbbe5 100644 --- a/packages/backend/src/services/chart/charts/drive.ts +++ b/packages/backend/src/core/chart/charts/drive.ts @@ -1,16 +1,27 @@ -import Chart, { KVs } from '../core.js'; -import { DriveFiles } from '@/models/index.js'; -import { Not, IsNull } from 'typeorm'; -import { DriveFile } from '@/models/entities/drive-file.js'; +import { Injectable, Inject } from '@nestjs/common'; +import { Not, IsNull, DataSource } from 'typeorm'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import Chart from '../core.js'; +import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/drive.js'; +import type { KVs } from '../core.js'; /** * ドライブに関するチャート */ // eslint-disable-next-line import/no-default-export +@Injectable() export default class DriveChart extends Chart { - constructor() { - super(name, schema); + constructor( + @Inject(DI.db) + private db: DataSource, + + private appLockService: AppLockService, + private chartLoggerService: ChartLoggerService, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/services/chart/charts/entities/active-users.ts b/packages/backend/src/core/chart/charts/entities/active-users.ts similarity index 100% rename from packages/backend/src/services/chart/charts/entities/active-users.ts rename to packages/backend/src/core/chart/charts/entities/active-users.ts diff --git a/packages/backend/src/services/chart/charts/entities/ap-request.ts b/packages/backend/src/core/chart/charts/entities/ap-request.ts similarity index 100% rename from packages/backend/src/services/chart/charts/entities/ap-request.ts rename to packages/backend/src/core/chart/charts/entities/ap-request.ts diff --git a/packages/backend/src/services/chart/charts/entities/drive.ts b/packages/backend/src/core/chart/charts/entities/drive.ts similarity index 100% rename from packages/backend/src/services/chart/charts/entities/drive.ts rename to packages/backend/src/core/chart/charts/entities/drive.ts diff --git a/packages/backend/src/services/chart/charts/entities/federation.ts b/packages/backend/src/core/chart/charts/entities/federation.ts similarity index 100% rename from packages/backend/src/services/chart/charts/entities/federation.ts rename to packages/backend/src/core/chart/charts/entities/federation.ts diff --git a/packages/backend/src/services/chart/charts/entities/hashtag.ts b/packages/backend/src/core/chart/charts/entities/hashtag.ts similarity index 100% rename from packages/backend/src/services/chart/charts/entities/hashtag.ts rename to packages/backend/src/core/chart/charts/entities/hashtag.ts diff --git a/packages/backend/src/services/chart/charts/entities/instance.ts b/packages/backend/src/core/chart/charts/entities/instance.ts similarity index 100% rename from packages/backend/src/services/chart/charts/entities/instance.ts rename to packages/backend/src/core/chart/charts/entities/instance.ts diff --git a/packages/backend/src/services/chart/charts/entities/notes.ts b/packages/backend/src/core/chart/charts/entities/notes.ts similarity index 100% rename from packages/backend/src/services/chart/charts/entities/notes.ts rename to packages/backend/src/core/chart/charts/entities/notes.ts diff --git a/packages/backend/src/services/chart/charts/entities/per-user-drive.ts b/packages/backend/src/core/chart/charts/entities/per-user-drive.ts similarity index 100% rename from packages/backend/src/services/chart/charts/entities/per-user-drive.ts rename to packages/backend/src/core/chart/charts/entities/per-user-drive.ts diff --git a/packages/backend/src/services/chart/charts/entities/per-user-following.ts b/packages/backend/src/core/chart/charts/entities/per-user-following.ts similarity index 100% rename from packages/backend/src/services/chart/charts/entities/per-user-following.ts rename to packages/backend/src/core/chart/charts/entities/per-user-following.ts diff --git a/packages/backend/src/services/chart/charts/entities/per-user-notes.ts b/packages/backend/src/core/chart/charts/entities/per-user-notes.ts similarity index 100% rename from packages/backend/src/services/chart/charts/entities/per-user-notes.ts rename to packages/backend/src/core/chart/charts/entities/per-user-notes.ts diff --git a/packages/backend/src/services/chart/charts/entities/per-user-reactions.ts b/packages/backend/src/core/chart/charts/entities/per-user-reactions.ts similarity index 100% rename from packages/backend/src/services/chart/charts/entities/per-user-reactions.ts rename to packages/backend/src/core/chart/charts/entities/per-user-reactions.ts diff --git a/packages/backend/src/services/chart/charts/entities/test-grouped.ts b/packages/backend/src/core/chart/charts/entities/test-grouped.ts similarity index 100% rename from packages/backend/src/services/chart/charts/entities/test-grouped.ts rename to packages/backend/src/core/chart/charts/entities/test-grouped.ts diff --git a/packages/backend/src/services/chart/charts/entities/test-intersection.ts b/packages/backend/src/core/chart/charts/entities/test-intersection.ts similarity index 100% rename from packages/backend/src/services/chart/charts/entities/test-intersection.ts rename to packages/backend/src/core/chart/charts/entities/test-intersection.ts diff --git a/packages/backend/src/services/chart/charts/entities/test-unique.ts b/packages/backend/src/core/chart/charts/entities/test-unique.ts similarity index 100% rename from packages/backend/src/services/chart/charts/entities/test-unique.ts rename to packages/backend/src/core/chart/charts/entities/test-unique.ts diff --git a/packages/backend/src/services/chart/charts/entities/test.ts b/packages/backend/src/core/chart/charts/entities/test.ts similarity index 100% rename from packages/backend/src/services/chart/charts/entities/test.ts rename to packages/backend/src/core/chart/charts/entities/test.ts diff --git a/packages/backend/src/services/chart/charts/entities/users.ts b/packages/backend/src/core/chart/charts/entities/users.ts similarity index 100% rename from packages/backend/src/services/chart/charts/entities/users.ts rename to packages/backend/src/core/chart/charts/entities/users.ts diff --git a/packages/backend/src/services/chart/charts/federation.ts b/packages/backend/src/core/chart/charts/federation.ts similarity index 50% rename from packages/backend/src/services/chart/charts/federation.ts rename to packages/backend/src/core/chart/charts/federation.ts index 10221ee1e..4366d4cce 100644 --- a/packages/backend/src/services/chart/charts/federation.ts +++ b/packages/backend/src/core/chart/charts/federation.ts @@ -1,15 +1,35 @@ -import Chart, { KVs } from '../core.js'; -import { Followings, Instances } from '@/models/index.js'; +import { Injectable, Inject } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { FollowingsRepository, InstancesRepository } from '@/models/index.js'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import { MetaService } from '@/core/MetaService.js'; +import Chart from '../core.js'; +import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/federation.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; +import type { KVs } from '../core.js'; /** * フェデレーションに関するチャート */ // eslint-disable-next-line import/no-default-export +@Injectable() export default class FederationChart extends Chart { - constructor() { - super(name, schema); + constructor( + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + private metaService: MetaService, + private appLockService: AppLockService, + private chartLoggerService: ChartLoggerService, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); } protected async tickMajor(): Promise>> { @@ -18,62 +38,62 @@ export default class FederationChart extends Chart { } protected async tickMinor(): Promise>> { - const meta = await fetchMeta(); + const meta = await this.metaService.fetch(); - const suspendedInstancesQuery = Instances.createQueryBuilder('instance') + const suspendedInstancesQuery = this.instancesRepository.createQueryBuilder('instance') .select('instance.host') .where('instance.isSuspended = true'); - const pubsubSubQuery = Followings.createQueryBuilder('f') + const pubsubSubQuery = this.followingsRepository.createQueryBuilder('f') .select('f.followerHost') .where('f.followerHost IS NOT NULL'); - const subInstancesQuery = Followings.createQueryBuilder('f') + const subInstancesQuery = this.followingsRepository.createQueryBuilder('f') .select('f.followeeHost') .where('f.followeeHost IS NOT NULL'); - const pubInstancesQuery = Followings.createQueryBuilder('f') + const pubInstancesQuery = this.followingsRepository.createQueryBuilder('f') .select('f.followerHost') .where('f.followerHost IS NOT NULL'); const [sub, pub, pubsub, subActive, pubActive] = await Promise.all([ - Followings.createQueryBuilder('following') + 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 IN (:...blocked)`, { blocked: meta.blockedHosts }) + .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT IN (:...blocked)', { blocked: meta.blockedHosts }) .andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) .getRawOne() .then(x => parseInt(x.count, 10)), - Followings.createQueryBuilder('following') + 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 IN (:...blocked)`, { blocked: meta.blockedHosts }) + .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followerHost NOT IN (:...blocked)', { blocked: meta.blockedHosts }) .andWhere(`following.followerHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) .getRawOne() .then(x => parseInt(x.count, 10)), - Followings.createQueryBuilder('following') + 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 IN (:...blocked)`, { blocked: meta.blockedHosts }) + .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT IN (:...blocked)', { blocked: meta.blockedHosts }) .andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) .andWhere(`following.followeeHost IN (${ pubsubSubQuery.getQuery() })`) .setParameters(pubsubSubQuery.getParameters()) .getRawOne() .then(x => parseInt(x.count, 10)), - Instances.createQueryBuilder('instance') + this.instancesRepository.createQueryBuilder('instance') .select('COUNT(instance.id)') .where(`instance.host IN (${ subInstancesQuery.getQuery() })`) - .andWhere(meta.blockedHosts.length === 0 ? '1=1' : `instance.host NOT IN (:...blocked)`, { blocked: meta.blockedHosts }) - .andWhere(`instance.isSuspended = false`) - .andWhere(`instance.lastCommunicatedAt > :gt`, { gt: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) }) + .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT IN (:...blocked)', { blocked: meta.blockedHosts }) + .andWhere('instance.isSuspended = false') + .andWhere('instance.lastCommunicatedAt > :gt', { gt: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) }) .getRawOne() .then(x => parseInt(x.count, 10)), - Instances.createQueryBuilder('instance') + this.instancesRepository.createQueryBuilder('instance') .select('COUNT(instance.id)') .where(`instance.host IN (${ pubInstancesQuery.getQuery() })`) - .andWhere(meta.blockedHosts.length === 0 ? '1=1' : `instance.host NOT IN (:...blocked)`, { blocked: meta.blockedHosts }) - .andWhere(`instance.isSuspended = false`) - .andWhere(`instance.lastCommunicatedAt > :gt`, { gt: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) }) + .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT IN (:...blocked)', { blocked: meta.blockedHosts }) + .andWhere('instance.isSuspended = false') + .andWhere('instance.lastCommunicatedAt > :gt', { gt: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) }) .getRawOne() .then(x => parseInt(x.count, 10)), ]); diff --git a/packages/backend/src/core/chart/charts/hashtag.ts b/packages/backend/src/core/chart/charts/hashtag.ts new file mode 100644 index 000000000..8b8c795cf --- /dev/null +++ b/packages/backend/src/core/chart/charts/hashtag.ts @@ -0,0 +1,43 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import type { User } from '@/models/entities/User.js'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import Chart from '../core.js'; +import { ChartLoggerService } from '../ChartLoggerService.js'; +import { name, schema } from './entities/hashtag.js'; +import type { KVs } from '../core.js'; + +/** + * ハッシュタグに関するチャート + */ +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class HashtagChart extends Chart { + constructor( + @Inject(DI.db) + private db: DataSource, + + private appLockService: AppLockService, + private userEntityService: UserEntityService, + private chartLoggerService: ChartLoggerService, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); + } + + protected async tickMajor(): Promise>> { + return {}; + } + + protected async tickMinor(): Promise>> { + return {}; + } + + public async update(hashtag: string, user: { id: User['id'], host: User['host'] }): Promise { + await this.commit({ + 'local.users': this.userEntityService.isLocalUser(user) ? [user.id] : [], + 'remote.users': this.userEntityService.isLocalUser(user) ? [] : [user.id], + }, hashtag); + } +} diff --git a/packages/backend/src/services/chart/charts/instance.ts b/packages/backend/src/core/chart/charts/instance.ts similarity index 59% rename from packages/backend/src/services/chart/charts/instance.ts rename to packages/backend/src/core/chart/charts/instance.ts index fe29ba522..be70bc79c 100644 --- a/packages/backend/src/services/chart/charts/instance.ts +++ b/packages/backend/src/core/chart/charts/instance.ts @@ -1,17 +1,43 @@ -import Chart, { KVs } from '../core.js'; -import { DriveFiles, Followings, Users, Notes } from '@/models/index.js'; -import { DriveFile } from '@/models/entities/drive-file.js'; -import { Note } from '@/models/entities/note.js'; -import { toPuny } from '@/misc/convert-host.js'; +import { Injectable, Inject } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { DriveFilesRepository, FollowingsRepository, UsersRepository, NotesRepository } from '@/models/index.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import type { Note } from '@/models/entities/Note.js'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import Chart from '../core.js'; +import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/instance.js'; +import type { KVs } from '../core.js'; /** * インスタンスごとのチャート */ // eslint-disable-next-line import/no-default-export +@Injectable() export default class InstanceChart extends Chart { - constructor() { - super(name, schema, true); + constructor( + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + private utilityService: UtilityService, + private appLockService: AppLockService, + private chartLoggerService: ChartLoggerService, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); } protected async tickMajor(group: string): Promise>> { @@ -22,11 +48,11 @@ export default class InstanceChart extends Chart { followersCount, driveFiles, ] = await Promise.all([ - Notes.countBy({ userHost: group }), - Users.countBy({ host: group }), - Followings.countBy({ followerHost: group }), - Followings.countBy({ followeeHost: group }), - DriveFiles.countBy({ userHost: group }), + this.notesRepository.countBy({ userHost: group }), + this.usersRepository.countBy({ host: group }), + this.followingsRepository.countBy({ followerHost: group }), + this.followingsRepository.countBy({ followeeHost: group }), + this.driveFilesRepository.countBy({ userHost: group }), ]); return { @@ -45,21 +71,21 @@ export default class InstanceChart extends Chart { public async requestReceived(host: string): Promise { await this.commit({ 'requests.received': 1, - }, toPuny(host)); + }, this.utilityService.toPuny(host)); } public async requestSent(host: string, isSucceeded: boolean): Promise { await this.commit({ 'requests.succeeded': isSucceeded ? 1 : 0, 'requests.failed': isSucceeded ? 0 : 1, - }, toPuny(host)); + }, this.utilityService.toPuny(host)); } public async newUser(host: string): Promise { await this.commit({ 'users.total': 1, 'users.inc': 1, - }, toPuny(host)); + }, this.utilityService.toPuny(host)); } public async updateNote(host: string, note: Note, isAdditional: boolean): Promise { @@ -71,7 +97,7 @@ export default class InstanceChart extends Chart { 'notes.diffs.renote': note.renoteId != null ? (isAdditional ? 1 : -1) : 0, 'notes.diffs.reply': note.replyId != null ? (isAdditional ? 1 : -1) : 0, 'notes.diffs.withFile': note.fileIds.length > 0 ? (isAdditional ? 1 : -1) : 0, - }, toPuny(host)); + }, this.utilityService.toPuny(host)); } public async updateFollowing(host: string, isAdditional: boolean): Promise { @@ -79,7 +105,7 @@ export default class InstanceChart extends Chart { 'following.total': isAdditional ? 1 : -1, 'following.inc': isAdditional ? 1 : 0, 'following.dec': isAdditional ? 0 : 1, - }, toPuny(host)); + }, this.utilityService.toPuny(host)); } public async updateFollowers(host: string, isAdditional: boolean): Promise { @@ -87,7 +113,7 @@ export default class InstanceChart extends Chart { 'followers.total': isAdditional ? 1 : -1, 'followers.inc': isAdditional ? 1 : 0, 'followers.dec': isAdditional ? 0 : 1, - }, toPuny(host)); + }, this.utilityService.toPuny(host)); } public async updateDrive(file: DriveFile, isAdditional: boolean): Promise { diff --git a/packages/backend/src/services/chart/charts/notes.ts b/packages/backend/src/core/chart/charts/notes.ts similarity index 56% rename from packages/backend/src/services/chart/charts/notes.ts rename to packages/backend/src/core/chart/charts/notes.ts index bb14b62f3..e1bfeabf9 100644 --- a/packages/backend/src/services/chart/charts/notes.ts +++ b/packages/backend/src/core/chart/charts/notes.ts @@ -1,22 +1,37 @@ -import Chart, { KVs } from '../core.js'; -import { Notes } from '@/models/index.js'; -import { Not, IsNull } from 'typeorm'; -import { Note } from '@/models/entities/note.js'; +import { Injectable, Inject } from '@nestjs/common'; +import { Not, IsNull, DataSource } from 'typeorm'; +import { NotesRepository } from '@/models/index.js'; +import type { Note } from '@/models/entities/Note.js'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import Chart from '../core.js'; +import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/notes.js'; +import type { KVs } from '../core.js'; /** * ノートに関するチャート */ // eslint-disable-next-line import/no-default-export +@Injectable() export default class NotesChart extends Chart { - constructor() { - super(name, schema); + constructor( + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private appLockService: AppLockService, + private chartLoggerService: ChartLoggerService, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); } protected async tickMajor(): Promise>> { const [localCount, remoteCount] = await Promise.all([ - Notes.countBy({ userHost: IsNull() }), - Notes.countBy({ userHost: Not(IsNull()) }), + this.notesRepository.countBy({ userHost: IsNull() }), + this.notesRepository.countBy({ userHost: Not(IsNull()) }), ]); return { diff --git a/packages/backend/src/core/chart/charts/per-user-drive.ts b/packages/backend/src/core/chart/charts/per-user-drive.ts new file mode 100644 index 000000000..752203daa --- /dev/null +++ b/packages/backend/src/core/chart/charts/per-user-drive.ts @@ -0,0 +1,60 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { DriveFilesRepository } from '@/models/index.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import Chart from '../core.js'; +import { ChartLoggerService } from '../ChartLoggerService.js'; +import { name, schema } from './entities/per-user-drive.js'; +import type { KVs } from '../core.js'; + +/** + * ユーザーごとのドライブに関するチャート + */ +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class PerUserDriveChart extends Chart { + constructor( + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private appLockService: AppLockService, + private driveFileEntityService: DriveFileEntityService, + private chartLoggerService: ChartLoggerService, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); + } + + protected async tickMajor(group: string): Promise>> { + const [count, size] = await Promise.all([ + this.driveFilesRepository.countBy({ userId: group }), + this.driveFileEntityService.calcDriveUsageOf(group), + ]); + + return { + 'totalCount': count, + 'totalSize': size, + }; + } + + protected async tickMinor(): Promise>> { + return {}; + } + + public async update(file: DriveFile, isAdditional: boolean): Promise { + const fileSizeKb = file.size / 1000; + await this.commit({ + 'totalCount': isAdditional ? 1 : -1, + 'totalSize': isAdditional ? fileSizeKb : -fileSizeKb, + 'incCount': isAdditional ? 1 : 0, + 'incSize': isAdditional ? fileSizeKb : 0, + 'decCount': isAdditional ? 0 : 1, + 'decSize': isAdditional ? 0 : fileSizeKb, + }, file.userId); + } +} diff --git a/packages/backend/src/core/chart/charts/per-user-following.ts b/packages/backend/src/core/chart/charts/per-user-following.ts new file mode 100644 index 000000000..48bf3d7c6 --- /dev/null +++ b/packages/backend/src/core/chart/charts/per-user-following.ts @@ -0,0 +1,73 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { Not, IsNull, DataSource } from 'typeorm'; +import type { User } from '@/models/entities/User.js'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { FollowingsRepository } from '@/models/index.js'; +import Chart from '../core.js'; +import { ChartLoggerService } from '../ChartLoggerService.js'; +import { name, schema } from './entities/per-user-following.js'; +import type { KVs } from '../core.js'; + +/** + * ユーザーごとのフォローに関するチャート + */ +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class PerUserFollowingChart extends Chart { + constructor( + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + private appLockService: AppLockService, + private userEntityService: UserEntityService, + private chartLoggerService: ChartLoggerService, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); + } + + protected async tickMajor(group: string): Promise>> { + const [ + localFollowingsCount, + localFollowersCount, + remoteFollowingsCount, + remoteFollowersCount, + ] = await Promise.all([ + this.followingsRepository.countBy({ followerId: group, followeeHost: IsNull() }), + this.followingsRepository.countBy({ followeeId: group, followerHost: IsNull() }), + this.followingsRepository.countBy({ followerId: group, followeeHost: Not(IsNull()) }), + this.followingsRepository.countBy({ followeeId: group, followerHost: Not(IsNull()) }), + ]); + + return { + 'local.followings.total': localFollowingsCount, + 'local.followers.total': localFollowersCount, + 'remote.followings.total': remoteFollowingsCount, + 'remote.followers.total': remoteFollowersCount, + }; + } + + protected async tickMinor(): Promise>> { + return {}; + } + + public async update(follower: { id: User['id']; host: User['host']; }, followee: { id: User['id']; host: User['host']; }, isFollow: boolean): Promise { + const prefixFollower = this.userEntityService.isLocalUser(follower) ? 'local' : 'remote'; + const prefixFollowee = this.userEntityService.isLocalUser(followee) ? 'local' : 'remote'; + + this.commit({ + [`${prefixFollower}.followings.total`]: isFollow ? 1 : -1, + [`${prefixFollower}.followings.inc`]: isFollow ? 1 : 0, + [`${prefixFollower}.followings.dec`]: isFollow ? 0 : 1, + }, follower.id); + this.commit({ + [`${prefixFollowee}.followers.total`]: isFollow ? 1 : -1, + [`${prefixFollowee}.followers.inc`]: isFollow ? 1 : 0, + [`${prefixFollowee}.followers.dec`]: isFollow ? 0 : 1, + }, followee.id); + } +} diff --git a/packages/backend/src/services/chart/charts/per-user-notes.ts b/packages/backend/src/core/chart/charts/per-user-notes.ts similarity index 54% rename from packages/backend/src/services/chart/charts/per-user-notes.ts rename to packages/backend/src/core/chart/charts/per-user-notes.ts index b9191dd08..ffe52dcd5 100644 --- a/packages/backend/src/services/chart/charts/per-user-notes.ts +++ b/packages/backend/src/core/chart/charts/per-user-notes.ts @@ -1,21 +1,37 @@ -import Chart, { KVs } from '../core.js'; -import { User } from '@/models/entities/user.js'; -import { Notes } from '@/models/index.js'; -import { Note } from '@/models/entities/note.js'; +import { Injectable, Inject } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import type { User } from '@/models/entities/User.js'; +import type { Note } from '@/models/entities/Note.js'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import { NotesRepository } from '@/models/index.js'; +import Chart from '../core.js'; +import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/per-user-notes.js'; +import type { KVs } from '../core.js'; /** * ユーザーごとのノートに関するチャート */ // eslint-disable-next-line import/no-default-export +@Injectable() export default class PerUserNotesChart extends Chart { - constructor() { - super(name, schema, true); + constructor( + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private appLockService: AppLockService, + private chartLoggerService: ChartLoggerService, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); } protected async tickMajor(group: string): Promise>> { const [count] = await Promise.all([ - Notes.countBy({ userId: group }), + this.notesRepository.countBy({ userId: group }), ]); return { diff --git a/packages/backend/src/core/chart/charts/per-user-reactions.ts b/packages/backend/src/core/chart/charts/per-user-reactions.ts new file mode 100644 index 000000000..416021972 --- /dev/null +++ b/packages/backend/src/core/chart/charts/per-user-reactions.ts @@ -0,0 +1,44 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import type { User } from '@/models/entities/User.js'; +import type { Note } from '@/models/entities/Note.js'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import Chart from '../core.js'; +import { ChartLoggerService } from '../ChartLoggerService.js'; +import { name, schema } from './entities/per-user-reactions.js'; +import type { KVs } from '../core.js'; + +/** + * ユーザーごとのリアクションに関するチャート + */ +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class PerUserReactionsChart extends Chart { + constructor( + @Inject(DI.db) + private db: DataSource, + + private appLockService: AppLockService, + private userEntityService: UserEntityService, + private chartLoggerService: ChartLoggerService, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); + } + + protected async tickMajor(group: string): Promise>> { + return {}; + } + + protected async tickMinor(): Promise>> { + return {}; + } + + public async update(user: { id: User['id'], host: User['host'] }, note: Note): Promise { + const prefix = this.userEntityService.isLocalUser(user) ? 'local' : 'remote'; + this.commit({ + [`${prefix}.count`]: 1, + }, note.userId); + } +} diff --git a/packages/backend/src/services/chart/charts/test-grouped.ts b/packages/backend/src/core/chart/charts/test-grouped.ts similarity index 56% rename from packages/backend/src/services/chart/charts/test-grouped.ts rename to packages/backend/src/core/chart/charts/test-grouped.ts index d01c9fcbd..500e85f9f 100644 --- a/packages/backend/src/services/chart/charts/test-grouped.ts +++ b/packages/backend/src/core/chart/charts/test-grouped.ts @@ -1,15 +1,28 @@ -import Chart, { KVs } from '../core.js'; +import { Injectable, Inject } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import Logger from '@/logger.js'; +import Chart from '../core.js'; import { name, schema } from './entities/test-grouped.js'; +import type { KVs } from '../core.js'; /** * For testing */ // eslint-disable-next-line import/no-default-export +@Injectable() export default class TestGroupedChart extends Chart { private total = {} as Record; - constructor() { - super(name, schema, true); + constructor( + @Inject(DI.db) + private db: DataSource, + + private appLockService: AppLockService, + private logger: Logger, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema, true); } protected async tickMajor(group: string): Promise>> { diff --git a/packages/backend/src/services/chart/charts/test-intersection.ts b/packages/backend/src/core/chart/charts/test-intersection.ts similarity index 52% rename from packages/backend/src/services/chart/charts/test-intersection.ts rename to packages/backend/src/core/chart/charts/test-intersection.ts index 88b5a715c..ff63e9976 100644 --- a/packages/backend/src/services/chart/charts/test-intersection.ts +++ b/packages/backend/src/core/chart/charts/test-intersection.ts @@ -1,13 +1,26 @@ -import Chart, { KVs } from '../core.js'; +import { Injectable, Inject } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import Logger from '@/logger.js'; +import Chart from '../core.js'; import { name, schema } from './entities/test-intersection.js'; +import type { KVs } from '../core.js'; /** * For testing */ // eslint-disable-next-line import/no-default-export +@Injectable() export default class TestIntersectionChart extends Chart { - constructor() { - super(name, schema); + constructor( + @Inject(DI.db) + private db: DataSource, + + private appLockService: AppLockService, + private logger: Logger, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/test-unique.ts b/packages/backend/src/core/chart/charts/test-unique.ts new file mode 100644 index 000000000..3be4b0df2 --- /dev/null +++ b/packages/backend/src/core/chart/charts/test-unique.ts @@ -0,0 +1,39 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import Logger from '@/logger.js'; +import Chart from '../core.js'; +import { name, schema } from './entities/test-unique.js'; +import type { KVs } from '../core.js'; + +/** + * For testing + */ +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class TestUniqueChart extends Chart { + constructor( + @Inject(DI.db) + private db: DataSource, + + private appLockService: AppLockService, + private logger: Logger, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema); + } + + protected async tickMajor(): Promise>> { + return {}; + } + + protected async tickMinor(): Promise>> { + return {}; + } + + public async uniqueIncrement(key: string): Promise { + await this.commit({ + foo: [key], + }); + } +} diff --git a/packages/backend/src/services/chart/charts/test.ts b/packages/backend/src/core/chart/charts/test.ts similarity index 58% rename from packages/backend/src/services/chart/charts/test.ts rename to packages/backend/src/core/chart/charts/test.ts index adb2b18c8..89f64c4c1 100644 --- a/packages/backend/src/services/chart/charts/test.ts +++ b/packages/backend/src/core/chart/charts/test.ts @@ -1,15 +1,28 @@ -import Chart, { KVs } from '../core.js'; +import { Injectable, Inject } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import Logger from '@/logger.js'; +import Chart from '../core.js'; import { name, schema } from './entities/test.js'; +import type { KVs } from '../core.js'; /** * For testing */ // eslint-disable-next-line import/no-default-export +@Injectable() export default class TestChart extends Chart { public total = 0; // publicにするのはテストのため - constructor() { - super(name, schema); + constructor( + @Inject(DI.db) + private db: DataSource, + + private appLockService: AppLockService, + private logger: Logger, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/users.ts b/packages/backend/src/core/chart/charts/users.ts new file mode 100644 index 000000000..b3187997c --- /dev/null +++ b/packages/backend/src/core/chart/charts/users.ts @@ -0,0 +1,58 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { Not, IsNull, DataSource } from 'typeorm'; +import type { User } from '@/models/entities/User.js'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { UsersRepository } from '@/models/index.js'; +import Chart from '../core.js'; +import { ChartLoggerService } from '../ChartLoggerService.js'; +import { name, schema } from './entities/users.js'; +import type { KVs } from '../core.js'; + +/** + * ユーザー数に関するチャート + */ +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class UsersChart extends Chart { + constructor( + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private appLockService: AppLockService, + private userEntityService: UserEntityService, + private chartLoggerService: ChartLoggerService, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); + } + + protected async tickMajor(): Promise>> { + const [localCount, remoteCount] = await Promise.all([ + this.usersRepository.countBy({ host: IsNull() }), + this.usersRepository.countBy({ host: Not(IsNull()) }), + ]); + + return { + 'local.total': localCount, + 'remote.total': remoteCount, + }; + } + + protected async tickMinor(): Promise>> { + return {}; + } + + public async update(user: { id: User['id'], host: User['host'] }, isAdditional: boolean): Promise { + const prefix = this.userEntityService.isLocalUser(user) ? 'local' : 'remote'; + + await this.commit({ + [`${prefix}.total`]: isAdditional ? 1 : -1, + [`${prefix}.inc`]: isAdditional ? 1 : 0, + [`${prefix}.dec`]: isAdditional ? 0 : 1, + }); + } +} diff --git a/packages/backend/src/services/chart/core.ts b/packages/backend/src/core/chart/core.ts similarity index 92% rename from packages/backend/src/services/chart/core.ts rename to packages/backend/src/core/chart/core.ts index 2960bac8f..cf5aa4888 100644 --- a/packages/backend/src/services/chart/core.ts +++ b/packages/backend/src/core/chart/core.ts @@ -5,13 +5,10 @@ */ import * as nestedProperty from 'nested-property'; -import Logger from '../logger.js'; -import { EntitySchema, Repository, LessThan, Between } from 'typeorm'; -import { dateUTC, isTimeSame, isTimeBefore, subtractTime, addTime } from '@/prelude/time.js'; -import { getChartInsertLock } from '@/misc/app-lock.js'; -import { db } from '@/db/postgre.js'; - -const logger = new Logger('chart', 'white', process.env.NODE_ENV !== 'test'); +import { EntitySchema, LessThan, Between } from 'typeorm'; +import { dateUTC, isTimeSame, isTimeBefore, subtractTime, addTime } from '@/misc/prelude/time.js'; +import type Logger from '@/logger.js'; +import type { Repository, DataSource } from 'typeorm'; const columnPrefix = '___' as const; const uniqueTempColumnPrefix = 'unique_temp___' as const; @@ -112,6 +109,8 @@ export function getJsonSchema(schema: S): ToJsonSchema { + private logger: Logger; + public schema: T; private name: string; @@ -230,9 +229,20 @@ export default abstract class Chart { }; } - constructor(name: string, schema: T, grouped = false) { + private lock: (key: string) => Promise<() => void>; + + constructor( + db: DataSource, + lock: (key: string) => Promise<() => void>, + logger: Logger, + name: string, + schema: T, + grouped = false, + ) { this.name = name; this.schema = schema; + this.lock = lock; + this.logger = logger; const { hour, day } = Chart.schemaToEntity(name, schema, grouped); this.repositoryForHour = db.getRepository<{ id: number; group?: string | null; date: number; }>(hour); @@ -242,7 +252,7 @@ export default abstract class Chart { private convertRawRecord(x: RawRecord): KVs { const kvs = {} as Record; for (const k of Object.keys(x).filter((k) => k.startsWith(columnPrefix)) as (keyof Columns)[]) { - kvs[(k as string).substr(columnPrefix.length).split(columnDot).join('.')] = x[k]; + kvs[(k as string).substr(columnPrefix.length).split(columnDot).join('.')] = x[k] as unknown as number; } return kvs as KVs; } @@ -323,13 +333,13 @@ export default abstract class Chart { // 初期ログデータを作成 data = this.getNewLog(null); - logger.info(`${this.name + (group ? `:${group}` : '')}(${span}): Initial commit created`); + this.logger.info(`${this.name + (group ? `:${group}` : '')}(${span}): Initial commit created`); } const date = Chart.dateToTimestamp(current); const lockKey = group ? `${this.name}:${date}:${span}:${group}` : `${this.name}:${date}:${span}`; - const unlock = await getChartInsertLock(lockKey); + const unlock = await this.lock(lockKey); try { // ロック内でもう1回チェックする const currentLog = await repository.findOneBy({ @@ -353,7 +363,7 @@ export default abstract class Chart { ...columns, }).then(x => repository.findOneByOrFail(x.identifiers[0])) as RawRecord; - logger.info(`${this.name + (group ? `:${group}` : '')}(${span}): New commit created`); + this.logger.info(`${this.name + (group ? `:${group}` : '')}(${span}): New commit created`); return log; } finally { @@ -372,7 +382,7 @@ export default abstract class Chart { public async save(): Promise { if (this.buffer.length === 0) { - logger.info(`${this.name}: Write skipped`); + this.logger.info(`${this.name}: Write skipped`); return; } @@ -403,16 +413,16 @@ export default abstract class Chart { const queryForDay: Record, number | (() => string)> = {} as any; for (const [k, v] of Object.entries(finalDiffs)) { if (typeof v === 'number') { - const name = columnPrefix + k.replaceAll('.', columnDot) as keyof Columns; + const name = columnPrefix + k.replaceAll('.', columnDot) as string & keyof Columns; if (v > 0) queryForHour[name] = () => `"${name}" + ${v}`; if (v < 0) queryForHour[name] = () => `"${name}" - ${Math.abs(v)}`; if (v > 0) queryForDay[name] = () => `"${name}" + ${v}`; if (v < 0) queryForDay[name] = () => `"${name}" - ${Math.abs(v)}`; } else if (Array.isArray(v) && v.length > 0) { // ユニークインクリメント - const tempColumnName = uniqueTempColumnPrefix + k.replaceAll('.', columnDot) as keyof TempColumnsForUnique; + const tempColumnName = uniqueTempColumnPrefix + k.replaceAll('.', columnDot) as string & keyof TempColumnsForUnique; // TODO: item をSQLエスケープ - const itemsForHour = v.filter(item => !logHour[tempColumnName].includes(item)).map(item => `"${item}"`); - const itemsForDay = v.filter(item => !logDay[tempColumnName].includes(item)).map(item => `"${item}"`); + const itemsForHour = v.filter(item => !(logHour[tempColumnName] as unknown as string[]).includes(item)).map(item => `"${item}"`); + const itemsForDay = v.filter(item => !(logDay[tempColumnName] as unknown as string[]).includes(item)).map(item => `"${item}"`); if (itemsForHour.length > 0) queryForHour[tempColumnName] = () => `array_cat("${tempColumnName}", '{${itemsForHour.join(',')}}'::varchar[])`; if (itemsForDay.length > 0) queryForDay[tempColumnName] = () => `array_cat("${tempColumnName}", '{${itemsForDay.join(',')}}'::varchar[])`; } @@ -423,8 +433,8 @@ export default abstract class Chart { if (this.schema[k].uniqueIncrement) { const name = columnPrefix + k.replaceAll('.', columnDot) as keyof Columns; const tempColumnName = uniqueTempColumnPrefix + k.replaceAll('.', columnDot) as keyof TempColumnsForUnique; - queryForHour[name] = new Set([...(v as string[]), ...logHour[tempColumnName]]).size; - queryForDay[name] = new Set([...(v as string[]), ...logDay[tempColumnName]]).size; + queryForHour[name] = new Set([...(v as string[]), ...(logHour[tempColumnName] as unknown as string[])]).size; + queryForDay[name] = new Set([...(v as string[]), ...(logDay[tempColumnName] as unknown as string[])]).size; } } @@ -437,14 +447,14 @@ export default abstract class Chart { const firstKey = intersection[0]; const firstTempColumnName = uniqueTempColumnPrefix + firstKey.replaceAll('.', columnDot) as keyof TempColumnsForUnique; const firstValues = finalDiffs[firstKey] as string[] | undefined; - const currentValuesForHour = new Set([...(firstValues ?? []), ...logHour[firstTempColumnName]]); - const currentValuesForDay = new Set([...(firstValues ?? []), ...logDay[firstTempColumnName]]); + const currentValuesForHour = new Set([...(firstValues ?? []), ...(logHour[firstTempColumnName] as unknown as string[])]); + const currentValuesForDay = new Set([...(firstValues ?? []), ...(logDay[firstTempColumnName] as unknown as string[])]); for (let i = 1; i < intersection.length; i++) { const targetKey = intersection[i]; const targetTempColumnName = uniqueTempColumnPrefix + targetKey.replaceAll('.', columnDot) as keyof TempColumnsForUnique; const targetValues = finalDiffs[targetKey] as string[] | undefined; - const targetValuesForHour = new Set([...(targetValues ?? []), ...logHour[targetTempColumnName]]); - const targetValuesForDay = new Set([...(targetValues ?? []), ...logDay[targetTempColumnName]]); + const targetValuesForHour = new Set([...(targetValues ?? []), ...(logHour[targetTempColumnName] as unknown as string[])]); + const targetValuesForDay = new Set([...(targetValues ?? []), ...(logDay[targetTempColumnName] as unknown as string[])]); currentValuesForHour.forEach(v => { if (!targetValuesForHour.has(v)) currentValuesForHour.delete(v); }); @@ -471,7 +481,7 @@ export default abstract class Chart { .execute(), ]); - logger.info(`${this.name + (logHour.group ? `:${logHour.group}` : '')}: Updated`); + this.logger.info(`${this.name + (logHour.group ? `:${logHour.group}` : '')}: Updated`); // TODO: この一連の処理が始まった後に新たにbufferに入ったものは消さないようにする this.buffer = this.buffer.filter(q => q.group != null && (q.group !== logHour.group)); diff --git a/packages/backend/src/services/chart/entities.ts b/packages/backend/src/core/chart/entities.ts similarity index 100% rename from packages/backend/src/services/chart/entities.ts rename to packages/backend/src/core/chart/entities.ts diff --git a/packages/backend/src/core/entities/AbuseUserReportEntityService.ts b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts new file mode 100644 index 000000000..6cc511fb4 --- /dev/null +++ b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts @@ -0,0 +1,49 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { AbuseUserReportsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { AbuseUserReport } from '@/models/entities/AbuseUserReport.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class AbuseUserReportEntityService { + constructor( + @Inject(DI.abuseUserReportsRepository) + private abuseUserReportsRepository: AbuseUserReportsRepository, + + private userEntityService: UserEntityService, + ) { + } + + public async pack( + src: AbuseUserReport['id'] | AbuseUserReport, + ) { + const report = typeof src === 'object' ? src : await this.abuseUserReportsRepository.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: report.id, + createdAt: report.createdAt.toISOString(), + comment: report.comment, + resolved: report.resolved, + reporterId: report.reporterId, + targetUserId: report.targetUserId, + assigneeId: report.assigneeId, + reporter: this.userEntityService.pack(report.reporter ?? report.reporterId, null, { + detail: true, + }), + targetUser: this.userEntityService.pack(report.targetUser ?? report.targetUserId, null, { + detail: true, + }), + assignee: report.assigneeId ? this.userEntityService.pack(report.assignee ?? report.assigneeId, null, { + detail: true, + }) : null, + forwarded: report.forwarded, + }); + } + + public packMany( + reports: any[], + ) { + return Promise.all(reports.map(x => this.pack(x))); + } +} diff --git a/packages/backend/src/core/entities/AntennaEntityService.ts b/packages/backend/src/core/entities/AntennaEntityService.ts new file mode 100644 index 000000000..9193cb81d --- /dev/null +++ b/packages/backend/src/core/entities/AntennaEntityService.ts @@ -0,0 +1,47 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { AntennaNotesRepository, AntennasRepository, UserGroupJoiningsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { Antenna } from '@/models/entities/Antenna.js'; + +@Injectable() +export class AntennaEntityService { + constructor( + @Inject(DI.antennasRepository) + private antennasRepository: AntennasRepository, + + @Inject(DI.antennaNotesRepository) + private antennaNotesRepository: AntennaNotesRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + ) { + } + + public async pack( + src: Antenna['id'] | Antenna, + ): Promise> { + const antenna = typeof src === 'object' ? src : await this.antennasRepository.findOneByOrFail({ id: src }); + + const hasUnreadNote = (await this.antennaNotesRepository.findOneBy({ antennaId: antenna.id, read: false })) != null; + const userGroupJoining = antenna.userGroupJoiningId ? await this.userGroupJoiningsRepository.findOneBy({ id: antenna.userGroupJoiningId }) : null; + + return { + id: antenna.id, + createdAt: antenna.createdAt.toISOString(), + name: antenna.name, + keywords: antenna.keywords, + excludeKeywords: antenna.excludeKeywords, + src: antenna.src, + userListId: antenna.userListId, + userGroupId: userGroupJoining ? userGroupJoining.userGroupId : null, + users: antenna.users, + caseSensitive: antenna.caseSensitive, + notify: antenna.notify, + withReplies: antenna.withReplies, + withFile: antenna.withFile, + hasUnreadNote, + }; + } +} diff --git a/packages/backend/src/core/entities/AppEntityService.ts b/packages/backend/src/core/entities/AppEntityService.ts new file mode 100644 index 000000000..6491b0b2d --- /dev/null +++ b/packages/backend/src/core/entities/AppEntityService.ts @@ -0,0 +1,52 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { AccessTokensRepository, AppsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { App } from '@/models/entities/App.js'; +import type { User } from '@/models/entities/User.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class AppEntityService { + constructor( + @Inject(DI.appsRepository) + private appsRepository: AppsRepository, + + @Inject(DI.accessTokensRepository) + private accessTokensRepository: AccessTokensRepository, + ) { + } + + public async pack( + src: App['id'] | App, + me?: { id: User['id'] } | null | undefined, + options?: { + detail?: boolean, + includeSecret?: boolean, + includeProfileImageIds?: boolean + }, + ): Promise> { + const opts = Object.assign({ + detail: false, + includeSecret: false, + includeProfileImageIds: false, + }, options); + + const app = typeof src === 'object' ? src : await this.appsRepository.findOneByOrFail({ id: src }); + + return { + id: app.id, + name: app.name, + callbackUrl: app.callbackUrl, + permission: app.permission, + ...(opts.includeSecret ? { secret: app.secret } : {}), + ...(me ? { + isAuthorized: await this.accessTokensRepository.countBy({ + appId: app.id, + userId: me.id, + }).then(count => count > 0), + } : {}), + }; + } +} diff --git a/packages/backend/src/core/entities/AuthSessionEntityService.ts b/packages/backend/src/core/entities/AuthSessionEntityService.ts new file mode 100644 index 000000000..a4dab3d9c --- /dev/null +++ b/packages/backend/src/core/entities/AuthSessionEntityService.ts @@ -0,0 +1,33 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { AuthSessionsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { AuthSession } from '@/models/entities/AuthSession.js'; +import type { User } from '@/models/entities/User.js'; +import { UserEntityService } from './UserEntityService.js'; +import { AppEntityService } from './AppEntityService.js'; + +@Injectable() +export class AuthSessionEntityService { + constructor( + @Inject(DI.authSessionsRepository) + private authSessionsRepository: AuthSessionsRepository, + + private appEntityService: AppEntityService, + ) { + } + + public async pack( + src: AuthSession['id'] | AuthSession, + me?: { id: User['id'] } | null | undefined, + ) { + const session = typeof src === 'object' ? src : await this.authSessionsRepository.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: session.id, + app: this.appEntityService.pack(session.appId, me), + token: session.token, + }); + } +} diff --git a/packages/backend/src/core/entities/BlockingEntityService.ts b/packages/backend/src/core/entities/BlockingEntityService.ts new file mode 100644 index 000000000..74ce6830b --- /dev/null +++ b/packages/backend/src/core/entities/BlockingEntityService.ts @@ -0,0 +1,42 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { BlockingsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { Blocking } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class BlockingEntityService { + constructor( + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + private userEntityService: UserEntityService, + ) { + } + + public async pack( + src: Blocking['id'] | Blocking, + me?: { id: User['id'] } | null | undefined, + ): Promise> { + const blocking = typeof src === 'object' ? src : await this.blockingsRepository.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: blocking.id, + createdAt: blocking.createdAt.toISOString(), + blockeeId: blocking.blockeeId, + blockee: this.userEntityService.pack(blocking.blockeeId, me, { + detail: true, + }), + }); + } + + public packMany( + blockings: any[], + me: { id: User['id'] }, + ) { + return Promise.all(blockings.map(x => this.pack(x, me))); + } +} diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts new file mode 100644 index 000000000..fec76e4e6 --- /dev/null +++ b/packages/backend/src/core/entities/ChannelEntityService.ts @@ -0,0 +1,66 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NoteUnreadsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { Channel } from '@/models/entities/Channel.js'; +import { UserEntityService } from './UserEntityService.js'; +import { DriveFileEntityService } from './DriveFileEntityService.js'; + +@Injectable() +export class ChannelEntityService { + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + + @Inject(DI.noteUnreadsRepository) + private noteUnreadsRepository: NoteUnreadsRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private userEntityService: UserEntityService, + private driveFileEntityService: DriveFileEntityService, + ) { + } + + public async pack( + src: Channel['id'] | Channel, + me?: { id: User['id'] } | null | undefined, + ): Promise> { + const channel = typeof src === 'object' ? src : await this.channelsRepository.findOneByOrFail({ id: src }); + const meId = me ? me.id : null; + + const banner = channel.bannerId ? await this.driveFilesRepository.findOneBy({ id: channel.bannerId }) : null; + + const hasUnreadNote = meId ? (await this.noteUnreadsRepository.findOneBy({ noteChannelId: channel.id, userId: meId })) != null : undefined; + + const following = meId ? await this.channelFollowingsRepository.findOneBy({ + followerId: meId, + followeeId: channel.id, + }) : null; + + return { + id: channel.id, + createdAt: channel.createdAt.toISOString(), + lastNotedAt: channel.lastNotedAt ? channel.lastNotedAt.toISOString() : null, + name: channel.name, + description: channel.description, + userId: channel.userId, + bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner, false) : null, + usersCount: channel.usersCount, + notesCount: channel.notesCount, + + ...(me ? { + isFollowing: following != null, + hasUnreadNote, + } : {}), + }; + } +} + diff --git a/packages/backend/src/core/entities/ClipEntityService.ts b/packages/backend/src/core/entities/ClipEntityService.ts new file mode 100644 index 000000000..27637e42e --- /dev/null +++ b/packages/backend/src/core/entities/ClipEntityService.ts @@ -0,0 +1,43 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { ClipsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { Clip } from '@/models/entities/Clip.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class ClipEntityService { + constructor( + @Inject(DI.clipsRepository) + private clipsRepository: ClipsRepository, + + private userEntityService: UserEntityService, + ) { + } + + public async pack( + src: Clip['id'] | Clip, + ): Promise> { + const clip = typeof src === 'object' ? src : await this.clipsRepository.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: clip.id, + createdAt: clip.createdAt.toISOString(), + userId: clip.userId, + user: this.userEntityService.pack(clip.user ?? clip.userId), + name: clip.name, + description: clip.description, + isPublic: clip.isPublic, + }); + } + + public packMany( + clips: Clip[], + ) { + return Promise.all(clips.map(x => this.pack(x))); + } +} + diff --git a/packages/backend/src/models/repositories/drive-file.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts similarity index 52% rename from packages/backend/src/models/repositories/drive-file.ts rename to packages/backend/src/core/entities/DriveFileEntityService.ts index 0d589d4f1..521bf51da 100644 --- a/packages/backend/src/models/repositories/drive-file.ts +++ b/packages/backend/src/core/entities/DriveFileEntityService.ts @@ -1,14 +1,17 @@ -import { db } from '@/db/postgre.js'; -import { DriveFile } from '@/models/entities/drive-file.js'; -import { User } from '@/models/entities/user.js'; -import { toPuny } from '@/misc/convert-host.js'; -import { awaitAll, Promiseable } from '@/prelude/await-all.js'; -import { Packed } from '@/misc/schema.js'; -import config from '@/config/index.js'; -import { query, appendQuery } from '@/prelude/url.js'; -import { Meta } from '@/models/entities/meta.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { Users, DriveFolders } from '../index.js'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { DataSource, In } from 'typeorm'; +import * as mfm from 'mfm-js'; +import { DI } from '@/di-symbols.js'; +import { NotesRepository, DriveFilesRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type { Packed } from '@/misc/schema.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { User } from '@/models/entities/User.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import { appendQuery, query } from '@/misc/prelude/url.js'; +import { UtilityService } from '../UtilityService.js'; +import { UserEntityService } from './UserEntityService.js'; +import { DriveFolderEntityService } from './DriveFolderEntityService.js'; type PackOptions = { detail?: boolean, @@ -16,8 +19,31 @@ type PackOptions = { withUser?: boolean, }; -export const DriveFileRepository = db.getRepository(DriveFile).extend({ - validateFileName(name: string): boolean { +@Injectable() +export class DriveFileEntityService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + // 循環参照のため / for circular dependency + @Inject(forwardRef(() => UserEntityService)) + private userEntityService: UserEntityService, + + private utilityService: UtilityService, + private driveFolderEntityService: DriveFolderEntityService, + ) { + } + + public validateFileName(name: string): boolean { return ( (name.trim().length > 0) && (name.length <= 200) && @@ -25,9 +51,9 @@ export const DriveFileRepository = db.getRepository(DriveFile).extend({ (name.indexOf('/') === -1) && (name.indexOf('..') === -1) ); - }, + } - getPublicProperties(file: DriveFile): DriveFile['properties'] { + public getPublicProperties(file: DriveFile): DriveFile['properties'] { if (file.properties.orientation != null) { // TODO //const properties = structuredClone(file.properties); @@ -40,78 +66,78 @@ export const DriveFileRepository = db.getRepository(DriveFile).extend({ } return file.properties; - }, + } - getPublicUrl(file: DriveFile, thumbnail = false): string | null { + public getPublicUrl(file: DriveFile, thumbnail = false): string | null { // リモートかつメディアプロキシ - if (file.uri != null && file.userHost != null && config.mediaProxy != null) { - return appendQuery(config.mediaProxy, query({ + if (file.uri != null && file.userHost != null && this.config.mediaProxy != null) { + return appendQuery(this.config.mediaProxy, query({ url: file.uri, thumbnail: thumbnail ? '1' : undefined, })); } // リモートかつ期限切れはローカルプロキシを試みる - if (file.uri != null && file.isLink && config.proxyRemoteFiles) { + if (file.uri != null && file.isLink && this.config.proxyRemoteFiles) { const key = thumbnail ? file.thumbnailAccessKey : file.webpublicAccessKey; if (key && !key.match('/')) { // 古いものはここにオブジェクトストレージキーが入ってるので除外 - return `${config.url}/files/${key}`; + return `${this.config.url}/files/${key}`; } } const isImage = file.type && ['image/png', 'image/apng', 'image/gif', 'image/jpeg', 'image/webp', 'image/svg+xml'].includes(file.type); - return thumbnail ? (file.thumbnailUrl || (isImage ? (file.webpublicUrl || file.url) : null)) : (file.webpublicUrl || file.url); - }, + return thumbnail ? (file.thumbnailUrl ?? (isImage ? (file.webpublicUrl ?? file.url) : null)) : (file.webpublicUrl ?? file.url); + } - async calcDriveUsageOf(user: User['id'] | { id: User['id'] }): Promise { + public async calcDriveUsageOf(user: User['id'] | { id: User['id'] }): Promise { const id = typeof user === 'object' ? user.id : user; - const { sum } = await this + const { sum } = await this.driveFilesRepository .createQueryBuilder('file') .where('file.userId = :id', { id: id }) .andWhere('file.isLink = FALSE') .select('SUM(file.size)', 'sum') .getRawOne(); - return parseInt(sum, 10) || 0; - }, + return parseInt(sum, 10) ?? 0; + } - async calcDriveUsageOfHost(host: string): Promise { - const { sum } = await this + public async calcDriveUsageOfHost(host: string): Promise { + const { sum } = await this.driveFilesRepository .createQueryBuilder('file') - .where('file.userHost = :host', { host: toPuny(host) }) + .where('file.userHost = :host', { host: this.utilityService.toPuny(host) }) .andWhere('file.isLink = FALSE') .select('SUM(file.size)', 'sum') .getRawOne(); - return parseInt(sum, 10) || 0; - }, + return parseInt(sum, 10) ?? 0; + } - async calcDriveUsageOfLocal(): Promise { - const { sum } = await this + public async calcDriveUsageOfLocal(): Promise { + const { sum } = await this.driveFilesRepository .createQueryBuilder('file') .where('file.userHost IS NULL') .andWhere('file.isLink = FALSE') .select('SUM(file.size)', 'sum') .getRawOne(); - return parseInt(sum, 10) || 0; - }, + return parseInt(sum, 10) ?? 0; + } - async calcDriveUsageOfRemote(): Promise { - const { sum } = await this + public async calcDriveUsageOfRemote(): Promise { + const { sum } = await this.driveFilesRepository .createQueryBuilder('file') .where('file.userHost IS NOT NULL') .andWhere('file.isLink = FALSE') .select('SUM(file.size)', 'sum') .getRawOne(); - return parseInt(sum, 10) || 0; - }, + return parseInt(sum, 10) ?? 0; + } - async pack( + public async pack( src: DriveFile['id'] | DriveFile, options?: PackOptions, ): Promise> { @@ -120,7 +146,7 @@ export const DriveFileRepository = db.getRepository(DriveFile).extend({ self: false, }, options); - const file = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + const file = typeof src === 'object' ? src : await this.driveFilesRepository.findOneByOrFail({ id: src }); return await awaitAll>({ id: file.id, @@ -136,15 +162,15 @@ export const DriveFileRepository = db.getRepository(DriveFile).extend({ thumbnailUrl: this.getPublicUrl(file, true), comment: file.comment, folderId: file.folderId, - folder: opts.detail && file.folderId ? DriveFolders.pack(file.folderId, { + folder: opts.detail && file.folderId ? this.driveFolderEntityService.pack(file.folderId, { detail: true, }) : null, userId: opts.withUser ? file.userId : null, - user: (opts.withUser && file.userId) ? Users.pack(file.userId) : null, + user: (opts.withUser && file.userId) ? this.userEntityService.pack(file.userId) : null, }); - }, + } - async packNullable( + public async packNullable( src: DriveFile['id'] | DriveFile, options?: PackOptions, ): Promise | null> { @@ -153,7 +179,7 @@ export const DriveFileRepository = db.getRepository(DriveFile).extend({ self: false, }, options); - const file = typeof src === 'object' ? src : await this.findOneBy({ id: src }); + const file = typeof src === 'object' ? src : await this.driveFilesRepository.findOneBy({ id: src }); if (file == null) return null; return await awaitAll>({ @@ -170,19 +196,19 @@ export const DriveFileRepository = db.getRepository(DriveFile).extend({ thumbnailUrl: this.getPublicUrl(file, true), comment: file.comment, folderId: file.folderId, - folder: opts.detail && file.folderId ? DriveFolders.pack(file.folderId, { + folder: opts.detail && file.folderId ? this.driveFolderEntityService.pack(file.folderId, { detail: true, }) : null, userId: opts.withUser ? file.userId : null, - user: (opts.withUser && file.userId) ? Users.pack(file.userId) : null, + user: (opts.withUser && file.userId) ? this.userEntityService.pack(file.userId) : null, }); - }, + } - async packMany( + public async packMany( files: (DriveFile['id'] | DriveFile)[], options?: PackOptions, ): Promise[]> { const items = await Promise.all(files.map(f => this.packNullable(f, options))); return items.filter((x): x is Packed<'DriveFile'> => x != null); - }, -}); + } +} diff --git a/packages/backend/src/core/entities/DriveFolderEntityService.ts b/packages/backend/src/core/entities/DriveFolderEntityService.ts new file mode 100644 index 000000000..beebbc320 --- /dev/null +++ b/packages/backend/src/core/entities/DriveFolderEntityService.ts @@ -0,0 +1,57 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { DriveFilesRepository, DriveFoldersRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { DriveFolder } from '@/models/entities/DriveFolder.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class DriveFolderEntityService { + constructor( + @Inject(DI.driveFoldersRepository) + private driveFoldersRepository: DriveFoldersRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + ) { + } + + public async pack( + src: DriveFolder['id'] | DriveFolder, + options?: { + detail: boolean + }, + ): Promise> { + const opts = Object.assign({ + detail: false, + }, options); + + const folder = typeof src === 'object' ? src : await this.driveFoldersRepository.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: folder.id, + createdAt: folder.createdAt.toISOString(), + name: folder.name, + parentId: folder.parentId, + + ...(opts.detail ? { + foldersCount: this.driveFoldersRepository.countBy({ + parentId: folder.id, + }), + filesCount: this.driveFilesRepository.countBy({ + folderId: folder.id, + }), + + ...(folder.parentId ? { + parent: this.pack(folder.parentId, { + detail: true, + }), + } : {}), + } : {}), + }); + } +} + diff --git a/packages/backend/src/core/entities/EmojiEntityService.ts b/packages/backend/src/core/entities/EmojiEntityService.ts new file mode 100644 index 000000000..10ed0f19e --- /dev/null +++ b/packages/backend/src/core/entities/EmojiEntityService.ts @@ -0,0 +1,43 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { EmojisRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { Emoji } from '@/models/entities/Emoji.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class EmojiEntityService { + constructor( + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, + + private userEntityService: UserEntityService, + ) { + } + + public async pack( + src: Emoji['id'] | Emoji, + ): Promise> { + const emoji = typeof src === 'object' ? src : await this.emojisRepository.findOneByOrFail({ id: src }); + + return { + id: emoji.id, + aliases: emoji.aliases, + name: emoji.name, + category: emoji.category, + host: emoji.host, + // ?? emoji.originalUrl してるのは後方互換性のため + url: emoji.publicUrl ?? emoji.originalUrl, + }; + } + + public packMany( + emojis: any[], + ) { + return Promise.all(emojis.map(x => this.pack(x))); + } +} + diff --git a/packages/backend/src/core/entities/FollowRequestEntityService.ts b/packages/backend/src/core/entities/FollowRequestEntityService.ts new file mode 100644 index 000000000..f7e7fd42e --- /dev/null +++ b/packages/backend/src/core/entities/FollowRequestEntityService.ts @@ -0,0 +1,34 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { FollowRequestsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { FollowRequest } from '@/models/entities/FollowRequest.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class FollowRequestEntityService { + constructor( + @Inject(DI.followRequestsRepository) + private followRequestsRepository: FollowRequestsRepository, + + private userEntityService: UserEntityService, + ) { + } + + public async pack( + src: FollowRequest['id'] | FollowRequest, + me?: { id: User['id'] } | null | undefined, + ) { + const request = typeof src === 'object' ? src : await this.followRequestsRepository.findOneByOrFail({ id: src }); + + return { + id: request.id, + follower: await this.userEntityService.pack(request.followerId, me), + followee: await this.userEntityService.pack(request.followeeId, me), + }; + } +} + diff --git a/packages/backend/src/models/repositories/following.ts b/packages/backend/src/core/entities/FollowingEntityService.ts similarity index 50% rename from packages/backend/src/models/repositories/following.ts rename to packages/backend/src/core/entities/FollowingEntityService.ts index 46109244f..93fed85f7 100644 --- a/packages/backend/src/models/repositories/following.ts +++ b/packages/backend/src/core/entities/FollowingEntityService.ts @@ -1,9 +1,12 @@ -import { db } from '@/db/postgre.js'; -import { Users } from '../index.js'; -import { Following } from '@/models/entities/following.js'; -import { awaitAll } from '@/prelude/await-all.js'; -import { Packed } from '@/misc/schema.js'; -import { User } from '@/models/entities/user.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { FollowingsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { Following } from '@/models/entities/Following.js'; +import { UserEntityService } from './UserEntityService.js'; type LocalFollowerFollowing = Following & { followerHost: null; @@ -29,32 +32,41 @@ type RemoteFolloweeFollowing = Following & { followeeSharedInbox: string; }; -export const FollowingRepository = db.getRepository(Following).extend({ - isLocalFollower(following: Following): following is LocalFollowerFollowing { +@Injectable() +export class FollowingEntityService { + constructor( + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + private userEntityService: UserEntityService, + ) { + } + + public isLocalFollower(following: Following): following is LocalFollowerFollowing { return following.followerHost == null; - }, + } - isRemoteFollower(following: Following): following is RemoteFollowerFollowing { + public isRemoteFollower(following: Following): following is RemoteFollowerFollowing { return following.followerHost != null; - }, + } - isLocalFollowee(following: Following): following is LocalFolloweeFollowing { + public isLocalFollowee(following: Following): following is LocalFolloweeFollowing { return following.followeeHost == null; - }, + } - isRemoteFollowee(following: Following): following is RemoteFolloweeFollowing { + public isRemoteFollowee(following: Following): following is RemoteFolloweeFollowing { return following.followeeHost != null; - }, + } - async pack( + public async pack( src: Following['id'] | Following, me?: { id: User['id'] } | null | undefined, opts?: { populateFollowee?: boolean; populateFollower?: boolean; - } + }, ): Promise> { - const following = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + const following = typeof src === 'object' ? src : await this.followingsRepository.findOneByOrFail({ id: src }); if (opts == null) opts = {}; @@ -63,23 +75,24 @@ export const FollowingRepository = db.getRepository(Following).extend({ createdAt: following.createdAt.toISOString(), followeeId: following.followeeId, followerId: following.followerId, - followee: opts.populateFollowee ? Users.pack(following.followee || following.followeeId, me, { + followee: opts.populateFollowee ? this.userEntityService.pack(following.followee ?? following.followeeId, me, { detail: true, }) : undefined, - follower: opts.populateFollower ? Users.pack(following.follower || following.followerId, me, { + follower: opts.populateFollower ? this.userEntityService.pack(following.follower ?? following.followerId, me, { detail: true, }) : undefined, }); - }, + } - packMany( + public packMany( followings: any[], me?: { id: User['id'] } | null | undefined, opts?: { populateFollowee?: boolean; populateFollower?: boolean; - } + }, ) { return Promise.all(followings.map(x => this.pack(x, me, opts))); - }, -}); + } +} + diff --git a/packages/backend/src/core/entities/GalleryLikeEntityService.ts b/packages/backend/src/core/entities/GalleryLikeEntityService.ts new file mode 100644 index 000000000..9473ed90b --- /dev/null +++ b/packages/backend/src/core/entities/GalleryLikeEntityService.ts @@ -0,0 +1,41 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { GalleryPosts, GalleryLikesRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { GalleryLike } from '@/models/entities/GalleryLike.js'; +import { UserEntityService } from './UserEntityService.js'; +import { GalleryPostEntityService } from './GalleryPostEntityService.js'; + +@Injectable() +export class GalleryLikeEntityService { + constructor( + @Inject(DI.galleryLikesRepository) + private galleryLikesRepository: GalleryLikesRepository, + + private galleryPostEntityService: GalleryPostEntityService, + ) { + } + + public async pack( + src: GalleryLike['id'] | GalleryLike, + me?: any, + ) { + const like = typeof src === 'object' ? src : await this.galleryLikesRepository.findOneByOrFail({ id: src }); + + return { + id: like.id, + post: await this.galleryPostEntityService.pack(like.post ?? like.postId, me), + }; + } + + public packMany( + likes: any[], + me: any, + ) { + return Promise.all(likes.map(x => this.pack(x, me))); + } +} + diff --git a/packages/backend/src/core/entities/GalleryPostEntityService.ts b/packages/backend/src/core/entities/GalleryPostEntityService.ts new file mode 100644 index 000000000..82b41697c --- /dev/null +++ b/packages/backend/src/core/entities/GalleryPostEntityService.ts @@ -0,0 +1,57 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { GalleryLikesRepository, GalleryPostsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { GalleryPost } from '@/models/entities/GalleryPost.js'; +import { UserEntityService } from './UserEntityService.js'; +import { DriveFileEntityService } from './DriveFileEntityService.js'; + +@Injectable() +export class GalleryPostEntityService { + constructor( + @Inject(DI.galleryPostsRepository) + private galleryPostsRepository: GalleryPostsRepository, + + @Inject(DI.galleryLikesRepository) + private galleryLikesRepository: GalleryLikesRepository, + + private userEntityService: UserEntityService, + private driveFileEntityService: DriveFileEntityService, + ) { + } + + public async pack( + src: GalleryPost['id'] | GalleryPost, + me?: { id: User['id'] } | null | undefined, + ): Promise> { + const meId = me ? me.id : null; + const post = typeof src === 'object' ? src : await this.galleryPostsRepository.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: post.id, + createdAt: post.createdAt.toISOString(), + updatedAt: post.updatedAt.toISOString(), + userId: post.userId, + user: this.userEntityService.pack(post.user ?? post.userId, me), + title: post.title, + description: post.description, + fileIds: post.fileIds, + files: this.driveFileEntityService.packMany(post.fileIds), + tags: post.tags.length > 0 ? post.tags : undefined, + isSensitive: post.isSensitive, + likedCount: post.likedCount, + isLiked: meId ? await this.galleryLikesRepository.findOneBy({ postId: post.id, userId: meId }).then(x => x != null) : undefined, + }); + } + + public packMany( + posts: GalleryPost[], + me?: { id: User['id'] } | null | undefined, + ) { + return Promise.all(posts.map(x => this.pack(x, me))); + } +} + diff --git a/packages/backend/src/core/entities/HashtagEntityService.ts b/packages/backend/src/core/entities/HashtagEntityService.ts new file mode 100644 index 000000000..6dcbf4903 --- /dev/null +++ b/packages/backend/src/core/entities/HashtagEntityService.ts @@ -0,0 +1,41 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { HashtagsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { Hashtag } from '@/models/entities/Hashtag.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class HashtagEntityService { + constructor( + @Inject(DI.hashtagsRepository) + private hashtagsRepository: HashtagsRepository, + + private userEntityService: UserEntityService, + ) { + } + + public async pack( + src: Hashtag, + ): Promise> { + return { + tag: src.name, + mentionedUsersCount: src.mentionedUsersCount, + mentionedLocalUsersCount: src.mentionedLocalUsersCount, + mentionedRemoteUsersCount: src.mentionedRemoteUsersCount, + attachedUsersCount: src.attachedUsersCount, + attachedLocalUsersCount: src.attachedLocalUsersCount, + attachedRemoteUsersCount: src.attachedRemoteUsersCount, + }; + } + + public packMany( + hashtags: Hashtag[], + ) { + return Promise.all(hashtags.map(x => this.pack(x))); + } +} + diff --git a/packages/backend/src/models/repositories/instance.ts b/packages/backend/src/core/entities/InstanceEntityService.ts similarity index 59% rename from packages/backend/src/models/repositories/instance.ts rename to packages/backend/src/core/entities/InstanceEntityService.ts index 5f0fd8d58..c58c2f8f3 100644 --- a/packages/backend/src/models/repositories/instance.ts +++ b/packages/backend/src/core/entities/InstanceEntityService.ts @@ -1,13 +1,28 @@ -import { db } from '@/db/postgre.js'; -import { Instance } from '@/models/entities/instance.js'; -import { Packed } from '@/misc/schema.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { InstancesRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { Instance } from '@/models/entities/Instance.js'; +import { MetaService } from '../MetaService.js'; +import { UserEntityService } from './UserEntityService.js'; -export const InstanceRepository = db.getRepository(Instance).extend({ - async pack( +@Injectable() +export class InstanceEntityService { + constructor( + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + private metaService: MetaService, + ) { + } + + public async pack( instance: Instance, ): Promise> { - const meta = await fetchMeta(); + const meta = await this.metaService.fetch(); return { id: instance.id, caughtAt: instance.caughtAt.toISOString(), @@ -33,11 +48,12 @@ export const InstanceRepository = db.getRepository(Instance).extend({ themeColor: instance.themeColor, infoUpdatedAt: instance.infoUpdatedAt ? instance.infoUpdatedAt.toISOString() : null, }; - }, + } - packMany( + public packMany( instances: Instance[], ) { return Promise.all(instances.map(x => this.pack(x))); - }, -}); + } +} + diff --git a/packages/backend/src/core/entities/MessagingMessageEntityService.ts b/packages/backend/src/core/entities/MessagingMessageEntityService.ts new file mode 100644 index 000000000..04467b94e --- /dev/null +++ b/packages/backend/src/core/entities/MessagingMessageEntityService.ts @@ -0,0 +1,57 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { MessagingMessagesRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { MessagingMessage } from '@/models/entities/MessagingMessage.js'; +import { UserEntityService } from './UserEntityService.js'; +import { DriveFileEntityService } from './DriveFileEntityService.js'; +import { UserGroupEntityService } from './UserGroupEntityService.js'; + +@Injectable() +export class MessagingMessageEntityService { + constructor( + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, + + private userEntityService: UserEntityService, + private userGroupEntityService: UserGroupEntityService, + private driveFileEntityService: DriveFileEntityService, + ) { + } + + public async pack( + src: MessagingMessage['id'] | MessagingMessage, + me?: { id: User['id'] } | null | undefined, + options?: { + populateRecipient?: boolean, + populateGroup?: boolean, + }, + ): Promise> { + const opts = options ?? { + populateRecipient: true, + populateGroup: true, + }; + + const message = typeof src === 'object' ? src : await this.messagingMessagesRepository.findOneByOrFail({ id: src }); + + return { + id: message.id, + createdAt: message.createdAt.toISOString(), + text: message.text, + userId: message.userId, + user: await this.userEntityService.pack(message.user ?? message.userId, me), + recipientId: message.recipientId, + recipient: message.recipientId && opts.populateRecipient ? await this.userEntityService.pack(message.recipient ?? message.recipientId, me) : undefined, + groupId: message.groupId, + group: message.groupId && opts.populateGroup ? await this.userGroupEntityService.pack(message.group ?? message.groupId) : undefined, + fileId: message.fileId, + file: message.fileId ? await this.driveFileEntityService.pack(message.fileId) : null, + isRead: message.isRead, + reads: message.reads, + }; + } +} + diff --git a/packages/backend/src/core/entities/ModerationLogEntityService.ts b/packages/backend/src/core/entities/ModerationLogEntityService.ts new file mode 100644 index 000000000..45d15088f --- /dev/null +++ b/packages/backend/src/core/entities/ModerationLogEntityService.ts @@ -0,0 +1,44 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { ModerationLogsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { ModerationLog } from '@/models/entities/ModerationLog.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class ModerationLogEntityService { + constructor( + @Inject(DI.moderationLogsRepository) + private moderationLogsRepository: ModerationLogsRepository, + + private userEntityService: UserEntityService, + ) { + } + + public async pack( + src: ModerationLog['id'] | ModerationLog, + ) { + const log = typeof src === 'object' ? src : await this.moderationLogsRepository.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: log.id, + createdAt: log.createdAt.toISOString(), + type: log.type, + info: log.info, + userId: log.userId, + user: this.userEntityService.pack(log.user ?? log.userId, null, { + detail: true, + }), + }); + } + + public packMany( + reports: any[], + ) { + return Promise.all(reports.map(x => this.pack(x))); + } +} + diff --git a/packages/backend/src/core/entities/MutingEntityService.ts b/packages/backend/src/core/entities/MutingEntityService.ts new file mode 100644 index 000000000..c5245bf20 --- /dev/null +++ b/packages/backend/src/core/entities/MutingEntityService.ts @@ -0,0 +1,45 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { MutingsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { Muting } from '@/models/entities/Muting.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class MutingEntityService { + constructor( + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + private userEntityService: UserEntityService, + ) { + } + + public async pack( + src: Muting['id'] | Muting, + me?: { id: User['id'] } | null | undefined, + ): Promise> { + const muting = typeof src === 'object' ? src : await this.mutingsRepository.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: muting.id, + createdAt: muting.createdAt.toISOString(), + expiresAt: muting.expiresAt ? muting.expiresAt.toISOString() : null, + muteeId: muting.muteeId, + mutee: this.userEntityService.pack(muting.muteeId, me, { + detail: true, + }), + }); + } + + public packMany( + mutings: any[], + me: { id: User['id'] }, + ) { + return Promise.all(mutings.map(x => this.pack(x, me))); + } +} + diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts new file mode 100644 index 000000000..669680758 --- /dev/null +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -0,0 +1,396 @@ +import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { DataSource, In } from 'typeorm'; +import * as mfm from 'mfm-js'; +import { ModuleRef } from '@nestjs/core'; +import { DI } from '@/di-symbols.js'; +import type { Notes, Polls, PollVotes, DriveFiles, Channels, Followings, Users, NoteReactions } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type { Packed } from '@/misc/schema.js'; +import { nyaize } from '@/misc/nyaize.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { User } from '@/models/entities/User.js'; +import type { Note } from '@/models/entities/Note.js'; +import type { NoteReaction } from '@/models/entities/NoteReaction.js'; +import type { OnModuleInit } from '@nestjs/common'; +import type { CustomEmojiService } from '../CustomEmojiService.js'; +import type { ReactionService } from '../ReactionService.js'; +import type { UserEntityService } from './UserEntityService.js'; +import type { DriveFileEntityService } from './DriveFileEntityService.js'; + +@Injectable() +export class NoteEntityService implements OnModuleInit { + private userEntityService: UserEntityService; + private driveFileEntityService: DriveFileEntityService; + private customEmojiService: CustomEmojiService; + private reactionService: ReactionService; + + constructor( + private moduleRef: ModuleRef, + + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.pollsRepository) + private pollsRepository: PollsRepository, + + @Inject(DI.pollVotesRepository) + private pollVotesRepository: PollVotesRepository, + + @Inject(DI.noteReactionsRepository) + private noteReactionsRepository: NoteReactionsRepository, + + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + //private userEntityService: UserEntityService, + //private driveFileEntityService: DriveFileEntityService, + //private customEmojiService: CustomEmojiService, + //private reactionService: ReactionService, + ) { + } + + onModuleInit() { + this.userEntityService = this.moduleRef.get('UserEntityService'); + this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); + this.customEmojiService = this.moduleRef.get('CustomEmojiService'); + this.reactionService = this.moduleRef.get('ReactionService'); + } + + private async hideNote(packedNote: Packed<'Note'>, meId: User['id'] | null) { + // TODO: isVisibleForMe を使うようにしても良さそう(型違うけど) + let hide = false; + + // visibility が specified かつ自分が指定されていなかったら非表示 + if (packedNote.visibility === 'specified') { + if (meId == null) { + hide = true; + } else if (meId === packedNote.userId) { + hide = false; + } else { + // 指定されているかどうか + const specified = packedNote.visibleUserIds!.some((id: any) => meId === id); + + if (specified) { + hide = false; + } else { + hide = true; + } + } + } + + // visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示 + if (packedNote.visibility === 'followers') { + if (meId == null) { + hide = true; + } else if (meId === packedNote.userId) { + hide = false; + } else if (packedNote.reply && (meId === packedNote.reply.userId)) { + // 自分の投稿に対するリプライ + hide = false; + } else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) { + // 自分へのメンション + hide = false; + } else { + // フォロワーかどうか + const following = await this.followingsRepository.findOneBy({ + followeeId: packedNote.userId, + followerId: meId, + }); + + if (following == null) { + hide = true; + } else { + hide = false; + } + } + } + + if (hide) { + packedNote.visibleUserIds = undefined; + packedNote.fileIds = []; + packedNote.files = []; + packedNote.text = null; + packedNote.poll = undefined; + packedNote.cw = null; + packedNote.isHidden = true; + } + } + + private async populatePoll(note: Note, meId: User['id'] | null) { + const poll = await this.pollsRepository.findOneByOrFail({ noteId: note.id }); + const choices = poll.choices.map(c => ({ + text: c, + votes: poll.votes[poll.choices.indexOf(c)], + isVoted: false, + })); + + if (meId) { + if (poll.multiple) { + const votes = await this.pollVotesRepository.findBy({ + userId: meId, + noteId: note.id, + }); + + const myChoices = votes.map(v => v.choice); + for (const myChoice of myChoices) { + choices[myChoice].isVoted = true; + } + } else { + const vote = await this.pollVotesRepository.findOneBy({ + userId: meId, + noteId: note.id, + }); + + if (vote) { + choices[vote.choice].isVoted = true; + } + } + } + + return { + multiple: poll.multiple, + expiresAt: poll.expiresAt, + choices, + }; + } + + private async populateMyReaction(note: Note, meId: User['id'], _hint_?: { + myReactions: Map; + }) { + if (_hint_?.myReactions) { + const reaction = _hint_.myReactions.get(note.id); + if (reaction) { + return this.reactionService.convertLegacyReaction(reaction.reaction); + } else if (reaction === null) { + return undefined; + } + // 実装上抜けがあるだけかもしれないので、「ヒントに含まれてなかったら(=undefinedなら)return」のようにはしない + } + + const reaction = await this.noteReactionsRepository.findOneBy({ + userId: meId, + noteId: note.id, + }); + + if (reaction) { + return this.reactionService.convertLegacyReaction(reaction.reaction); + } + + return undefined; + } + + public async isVisibleForMe(note: Note, meId: User['id'] | null): Promise { + // This code must always be synchronized with the checks in generateVisibilityQuery. + // visibility が specified かつ自分が指定されていなかったら非表示 + if (note.visibility === 'specified') { + if (meId == null) { + return false; + } else if (meId === note.userId) { + return true; + } else { + // 指定されているかどうか + return note.visibleUserIds.some((id: any) => meId === id); + } + } + + // visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示 + if (note.visibility === 'followers') { + if (meId == null) { + return false; + } else if (meId === note.userId) { + return true; + } else if (note.reply && (meId === note.reply.userId)) { + // 自分の投稿に対するリプライ + return true; + } else if (note.mentions && note.mentions.some(id => meId === id)) { + // 自分へのメンション + return true; + } else { + // フォロワーかどうか + const [following, user] = await Promise.all([ + this.followingsRepository.count({ + where: { + followeeId: note.userId, + followerId: meId, + }, + take: 1, + }), + this.usersRepository.findOneByOrFail({ id: meId }), + ]); + + /* If we know the following, everyhting is fine. + + But if we do not know the following, it might be that both the + author of the note and the author of the like are remote users, + in which case we can never know the following. Instead we have + to assume that the users are following each other. + */ + return following > 0 || (note.userHost != null && user.host != null); + } + } + + return true; + } + + public async pack( + src: Note['id'] | Note, + me?: { id: User['id'] } | null | undefined, + options?: { + detail?: boolean; + skipHide?: boolean; + _hint_?: { + myReactions: Map; + }; + }, + ): Promise> { + const opts = Object.assign({ + detail: true, + skipHide: false, + }, options); + + const meId = me ? me.id : null; + const note = typeof src === 'object' ? src : await this.notesRepository.findOneByOrFail({ id: src }); + const host = note.userHost; + + let text = note.text; + + if (note.name && (note.url ?? note.uri)) { + text = `【${note.name}】\n${(note.text || '').trim()}\n\n${note.url ?? note.uri}`; + } + + const channel = note.channelId + ? note.channel + ? note.channel + : await this.channelsRepository.findOneBy({ id: note.channelId }) + : null; + + const reactionEmojiNames = Object.keys(note.reactions).filter(x => x.startsWith(':')).map(x => this.reactionService.decodeReaction(x).reaction).map(x => x.replace(/:/g, '')); + + const packed: Packed<'Note'> = await awaitAll({ + id: note.id, + createdAt: note.createdAt.toISOString(), + userId: note.userId, + user: this.userEntityService.pack(note.user ?? note.userId, me, { + detail: false, + }), + text: text, + cw: note.cw, + visibility: note.visibility, + localOnly: note.localOnly ?? undefined, + visibleUserIds: note.visibility === 'specified' ? note.visibleUserIds : undefined, + renoteCount: note.renoteCount, + repliesCount: note.repliesCount, + reactions: this.reactionService.convertLegacyReactions(note.reactions), + tags: note.tags.length > 0 ? note.tags : undefined, + emojis: this.customEmojiService.populateEmojis(note.emojis.concat(reactionEmojiNames), host), + fileIds: note.fileIds, + files: this.driveFileEntityService.packMany(note.fileIds), + replyId: note.replyId, + renoteId: note.renoteId, + channelId: note.channelId ?? undefined, + channel: channel ? { + id: channel.id, + name: channel.name, + } : undefined, + mentions: note.mentions.length > 0 ? note.mentions : undefined, + uri: note.uri ?? undefined, + url: note.url ?? undefined, + + ...(opts.detail ? { + reply: note.replyId ? this.pack(note.reply ?? note.replyId, me, { + detail: false, + _hint_: options?._hint_, + }) : undefined, + + renote: note.renoteId ? this.pack(note.renote ?? note.renoteId, me, { + detail: true, + _hint_: options?._hint_, + }) : undefined, + + poll: note.hasPoll ? this.populatePoll(note, meId) : undefined, + + ...(meId ? { + myReaction: this.populateMyReaction(note, meId, options?._hint_), + } : {}), + } : {}), + }); + + if (packed.user.isCat && packed.text) { + const tokens = packed.text ? mfm.parse(packed.text) : []; + mfm.inspect(tokens, node => { + if (node.type === 'text') { + // TODO: quoteなtextはskip + node.props.text = nyaize(node.props.text); + } + }); + packed.text = mfm.toString(tokens); + } + + if (!opts.skipHide) { + await this.hideNote(packed, meId); + } + + return packed; + } + + public async packMany( + notes: Note[], + me?: { id: User['id'] } | null | undefined, + options?: { + detail?: boolean; + skipHide?: boolean; + }, + ) { + if (notes.length === 0) return []; + + const meId = me ? me.id : null; + const myReactionsMap = new Map(); + if (meId) { + const renoteIds = notes.filter(n => n.renoteId != null).map(n => n.renoteId!); + const targets = [...notes.map(n => n.id), ...renoteIds]; + const myReactions = await this.noteReactionsRepository.findBy({ + userId: meId, + noteId: In(targets), + }); + + for (const target of targets) { + myReactionsMap.set(target, myReactions.find(reaction => reaction.noteId === target) ?? null); + } + } + + await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes)); + + return await Promise.all(notes.map(n => this.pack(n, me, { + ...options, + _hint_: { + myReactions: myReactionsMap, + }, + }))); + } + + public async countSameRenotes(userId: string, renoteId: string, excludeNoteId: string | undefined): Promise { + // 指定したユーザーの指定したノートのリノートがいくつあるか数える + const query = this.notesRepository.createQueryBuilder('note') + .where('note.userId = :userId', { userId }) + .andWhere('note.renoteId = :renoteId', { renoteId }); + + // 指定した投稿を除く + if (excludeNoteId) { + query.andWhere('note.id != :excludeNoteId', { excludeNoteId }); + } + + return await query.getCount(); + } +} diff --git a/packages/backend/src/core/entities/NoteFavoriteEntityService.ts b/packages/backend/src/core/entities/NoteFavoriteEntityService.ts new file mode 100644 index 000000000..f0bbf27b6 --- /dev/null +++ b/packages/backend/src/core/entities/NoteFavoriteEntityService.ts @@ -0,0 +1,42 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { NoteFavoritesRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { NoteFavorite } from '@/models/entities/NoteFavorite.js'; +import { UserEntityService } from './UserEntityService.js'; +import { NoteEntityService } from './NoteEntityService.js'; + +@Injectable() +export class NoteFavoriteEntityService { + constructor( + @Inject(DI.noteFavoritesRepository) + private noteFavoritesRepository: NoteFavoritesRepository, + + private noteEntityService: NoteEntityService, + ) { + } + + public async pack( + src: NoteFavorite['id'] | NoteFavorite, + me?: { id: User['id'] } | null | undefined, + ) { + const favorite = typeof src === 'object' ? src : await this.noteFavoritesRepository.findOneByOrFail({ id: src }); + + return { + id: favorite.id, + createdAt: favorite.createdAt.toISOString(), + noteId: favorite.noteId, + note: await this.noteEntityService.pack(favorite.note ?? favorite.noteId, me), + }; + } + + public packMany( + favorites: any[], + me: { id: User['id'] }, + ) { + return Promise.all(favorites.map(x => this.pack(x, me))); + } +} diff --git a/packages/backend/src/core/entities/NoteReactionEntityService.ts b/packages/backend/src/core/entities/NoteReactionEntityService.ts new file mode 100644 index 000000000..e64f2af68 --- /dev/null +++ b/packages/backend/src/core/entities/NoteReactionEntityService.ts @@ -0,0 +1,62 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { NoteReactionsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { OnModuleInit } from '@nestjs/common'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { NoteReaction } from '@/models/entities/NoteReaction.js'; +import type { ReactionService } from '../ReactionService.js'; +import type { UserEntityService } from './UserEntityService.js'; +import type { NoteEntityService } from './NoteEntityService.js'; +import { ModuleRef } from '@nestjs/core'; + +@Injectable() +export class NoteReactionEntityService implements OnModuleInit { + private userEntityService: UserEntityService; + private noteEntityService: NoteEntityService; + private reactionService: ReactionService; + + constructor( + private moduleRef: ModuleRef, + + @Inject(DI.noteReactionsRepository) + private noteReactionsRepository: NoteReactionsRepository, + + //private userEntityService: UserEntityService, + //private noteEntityService: NoteEntityService, + //private reactionService: ReactionService, + ) { + } + + onModuleInit() { + this.userEntityService = this.moduleRef.get('UserEntityService'); + this.noteEntityService = this.moduleRef.get('NoteEntityService'); + this.reactionService = this.moduleRef.get('ReactionService'); + } + + public async pack( + src: NoteReaction['id'] | NoteReaction, + me?: { id: User['id'] } | null | undefined, + options?: { + withNote: boolean; + }, + ): Promise> { + const opts = Object.assign({ + withNote: false, + }, options); + + const reaction = typeof src === 'object' ? src : await this.noteReactionsRepository.findOneByOrFail({ id: src }); + + return { + id: reaction.id, + createdAt: reaction.createdAt.toISOString(), + user: await this.userEntityService.pack(reaction.user ?? reaction.userId, me), + type: this.reactionService.convertLegacyReaction(reaction.reaction), + ...(opts.withNote ? { + note: await this.noteEntityService.pack(reaction.note ?? reaction.noteId, me), + } : {}), + }; + } +} diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts new file mode 100644 index 000000000..6a0683d54 --- /dev/null +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -0,0 +1,151 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import { ModuleRef } from '@nestjs/core'; +import { DI } from '@/di-symbols.js'; +import { AccessTokensRepository, NoteReactionsRepository, NotificationsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Notification } from '@/models/entities/Notification.js'; +import type { NoteReaction } from '@/models/entities/NoteReaction.js'; +import type { Note } from '@/models/entities/Note.js'; +import type { Packed } from '@/misc/schema.js'; +import type { OnModuleInit } from '@nestjs/common'; +import type { CustomEmojiService } from '../CustomEmojiService.js'; +import type { UserEntityService } from './UserEntityService.js'; +import type { NoteEntityService } from './NoteEntityService.js'; +import type { UserGroupInvitationEntityService } from './UserGroupInvitationEntityService.js'; + +@Injectable() +export class NotificationEntityService implements OnModuleInit { + private userEntityService: UserEntityService; + private noteEntityService: NoteEntityService; + private userGroupInvitationEntityService: UserGroupInvitationEntityService; + private customEmojiService: CustomEmojiService; + + constructor( + private moduleRef: ModuleRef, + + @Inject(DI.notificationsRepository) + private notificationsRepository: NotificationsRepository, + + @Inject(DI.noteReactionsRepository) + private noteReactionsRepository: NoteReactionsRepository, + + @Inject(DI.accessTokensRepository) + private accessTokensRepository: AccessTokensRepository, + + //private userEntityService: UserEntityService, + //private noteEntityService: NoteEntityService, + //private userGroupInvitationEntityService: UserGroupInvitationEntityService, + //private customEmojiService: CustomEmojiService, + ) { + } + + onModuleInit() { + this.userEntityService = this.moduleRef.get('UserEntityService'); + this.noteEntityService = this.moduleRef.get('NoteEntityService'); + this.userGroupInvitationEntityService = this.moduleRef.get('UserGroupInvitationEntityService'); + this.customEmojiService = this.moduleRef.get('CustomEmojiService'); + } + + public async pack( + src: Notification['id'] | Notification, + options: { + _hintForEachNotes_?: { + myReactions: Map; + }; + }, + ): Promise> { + const notification = typeof src === 'object' ? src : await this.notificationsRepository.findOneByOrFail({ id: src }); + const token = notification.appAccessTokenId ? await this.accessTokensRepository.findOneByOrFail({ id: notification.appAccessTokenId }) : null; + + return await awaitAll({ + id: notification.id, + createdAt: notification.createdAt.toISOString(), + type: notification.type, + isRead: notification.isRead, + userId: notification.notifierId, + user: notification.notifierId ? this.userEntityService.pack(notification.notifier ?? notification.notifierId) : null, + ...(notification.type === 'mention' ? { + note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { + detail: true, + _hint_: options._hintForEachNotes_, + }), + } : {}), + ...(notification.type === 'reply' ? { + note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { + detail: true, + _hint_: options._hintForEachNotes_, + }), + } : {}), + ...(notification.type === 'renote' ? { + note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { + detail: true, + _hint_: options._hintForEachNotes_, + }), + } : {}), + ...(notification.type === 'quote' ? { + note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { + detail: true, + _hint_: options._hintForEachNotes_, + }), + } : {}), + ...(notification.type === 'reaction' ? { + note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { + detail: true, + _hint_: options._hintForEachNotes_, + }), + reaction: notification.reaction, + } : {}), + ...(notification.type === 'pollVote' ? { + note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { + detail: true, + _hint_: options._hintForEachNotes_, + }), + choice: notification.choice, + } : {}), + ...(notification.type === 'pollEnded' ? { + note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { + detail: true, + _hint_: options._hintForEachNotes_, + }), + } : {}), + ...(notification.type === 'groupInvited' ? { + invitation: this.userGroupInvitationEntityService.pack(notification.userGroupInvitationId!), + } : {}), + ...(notification.type === 'app' ? { + body: notification.customBody, + header: notification.customHeader ?? token?.name, + icon: notification.customIcon ?? token?.iconUrl, + } : {}), + }); + } + + public async packMany( + notifications: Notification[], + meId: User['id'], + ) { + if (notifications.length === 0) return []; + + const notes = notifications.filter(x => x.note != null).map(x => x.note!); + const noteIds = notes.map(n => n.id); + const myReactionsMap = new Map(); + const renoteIds = notes.filter(n => n.renoteId != null).map(n => n.renoteId!); + const targets = [...noteIds, ...renoteIds]; + const myReactions = await this.noteReactionsRepository.findBy({ + userId: meId, + noteId: In(targets), + }); + + for (const target of targets) { + myReactionsMap.set(target, myReactions.find(reaction => reaction.noteId === target) ?? null); + } + + await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes)); + + return await Promise.all(notifications.map(x => this.pack(x, { + _hintForEachNotes_: { + myReactions: myReactionsMap, + }, + }))); + } +} diff --git a/packages/backend/src/core/entities/PageEntityService.ts b/packages/backend/src/core/entities/PageEntityService.ts new file mode 100644 index 000000000..cbd193fe0 --- /dev/null +++ b/packages/backend/src/core/entities/PageEntityService.ts @@ -0,0 +1,109 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { DriveFilesRepository, PagesRepository, PageLikesRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { Page } from '@/models/entities/Page.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import { UserEntityService } from './UserEntityService.js'; +import { DriveFileEntityService } from './DriveFileEntityService.js'; + +@Injectable() +export class PageEntityService { + constructor( + @Inject(DI.pagesRepository) + private pagesRepository: PagesRepository, + + @Inject(DI.pageLikesRepository) + private pageLikesRepository: PageLikesRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private userEntityService: UserEntityService, + private driveFileEntityService: DriveFileEntityService, + ) { + } + + public async pack( + src: Page['id'] | Page, + me?: { id: User['id'] } | null | undefined, + ): Promise> { + const meId = me ? me.id : null; + const page = typeof src === 'object' ? src : await this.pagesRepository.findOneByOrFail({ id: src }); + + const attachedFiles: Promise[] = []; + const collectFile = (xs: any[]) => { + for (const x of xs) { + if (x.type === 'image') { + attachedFiles.push(this.driveFilesRepository.findOneBy({ + id: x.fileId, + userId: page.userId, + })); + } + if (x.children) { + collectFile(x.children); + } + } + }; + collectFile(page.content); + + // 後方互換性のため + let migrated = false; + const migrate = (xs: any[]) => { + for (const x of xs) { + if (x.type === 'input') { + if (x.inputType === 'text') { + x.type = 'textInput'; + } + if (x.inputType === 'number') { + x.type = 'numberInput'; + if (x.default) x.default = parseInt(x.default, 10); + } + migrated = true; + } + if (x.children) { + migrate(x.children); + } + } + }; + migrate(page.content); + if (migrated) { + this.pagesRepository.update(page.id, { + content: page.content, + }); + } + + return await awaitAll({ + id: page.id, + createdAt: page.createdAt.toISOString(), + updatedAt: page.updatedAt.toISOString(), + userId: page.userId, + user: this.userEntityService.pack(page.user ?? page.userId, me), // { detail: true } すると無限ループするので注意 + content: page.content, + variables: page.variables, + title: page.title, + name: page.name, + summary: page.summary, + hideTitleWhenPinned: page.hideTitleWhenPinned, + alignCenter: page.alignCenter, + font: page.font, + script: page.script, + eyeCatchingImageId: page.eyeCatchingImageId, + eyeCatchingImage: page.eyeCatchingImageId ? await this.driveFileEntityService.pack(page.eyeCatchingImageId) : null, + attachedFiles: this.driveFileEntityService.packMany((await Promise.all(attachedFiles)).filter((x): x is DriveFile => x != null)), + likedCount: page.likedCount, + isLiked: meId ? await this.pageLikesRepository.findOneBy({ pageId: page.id, userId: meId }).then(x => x != null) : undefined, + }); + } + + public packMany( + pages: Page[], + me?: { id: User['id'] } | null | undefined, + ) { + return Promise.all(pages.map(x => this.pack(x, me))); + } +} + diff --git a/packages/backend/src/core/entities/PageLikeEntityService.ts b/packages/backend/src/core/entities/PageLikeEntityService.ts new file mode 100644 index 000000000..dccaf2ede --- /dev/null +++ b/packages/backend/src/core/entities/PageLikeEntityService.ts @@ -0,0 +1,41 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { PageLikesRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { PageLike } from '@/models/entities/PageLike.js'; +import { UserEntityService } from './UserEntityService.js'; +import { PageEntityService } from './PageEntityService.js'; + +@Injectable() +export class PageLikeEntityService { + constructor( + @Inject(DI.pageLikesRepository) + private pageLikesRepository: PageLikesRepository, + + private pageEntityService: PageEntityService, + ) { + } + + public async pack( + src: PageLike['id'] | PageLike, + me?: { id: User['id'] } | null | undefined, + ) { + const like = typeof src === 'object' ? src : await this.pageLikesRepository.findOneByOrFail({ id: src }); + + return { + id: like.id, + page: await this.pageEntityService.pack(like.page ?? like.pageId, me), + }; + } + + public packMany( + likes: any[], + me: { id: User['id'] }, + ) { + return Promise.all(likes.map(x => this.pack(x, me))); + } +} + diff --git a/packages/backend/src/core/entities/SigninEntityService.ts b/packages/backend/src/core/entities/SigninEntityService.ts new file mode 100644 index 000000000..521c7669e --- /dev/null +++ b/packages/backend/src/core/entities/SigninEntityService.ts @@ -0,0 +1,27 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { SigninsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { Signin } from '@/models/entities/Signin.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class SigninEntityService { + constructor( + @Inject(DI.signinsRepository) + private signinsRepository: SigninsRepository, + + private userEntityService: UserEntityService, + ) { + } + + public async pack( + src: Signin, + ) { + return src; + } +} + diff --git a/packages/backend/src/models/repositories/user.ts b/packages/backend/src/core/entities/UserEntityService.ts similarity index 51% rename from packages/backend/src/models/repositories/user.ts rename to packages/backend/src/core/entities/UserEntityService.ts index 5c46ae27a..343e42df0 100644 --- a/packages/backend/src/models/repositories/user.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -1,18 +1,23 @@ +import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { EntityRepository, Repository, In, Not } from 'typeorm'; import Ajv from 'ajv'; -import { User, ILocalUser, IRemoteUser } from '@/models/entities/user.js'; -import config from '@/config/index.js'; -import { Packed } from '@/misc/schema.js'; -import { awaitAll, Promiseable } from '@/prelude/await-all.js'; -import { populateEmojis } from '@/misc/populate-emojis.js'; -import { getAntennas } from '@/misc/antenna-cache.js'; +import { ModuleRef } from '@nestjs/core'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import type { Packed } from '@/misc/schema.js'; +import type { Promiseable } from '@/misc/prelude/await-all.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js'; import { Cache } from '@/misc/cache.js'; -import { db } from '@/db/postgre.js'; -import { Instance } from '../entities/instance.js'; -import { Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages, Announcements, AnnouncementReads, Antennas, AntennaNotes, ChannelFollowings, Instances, DriveFiles } from '../index.js'; - -const userInstanceCache = new Cache(1000 * 60 * 60 * 3); +import type { Instance } from '@/models/entities/Instance.js'; +import type { ILocalUser, IRemoteUser, User } from '@/models/entities/User.js'; +import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js'; +import type { OnModuleInit } from '@nestjs/common'; +import type { AntennaService } from '../AntennaService.js'; +import type { CustomEmojiService } from '../CustomEmojiService.js'; +import type { NoteEntityService } from './NoteEntityService.js'; +import type { DriveFileEntityService } from './DriveFileEntityService.js'; +import type { PageEntityService } from './PageEntityService.js'; type IsUserDetailed = Detailed extends true ? Packed<'UserDetailed'> : Packed<'UserLite'>; type IsMeAndIsUserDetailed = @@ -24,13 +29,6 @@ type IsMeAndIsUserDetailed(user: T): user is T & { host: null; }; function isLocalUser(user: User | { host: User['host'] }): boolean { @@ -43,69 +41,153 @@ function isRemoteUser(user: User | { host: User['host'] }): boolean { return !isLocalUser(user); } -export const UserRepository = db.getRepository(User).extend({ - localUsernameSchema, - passwordSchema, - nameSchema, - descriptionSchema, - locationSchema, - birthdaySchema, +@Injectable() +export class UserEntityService implements OnModuleInit { + private noteEntityService: NoteEntityService; + private driveFileEntityService: DriveFileEntityService; + private pageEntityService: PageEntityService; + private customEmojiService: CustomEmojiService; + private antennaService: AntennaService; + private userInstanceCache: Cache; + + constructor( + private moduleRef: ModuleRef, + + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userSecurityKeysRepository) + private userSecurityKeysRepository: UserSecurityKeysRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.followRequestsRepository) + private followRequestsRepository: FollowRequestsRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + @Inject(DI.noteUnreadsRepository) + private noteUnreadsRepository: NoteUnreadsRepository, + + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + + @Inject(DI.notificationsRepository) + private notificationsRepository: NotificationsRepository, + + @Inject(DI.userNotePiningsRepository) + private userNotePiningsRepository: UserNotePiningsRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + @Inject(DI.announcementReadsRepository) + private announcementReadsRepository: AnnouncementReadsRepository, + + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + @Inject(DI.announcementsRepository) + private announcementsRepository: AnnouncementsRepository, + + @Inject(DI.antennaNotesRepository) + private antennaNotesRepository: AntennaNotesRepository, + + @Inject(DI.pagesRepository) + private pagesRepository: PagesRepository, + + //private noteEntityService: NoteEntityService, + //private driveFileEntityService: DriveFileEntityService, + //private pageEntityService: PageEntityService, + //private customEmojiService: CustomEmojiService, + //private antennaService: AntennaService, + ) { + this.userInstanceCache = new Cache(1000 * 60 * 60 * 3); + } + + onModuleInit() { + this.noteEntityService = this.moduleRef.get('NoteEntityService'); + this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); + this.pageEntityService = this.moduleRef.get('PageEntityService'); + this.customEmojiService = this.moduleRef.get('CustomEmojiService'); + this.antennaService = this.moduleRef.get('AntennaService'); + } //#region Validators - validateLocalUsername: ajv.compile(localUsernameSchema), - validatePassword: ajv.compile(passwordSchema), - validateName: ajv.compile(nameSchema), - validateDescription: ajv.compile(descriptionSchema), - validateLocation: ajv.compile(locationSchema), - validateBirthday: ajv.compile(birthdaySchema), + public validateLocalUsername = ajv.compile(localUsernameSchema); + public validatePassword = ajv.compile(passwordSchema); + public validateName = ajv.compile(nameSchema); + public validateDescription = ajv.compile(descriptionSchema); + public validateLocation = ajv.compile(locationSchema); + public validateBirthday = ajv.compile(birthdaySchema); //#endregion - async getRelation(me: User['id'], target: User['id']) { + public isLocalUser = isLocalUser; + public isRemoteUser = isRemoteUser; + + public async getRelation(me: User['id'], target: User['id']) { return awaitAll({ id: target, - isFollowing: Followings.count({ + isFollowing: this.followingsRepository.count({ where: { followerId: me, followeeId: target, }, take: 1, }).then(n => n > 0), - isFollowed: Followings.count({ + isFollowed: this.followingsRepository.count({ where: { followerId: target, followeeId: me, }, take: 1, }).then(n => n > 0), - hasPendingFollowRequestFromYou: FollowRequests.count({ + hasPendingFollowRequestFromYou: this.followRequestsRepository.count({ where: { followerId: me, followeeId: target, }, take: 1, }).then(n => n > 0), - hasPendingFollowRequestToYou: FollowRequests.count({ + hasPendingFollowRequestToYou: this.followRequestsRepository.count({ where: { followerId: target, followeeId: me, }, take: 1, }).then(n => n > 0), - isBlocking: Blockings.count({ + isBlocking: this.blockingsRepository.count({ where: { blockerId: me, blockeeId: target, }, take: 1, }).then(n => n > 0), - isBlocked: Blockings.count({ + isBlocked: this.blockingsRepository.count({ where: { blockerId: target, blockeeId: me, }, take: 1, }).then(n => n > 0), - isMuted: Mutings.count({ + isMuted: this.mutingsRepository.count({ where: { muterId: me, muteeId: target, @@ -113,16 +195,16 @@ export const UserRepository = db.getRepository(User).extend({ take: 1, }).then(n => n > 0), }); - }, + } - async getHasUnreadMessagingMessage(userId: User['id']): Promise { - const mute = await Mutings.findBy({ + public async getHasUnreadMessagingMessage(userId: User['id']): Promise { + const mute = await this.mutingsRepository.findBy({ muterId: userId, }); - const joinings = await UserGroupJoinings.findBy({ userId: userId }); + const joinings = await this.userGroupJoiningsRepository.findBy({ userId: userId }); - const groupQs = Promise.all(joinings.map(j => MessagingMessages.createQueryBuilder('message') + const groupQs = Promise.all(joinings.map(j => this.messagingMessagesRepository.createQueryBuilder('message') .where('message.groupId = :groupId', { groupId: j.userGroupId }) .andWhere('message.userId != :userId', { userId: userId }) .andWhere('NOT (:userId = ANY(message.reads))', { userId: userId }) @@ -130,7 +212,7 @@ export const UserRepository = db.getRepository(User).extend({ .getOne().then(x => x != null))); const [withUser, withGroups] = await Promise.all([ - MessagingMessages.count({ + this.messagingMessagesRepository.count({ where: { recipientId: userId, isRead: false, @@ -142,49 +224,49 @@ export const UserRepository = db.getRepository(User).extend({ ]); return withUser || withGroups.some(x => x); - }, + } - async getHasUnreadAnnouncement(userId: User['id']): Promise { - const reads = await AnnouncementReads.findBy({ + public async getHasUnreadAnnouncement(userId: User['id']): Promise { + const reads = await this.announcementReadsRepository.findBy({ userId: userId, }); - const count = await Announcements.countBy(reads.length > 0 ? { + const count = await this.announcementsRepository.countBy(reads.length > 0 ? { id: Not(In(reads.map(read => read.announcementId))), } : {}); return count > 0; - }, + } - async getHasUnreadAntenna(userId: User['id']): Promise { - const myAntennas = (await getAntennas()).filter(a => a.userId === userId); + public async getHasUnreadAntenna(userId: User['id']): Promise { + const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId); - const unread = myAntennas.length > 0 ? await AntennaNotes.findOneBy({ + const unread = myAntennas.length > 0 ? await this.antennaNotesRepository.findOneBy({ antennaId: In(myAntennas.map(x => x.id)), read: false, }) : null; return unread != null; - }, + } - async getHasUnreadChannel(userId: User['id']): Promise { - const channels = await ChannelFollowings.findBy({ followerId: userId }); + public async getHasUnreadChannel(userId: User['id']): Promise { + const channels = await this.channelFollowingsRepository.findBy({ followerId: userId }); - const unread = channels.length > 0 ? await NoteUnreads.findOneBy({ + const unread = channels.length > 0 ? await this.noteUnreadsRepository.findOneBy({ userId: userId, noteChannelId: In(channels.map(x => x.followeeId)), }) : null; return unread != null; - }, + } - async getHasUnreadNotification(userId: User['id']): Promise { - const mute = await Mutings.findBy({ + public async getHasUnreadNotification(userId: User['id']): Promise { + const mute = await this.mutingsRepository.findBy({ muterId: userId, }); const mutedUserIds = mute.map(m => m.muteeId); - const count = await Notifications.count({ + const count = await this.notificationsRepository.count({ where: { notifieeId: userId, ...(mutedUserIds.length > 0 ? { notifierId: Not(In(mutedUserIds)) } : {}), @@ -194,17 +276,17 @@ export const UserRepository = db.getRepository(User).extend({ }); return count > 0; - }, + } - async getHasPendingReceivedFollowRequest(userId: User['id']): Promise { - const count = await FollowRequests.countBy({ + public async getHasPendingReceivedFollowRequest(userId: User['id']): Promise { + const count = await this.followRequestsRepository.countBy({ followeeId: userId, }); return count > 0; - }, + } - getOnlineStatus(user: User): 'unknown' | 'online' | 'active' | 'offline' { + public getOnlineStatus(user: User): 'unknown' | 'online' | 'active' | 'offline' { if (user.hideOnlineStatus) return 'unknown'; if (user.lastActiveDate == null) return 'unknown'; const elapsed = Date.now() - user.lastActiveDate.getTime(); @@ -213,32 +295,32 @@ export const UserRepository = db.getRepository(User).extend({ elapsed < USER_ACTIVE_THRESHOLD ? 'active' : 'offline' ); - }, + } - async getAvatarUrl(user: User): Promise { + public async getAvatarUrl(user: User): Promise { if (user.avatar) { - return DriveFiles.getPublicUrl(user.avatar, true) || this.getIdenticonUrl(user.id); + return this.driveFileEntityService.getPublicUrl(user.avatar, true) ?? this.getIdenticonUrl(user.id); } else if (user.avatarId) { - const avatar = await DriveFiles.findOneByOrFail({ id: user.avatarId }); - return DriveFiles.getPublicUrl(avatar, true) || this.getIdenticonUrl(user.id); + const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId }); + return this.driveFileEntityService.getPublicUrl(avatar, true) ?? this.getIdenticonUrl(user.id); } else { return this.getIdenticonUrl(user.id); } - }, + } - getAvatarUrlSync(user: User): string { + public getAvatarUrlSync(user: User): string { if (user.avatar) { - return DriveFiles.getPublicUrl(user.avatar, true) || this.getIdenticonUrl(user.id); + return this.driveFileEntityService.getPublicUrl(user.avatar, true) ?? this.getIdenticonUrl(user.id); } else { return this.getIdenticonUrl(user.id); } - }, + } - getIdenticonUrl(userId: User['id']): string { - return `${config.url}/identicon/${userId}`; - }, + public getIdenticonUrl(userId: User['id']): string { + return `${this.config.url}/identicon/${userId}`; + } - async pack( + public async pack( src: User['id'] | User, me?: { id: User['id'] } | null | undefined, options?: { @@ -255,10 +337,10 @@ export const UserRepository = db.getRepository(User).extend({ if (typeof src === 'object') { user = src; - if (src.avatar === undefined && src.avatarId) src.avatar = await DriveFiles.findOneBy({ id: src.avatarId }) ?? null; - if (src.banner === undefined && src.bannerId) src.banner = await DriveFiles.findOneBy({ id: src.bannerId }) ?? null; + if (src.avatar === undefined && src.avatarId) src.avatar = await this.driveFilesRepository.findOneBy({ id: src.avatarId }) ?? null; + if (src.banner === undefined && src.bannerId) src.banner = await this.driveFilesRepository.findOneBy({ id: src.bannerId }) ?? null; } else { - user = await this.findOneOrFail({ + user = await this.usersRepository.findOneOrFail({ where: { id: src }, relations: { avatar: true, @@ -271,12 +353,12 @@ export const UserRepository = db.getRepository(User).extend({ const isMe = meId === user.id; const relation = meId && !isMe && opts.detail ? await this.getRelation(meId, user.id) : null; - const pins = opts.detail ? await UserNotePinings.createQueryBuilder('pin') + const pins = opts.detail ? await this.userNotePiningsRepository.createQueryBuilder('pin') .where('pin.userId = :userId', { userId: user.id }) .innerJoinAndSelect('pin.note', 'note') .orderBy('pin.id', 'DESC') .getMany() : []; - const profile = opts.detail ? await UserProfiles.findOneByOrFail({ userId: user.id }) : null; + const profile = opts.detail ? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }) : null; const followingCount = profile == null ? null : (profile.ffVisibility === 'public') || isMe ? user.followingCount : @@ -296,14 +378,14 @@ export const UserRepository = db.getRepository(User).extend({ username: user.username, host: user.host, avatarUrl: this.getAvatarUrlSync(user), - avatarBlurhash: user.avatar?.blurhash || null, + avatarBlurhash: user.avatar?.blurhash ?? null, avatarColor: null, // 後方互換性のため - isAdmin: user.isAdmin || falsy, - isModerator: user.isModerator || falsy, - isBot: user.isBot || falsy, - isCat: user.isCat || falsy, - instance: user.host ? userInstanceCache.fetch(user.host, - () => Instances.findOneBy({ host: user.host! }), + isAdmin: user.isAdmin ?? falsy, + isModerator: user.isModerator ?? falsy, + isBot: user.isBot ?? falsy, + isCat: user.isCat ?? falsy, + instance: user.host ? this.userInstanceCache.fetch(user.host, + () => this.instancesRepository.findOneBy({ host: user.host! }), v => v != null, ).then(instance => instance ? { name: instance.name, @@ -313,7 +395,7 @@ export const UserRepository = db.getRepository(User).extend({ faviconUrl: instance.faviconUrl, themeColor: instance.themeColor, } : undefined) : undefined, - emojis: populateEmojis(user.emojis, user.host), + emojis: this.customEmojiService.populateEmojis(user.emojis, user.host), onlineStatus: this.getOnlineStatus(user), driveCapacityOverrideMb: user.driveCapacityOverrideMb, @@ -323,32 +405,32 @@ export const UserRepository = db.getRepository(User).extend({ createdAt: user.createdAt.toISOString(), updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null, lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null, - bannerUrl: user.banner ? DriveFiles.getPublicUrl(user.banner, false) : null, - bannerBlurhash: user.banner?.blurhash || null, + bannerUrl: user.banner ? this.driveFileEntityService.getPublicUrl(user.banner, false) : null, + bannerBlurhash: user.banner?.blurhash ?? null, bannerColor: null, // 後方互換性のため isLocked: user.isLocked, - isSilenced: user.isSilenced || falsy, - isSuspended: user.isSuspended || falsy, + isSilenced: user.isSilenced ?? falsy, + isSuspended: user.isSuspended ?? falsy, description: profile!.description, location: profile!.location, birthday: profile!.birthday, lang: profile!.lang, fields: profile!.fields, - followersCount: followersCount || 0, - followingCount: followingCount || 0, + followersCount: followersCount ?? 0, + followingCount: followingCount ?? 0, notesCount: user.notesCount, pinnedNoteIds: pins.map(pin => pin.noteId), - pinnedNotes: Notes.packMany(pins.map(pin => pin.note!), me, { + pinnedNotes: this.noteEntityService.packMany(pins.map(pin => pin.note!), me, { detail: true, }), pinnedPageId: profile!.pinnedPageId, - pinnedPage: profile!.pinnedPageId ? Pages.pack(profile!.pinnedPageId, me) : null, + pinnedPage: profile!.pinnedPageId ? this.pageEntityService.pack(profile!.pinnedPageId, me) : null, publicReactions: profile!.publicReactions, ffVisibility: profile!.ffVisibility, twoFactorEnabled: profile!.twoFactorEnabled, usePasswordLessLogin: profile!.usePasswordLessLogin, securityKeys: profile!.twoFactorEnabled - ? UserSecurityKeys.countBy({ + ? this.userSecurityKeysRepository.countBy({ userId: user.id, }).then(result => result >= 1) : false, @@ -367,11 +449,11 @@ export const UserRepository = db.getRepository(User).extend({ isExplorable: user.isExplorable, isDeleted: user.isDeleted, hideOnlineStatus: user.hideOnlineStatus, - hasUnreadSpecifiedNotes: NoteUnreads.count({ + hasUnreadSpecifiedNotes: this.noteUnreadsRepository.count({ where: { userId: user.id, isSpecified: true }, take: 1, }).then(count => count > 0), - hasUnreadMentions: NoteUnreads.count({ + hasUnreadMentions: this.noteUnreadsRepository.count({ where: { userId: user.id, isMentioned: true }, take: 1, }).then(count => count > 0), @@ -386,14 +468,14 @@ export const UserRepository = db.getRepository(User).extend({ mutedInstances: profile!.mutedInstances, mutingNotificationTypes: profile!.mutingNotificationTypes, emailNotificationTypes: profile!.emailNotificationTypes, - showTimelineReplies: user.showTimelineReplies || falsy, + showTimelineReplies: user.showTimelineReplies ?? falsy, } : {}), ...(opts.includeSecrets ? { email: profile!.email, emailVerified: profile!.emailVerified, securityKeysList: profile!.twoFactorEnabled - ? UserSecurityKeys.find({ + ? this.userSecurityKeysRepository.find({ where: { userId: user.id, }, @@ -418,9 +500,9 @@ export const UserRepository = db.getRepository(User).extend({ } as Promiseable> as Promiseable>; return await awaitAll(packed); - }, + } - packMany( + public packMany( users: (User['id'] | User)[], me?: { id: User['id'] } | null | undefined, options?: { @@ -429,8 +511,5 @@ export const UserRepository = db.getRepository(User).extend({ }, ): Promise[]> { return Promise.all(users.map(u => this.pack(u, me, options))); - }, - - isLocalUser, - isRemoteUser, -}); + } +} diff --git a/packages/backend/src/core/entities/UserGroupEntityService.ts b/packages/backend/src/core/entities/UserGroupEntityService.ts new file mode 100644 index 000000000..acd26ea1e --- /dev/null +++ b/packages/backend/src/core/entities/UserGroupEntityService.ts @@ -0,0 +1,42 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { UserGroupJoiningsRepository, UserGroupsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { UserGroup } from '@/models/entities/UserGroup.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class UserGroupEntityService { + constructor( + @Inject(DI.userGroupsRepository) + private userGroupsRepository: UserGroupsRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + private userEntityService: UserEntityService, + ) { + } + + public async pack( + src: UserGroup['id'] | UserGroup, + ): Promise> { + const userGroup = typeof src === 'object' ? src : await this.userGroupsRepository.findOneByOrFail({ id: src }); + + const users = await this.userGroupJoiningsRepository.findBy({ + userGroupId: userGroup.id, + }); + + return { + id: userGroup.id, + createdAt: userGroup.createdAt.toISOString(), + name: userGroup.name, + ownerId: userGroup.userId, + userIds: users.map(x => x.userId), + }; + } +} + diff --git a/packages/backend/src/core/entities/UserGroupInvitationEntityService.ts b/packages/backend/src/core/entities/UserGroupInvitationEntityService.ts new file mode 100644 index 000000000..50ff2231a --- /dev/null +++ b/packages/backend/src/core/entities/UserGroupInvitationEntityService.ts @@ -0,0 +1,39 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { UserGroupInvitationsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { UserGroupInvitation } from '@/models/entities/UserGroupInvitation.js'; +import { UserEntityService } from './UserEntityService.js'; +import { UserGroupEntityService } from './UserGroupEntityService.js'; + +@Injectable() +export class UserGroupInvitationEntityService { + constructor( + @Inject(DI.userGroupInvitationsRepository) + private userGroupInvitationsRepository: UserGroupInvitationsRepository, + + private userGroupEntityService: UserGroupEntityService, + ) { + } + + public async pack( + src: UserGroupInvitation['id'] | UserGroupInvitation, + ) { + const invitation = typeof src === 'object' ? src : await this.userGroupInvitationsRepository.findOneByOrFail({ id: src }); + + return { + id: invitation.id, + group: await this.userGroupEntityService.pack(invitation.userGroup ?? invitation.userGroupId), + }; + } + + public packMany( + invitations: any[], + ) { + return Promise.all(invitations.map(x => this.pack(x))); + } +} + diff --git a/packages/backend/src/core/entities/UserListEntityService.ts b/packages/backend/src/core/entities/UserListEntityService.ts new file mode 100644 index 000000000..05434d9c8 --- /dev/null +++ b/packages/backend/src/core/entities/UserListEntityService.ts @@ -0,0 +1,41 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { UserListJoiningsRepository, UserListsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { UserList } from '@/models/entities/UserList.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class UserListEntityService { + constructor( + @Inject(DI.userListsRepository) + private userListsRepository: UserListsRepository, + + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, + + private userEntityService: UserEntityService, + ) { + } + + public async pack( + src: UserList['id'] | UserList, + ): Promise> { + const userList = typeof src === 'object' ? src : await this.userListsRepository.findOneByOrFail({ id: src }); + + const users = await this.userListJoiningsRepository.findBy({ + userListId: userList.id, + }); + + return { + id: userList.id, + createdAt: userList.createdAt.toISOString(), + name: userList.name, + userIds: users.map(x => x.userId), + }; + } +} + diff --git a/packages/backend/src/core/queue/QueueModule.ts b/packages/backend/src/core/queue/QueueModule.ts new file mode 100644 index 000000000..3a271ea37 --- /dev/null +++ b/packages/backend/src/core/queue/QueueModule.ts @@ -0,0 +1,112 @@ +import { Module } from '@nestjs/common'; +import Bull from 'bull'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import type { Provider } from '@nestjs/common'; +import type { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, EndedPollNotificationJobData, WebhookDeliverJobData } from '../../queue/types.js'; + +function q(config: Config, name: string, limitPerSec = -1) { + return new Bull(name, { + redis: { + port: config.redis.port, + host: config.redis.host, + family: config.redis.family == null ? 0 : config.redis.family, + password: config.redis.pass, + db: config.redis.db ?? 0, + }, + prefix: config.redis.prefix ? `${config.redis.prefix}:queue` : 'queue', + limiter: limitPerSec > 0 ? { + max: limitPerSec, + duration: 1000, + } : undefined, + settings: { + backoffStrategies: { + apBackoff, + }, + }, + }); +} + +// ref. https://github.com/misskey-dev/misskey/pull/7635#issue-971097019 +function apBackoff(attemptsMade: number, err: Error) { + const baseDelay = 60 * 1000; // 1min + const maxBackoff = 8 * 60 * 60 * 1000; // 8hours + let backoff = (Math.pow(2, attemptsMade) - 1) * baseDelay; + backoff = Math.min(backoff, maxBackoff); + backoff += Math.round(backoff * Math.random() * 0.2); + return backoff; +} + +export type SystemQueue = Bull.Queue>; +export type EndedPollNotificationQueue = Bull.Queue; +export type DeliverQueue = Bull.Queue; +export type InboxQueue = Bull.Queue; +export type DbQueue = Bull.Queue; +export type ObjectStorageQueue = Bull.Queue; +export type WebhookDeliverQueue = Bull.Queue; + +const $system: Provider = { + provide: 'queue:system', + useFactory: (config: Config) => q(config, 'system'), + inject: [DI.config], +}; + +const $endedPollNotification: Provider = { + provide: 'queue:endedPollNotification', + useFactory: (config: Config) => q(config, 'endedPollNotification'), + inject: [DI.config], +}; + +const $deliver: Provider = { + provide: 'queue:deliver', + useFactory: (config: Config) => q(config, 'deliver', config.deliverJobPerSec ?? 128), + inject: [DI.config], +}; + +const $inbox: Provider = { + provide: 'queue:inbox', + useFactory: (config: Config) => q(config, 'inbox', config.inboxJobPerSec ?? 16), + inject: [DI.config], +}; + +const $db: Provider = { + provide: 'queue:db', + useFactory: (config: Config) => q(config, 'db'), + inject: [DI.config], +}; + +const $objectStorage: Provider = { + provide: 'queue:objectStorage', + useFactory: (config: Config) => q(config, 'objectStorage'), + inject: [DI.config], +}; + +const $webhookDeliver: Provider = { + provide: 'queue:webhookDeliver', + useFactory: (config: Config) => q(config, 'webhookDeliver', 64), + inject: [DI.config], +}; + +@Module({ + imports: [ + ], + providers: [ + $system, + $endedPollNotification, + $deliver, + $inbox, + $db, + $objectStorage, + $webhookDeliver, + ], + exports: [ + $system, + $endedPollNotification, + $deliver, + $inbox, + $db, + $objectStorage, + $webhookDeliver, + ], +}) +export class QueueModule {} diff --git a/packages/backend/src/core/remote/RemoteLoggerService.ts b/packages/backend/src/core/remote/RemoteLoggerService.ts new file mode 100644 index 000000000..68246466c --- /dev/null +++ b/packages/backend/src/core/remote/RemoteLoggerService.ts @@ -0,0 +1,14 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type Logger from '@/logger.js'; +import { LoggerService } from '@/core/LoggerService.js'; + +@Injectable() +export class RemoteLoggerService { + public logger: Logger; + + constructor( + private loggerService: LoggerService, + ) { + this.logger = this.loggerService.getLogger('remote', 'cyan'); + } +} diff --git a/packages/backend/src/core/remote/ResolveUserService.ts b/packages/backend/src/core/remote/ResolveUserService.ts new file mode 100644 index 000000000..b45168fb0 --- /dev/null +++ b/packages/backend/src/core/remote/ResolveUserService.ts @@ -0,0 +1,132 @@ +import { URL } from 'node:url'; +import { Inject, Injectable } from '@nestjs/common'; +import chalk from 'chalk'; +import { IsNull } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { UsersRepository } from '@/models/index.js'; +import type { IRemoteUser, User } from '@/models/entities/User.js'; +import { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import { UtilityService } from '../UtilityService.js'; +import { WebfingerService } from './WebfingerService.js'; +import { RemoteLoggerService } from './RemoteLoggerService.js'; +import { ApPersonService } from './activitypub/models/ApPersonService.js'; + +@Injectable() +export class ResolveUserService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private utilityService: UtilityService, + private webfingerService: WebfingerService, + private remoteLoggerService: RemoteLoggerService, + private apPersonService: ApPersonService, + ) { + this.logger = this.remoteLoggerService.logger.createSubLogger('resolve-user'); + } + + public async resolveUser(username: string, host: string | null): Promise { + const usernameLower = username.toLowerCase(); + + if (host == null) { + this.logger.info(`return local user: ${usernameLower}`); + return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => { + if (u == null) { + throw new Error('user not found'); + } else { + return u; + } + }); + } + + host = this.utilityService.toPuny(host); + + if (this.config.host === host) { + this.logger.info(`return local user: ${usernameLower}`); + return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => { + if (u == null) { + throw new Error('user not found'); + } else { + return u; + } + }); + } + + const user = await this.usersRepository.findOneBy({ usernameLower, host }) as IRemoteUser | null; + + const acctLower = `${usernameLower}@${host}`; + + if (user == null) { + const self = await this.resolveSelf(acctLower); + + this.logger.succ(`return new remote user: ${chalk.magenta(acctLower)}`); + return await this.apPersonService.createPerson(self.href); + } + + // ユーザー情報が古い場合は、WebFilgerからやりなおして返す + if (user.lastFetchedAt == null || Date.now() - user.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { + // 繋がらないインスタンスに何回も試行するのを防ぐ, 後続の同様処理の連続試行を防ぐ ため 試行前にも更新する + await this.usersRepository.update(user.id, { + lastFetchedAt: new Date(), + }); + + this.logger.info(`try resync: ${acctLower}`); + const self = await this.resolveSelf(acctLower); + + if (user.uri !== self.href) { + // if uri mismatch, Fix (user@host <=> AP's Person id(IRemoteUser.uri)) mapping. + this.logger.info(`uri missmatch: ${acctLower}`); + this.logger.info(`recovery missmatch uri for (username=${username}, host=${host}) from ${user.uri} to ${self.href}`); + + // validate uri + const uri = new URL(self.href); + if (uri.hostname !== host) { + throw new Error('Invalid uri'); + } + + await this.usersRepository.update({ + usernameLower, + host: host, + }, { + uri: self.href, + }); + } else { + this.logger.info(`uri is fine: ${acctLower}`); + } + + await this.apPersonService.updatePerson(self.href); + + this.logger.info(`return resynced remote user: ${acctLower}`); + return await this.usersRepository.findOneBy({ uri: self.href }).then(u => { + if (u == null) { + throw new Error('user not found'); + } else { + return u; + } + }); + } + + this.logger.info(`return existing remote user: ${acctLower}`); + return user; + } + + private async resolveSelf(acctLower: string) { + this.logger.info(`WebFinger for ${chalk.yellow(acctLower)}`); + const finger = await this.webfingerService.webfinger(acctLower).catch(err => { + this.logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: ${ err.statusCode ?? err.message }`); + throw new Error(`Failed to WebFinger for ${acctLower}: ${ err.statusCode ?? err.message }`); + }); + const self = finger.links.find(link => link.rel != null && link.rel.toLowerCase() === 'self'); + if (!self) { + this.logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: self link not found`); + throw new Error('self link not found'); + } + return self; + } +} diff --git a/packages/backend/src/core/remote/WebfingerService.ts b/packages/backend/src/core/remote/WebfingerService.ts new file mode 100644 index 000000000..ab4631479 --- /dev/null +++ b/packages/backend/src/core/remote/WebfingerService.ts @@ -0,0 +1,48 @@ +import { URL } from 'node:url'; +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import { query as urlQuery } from '@/misc/prelude/url.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; + +type ILink = { + href: string; + rel?: string; +}; + +type IWebFinger = { + links: ILink[]; + subject: string; +}; + +@Injectable() +export class WebfingerService { + constructor( + @Inject(DI.config) + private config: Config, + + private httpRequestService: HttpRequestService, + ) { + } + + public async webfinger(query: string): Promise { + const url = this.genUrl(query); + + return await this.httpRequestService.getJson(url, 'application/jrd+json, application/json') as IWebFinger; + } + + private genUrl(query: string): string { + if (query.match(/^https?:\/\//)) { + const u = new URL(query); + return `${u.protocol}//${u.hostname}/.well-known/webfinger?` + urlQuery({ resource: query }); + } + + const m = query.match(/^([^@]+)@(.*)/); + if (m) { + const hostname = m[2]; + return `https://${hostname}/.well-known/webfinger?` + urlQuery({ resource: `acct:${query}` }); + } + + throw new Error(`Invalid query (${query})`); + } +} diff --git a/packages/backend/src/core/remote/activitypub/ApAudienceService.ts b/packages/backend/src/core/remote/activitypub/ApAudienceService.ts new file mode 100644 index 000000000..744017aa3 --- /dev/null +++ b/packages/backend/src/core/remote/activitypub/ApAudienceService.ts @@ -0,0 +1,104 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import promiseLimit from 'promise-limit'; +import { DI } from '@/di-symbols.js'; +import type { CacheableRemoteUser, CacheableUser } from '@/models/entities/User.js'; +import { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js'; +import { getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isPost, isRead, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; +import { ApPersonService } from './models/ApPersonService.js'; +import type { ApObject } from './type.js'; +import type { Resolver } from './ApResolverService.js'; + +type Visibility = 'public' | 'home' | 'followers' | 'specified'; + +type AudienceInfo = { + visibility: Visibility, + mentionedUsers: CacheableUser[], + visibleUsers: CacheableUser[], +}; + +@Injectable() +export class ApAudienceService { + constructor( + private apPersonService: ApPersonService, + ) { + } + + public async parseAudience(actor: CacheableRemoteUser, to?: ApObject, cc?: ApObject, resolver?: Resolver): Promise { + const toGroups = this.groupingAudience(getApIds(to), actor); + const ccGroups = this.groupingAudience(getApIds(cc), actor); + + const others = unique(concat([toGroups.other, ccGroups.other])); + + const limit = promiseLimit(2); + const mentionedUsers = (await Promise.all( + others.map(id => limit(() => this.apPersonService.resolvePerson(id, resolver).catch(() => null))), + )).filter((x): x is CacheableUser => x != null); + + if (toGroups.public.length > 0) { + return { + visibility: 'public', + mentionedUsers, + visibleUsers: [], + }; + } + + if (ccGroups.public.length > 0) { + return { + visibility: 'home', + mentionedUsers, + visibleUsers: [], + }; + } + + if (toGroups.followers.length > 0) { + return { + visibility: 'followers', + mentionedUsers, + visibleUsers: [], + }; + } + + return { + visibility: 'specified', + mentionedUsers, + visibleUsers: mentionedUsers, + }; + } + + private groupingAudience(ids: string[], actor: CacheableRemoteUser) { + const groups = { + public: [] as string[], + followers: [] as string[], + other: [] as string[], + }; + + for (const id of ids) { + if (this.isPublic(id)) { + groups.public.push(id); + } else if (this.isFollowers(id, actor)) { + groups.followers.push(id); + } else { + groups.other.push(id); + } + } + + groups.other = unique(groups.other); + + return groups; + } + + private isPublic(id: string) { + return [ + 'https://www.w3.org/ns/activitystreams#Public', + 'as#Public', + 'Public', + ].includes(id); + } + + private isFollowers(id: string, actor: CacheableRemoteUser) { + return ( + id === (actor.followersUri ?? `${actor.uri}/followers`) + ); + } +} diff --git a/packages/backend/src/core/remote/activitypub/ApDbResolverService.ts b/packages/backend/src/core/remote/activitypub/ApDbResolverService.ts new file mode 100644 index 000000000..6f197985d --- /dev/null +++ b/packages/backend/src/core/remote/activitypub/ApDbResolverService.ts @@ -0,0 +1,179 @@ +import { Inject, Injectable } from '@nestjs/common'; +import escapeRegexp from 'escape-regexp'; +import { DI } from '@/di-symbols.js'; +import { MessagingMessagesRepository, NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type { CacheableRemoteUser, CacheableUser } from '@/models/entities/User.js'; +import { Cache } from '@/misc/cache.js'; +import type { UserPublickey } from '@/models/entities/UserPublickey.js'; +import { UserCacheService } from '@/core/UserCacheService.js'; +import type { Note } from '@/models/entities/Note.js'; +import type { MessagingMessage } from '@/models/entities/MessagingMessage.js'; +import { getApId } from './type.js'; +import { ApPersonService } from './models/ApPersonService.js'; +import type { IObject } from './type.js'; + +export type UriParseResult = { + /** wether the URI was generated by us */ + local: true; + /** id in DB */ + id: string; + /** hint of type, e.g. "notes", "users" */ + type: string; + /** any remaining text after type and id, not including the slash after id. undefined if empty */ + rest?: string; +} | { + /** wether the URI was generated by us */ + local: false; + /** uri in DB */ + uri: string; +}; + +@Injectable() +export class ApDbResolverService { + private publicKeyCache: Cache; + private publicKeyByUserIdCache: Cache; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.userPublickeysRepository) + private userPublickeysRepository: UserPublickeysRepository, + + private userCacheService: UserCacheService, + private apPersonService: ApPersonService, + ) { + this.publicKeyCache = new Cache(Infinity); + this.publicKeyByUserIdCache = new Cache(Infinity); + } + + public parseUri(value: string | IObject): UriParseResult { + const uri = getApId(value); + + // the host part of a URL is case insensitive, so use the 'i' flag. + const localRegex = new RegExp('^' + escapeRegexp(this.config.url) + '/(\\w+)/(\\w+)(?:\/(.+))?', 'i'); + const matchLocal = uri.match(localRegex); + + if (matchLocal) { + return { + local: true, + type: matchLocal[1], + id: matchLocal[2], + rest: matchLocal[3], + }; + } else { + return { + local: false, + uri, + }; + } + } + + /** + * AP Note => Misskey Note in DB + */ + public async getNoteFromApId(value: string | IObject): Promise { + const parsed = this.parseUri(value); + + if (parsed.local) { + if (parsed.type !== 'notes') return null; + + return await this.notesRepository.findOneBy({ + id: parsed.id, + }); + } else { + return await this.notesRepository.findOneBy({ + uri: parsed.uri, + }); + } + } + + public async getMessageFromApId(value: string | IObject): Promise { + const parsed = this.parseUri(value); + + if (parsed.local) { + if (parsed.type !== 'notes') return null; + + return await this.messagingMessagesRepository.findOneBy({ + id: parsed.id, + }); + } else { + return await this.messagingMessagesRepository.findOneBy({ + uri: parsed.uri, + }); + } + } + + /** + * AP Person => Misskey User in DB + */ + public async getUserFromApId(value: string | IObject): Promise { + const parsed = this.parseUri(value); + + if (parsed.local) { + if (parsed.type !== 'users') return null; + + return await this.userCacheService.userByIdCache.fetchMaybe(parsed.id, () => this.usersRepository.findOneBy({ + id: parsed.id, + }).then(x => x ?? undefined)) ?? null; + } else { + return await this.userCacheService.uriPersonCache.fetch(parsed.uri, () => this.usersRepository.findOneBy({ + uri: parsed.uri, + })); + } + } + + /** + * AP KeyId => Misskey User and Key + */ + public async getAuthUserFromKeyId(keyId: string): Promise<{ + user: CacheableRemoteUser; + key: UserPublickey; + } | null> { + const key = await this.publicKeyCache.fetch(keyId, async () => { + const key = await this.userPublickeysRepository.findOneBy({ + keyId, + }); + + if (key == null) return null; + + return key; + }, key => key != null); + + if (key == null) return null; + + return { + user: await this.userCacheService.userByIdCache.fetch(key.userId, () => this.usersRepository.findOneByOrFail({ id: key.userId })) as CacheableRemoteUser, + key, + }; + } + + /** + * AP Actor id => Misskey User and Key + */ + public async getAuthUserFromApId(uri: string): Promise<{ + user: CacheableRemoteUser; + key: UserPublickey | null; + } | null> { + const user = await this.apPersonService.resolvePerson(uri) as CacheableRemoteUser; + + if (user == null) return null; + + const key = await this.publicKeyByUserIdCache.fetch(user.id, () => this.userPublickeysRepository.findOneBy({ userId: user.id }), v => v != null); + + return { + user, + key, + }; + } +} diff --git a/packages/backend/src/remote/activitypub/deliver-manager.ts b/packages/backend/src/core/remote/activitypub/ApDeliverManagerService.ts similarity index 50% rename from packages/backend/src/remote/activitypub/deliver-manager.ts rename to packages/backend/src/core/remote/activitypub/ApDeliverManagerService.ts index 4c1999e4c..a6ee85752 100644 --- a/packages/backend/src/remote/activitypub/deliver-manager.ts +++ b/packages/backend/src/core/remote/activitypub/ApDeliverManagerService.ts @@ -1,9 +1,12 @@ -import { Users, Followings } from '@/models/index.js'; -import { ILocalUser, IRemoteUser, User } from '@/models/entities/user.js'; -import { deliver } from '@/queue/index.js'; +import { Inject, Injectable } from '@nestjs/common'; import { IsNull, Not } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { FollowingsRepository, UsersRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type { ILocalUser, IRemoteUser, User } from '@/models/entities/User.js'; +import { QueueService } from '@/core/QueueService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; -//#region types interface IRecipe { type: string; } @@ -22,9 +25,71 @@ const isFollowers = (recipe: any): recipe is IFollowersRecipe => const isDirect = (recipe: any): recipe is IDirectRecipe => recipe.type === 'Direct'; -//#endregion -export default class DeliverManager { +@Injectable() +export class ApDeliverManagerService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + private userEntityService: UserEntityService, + private queueService: QueueService, + ) { + } + + /** + * Deliver activity to followers + * @param activity Activity + * @param from Followee + */ + public async deliverToFollowers(actor: { id: ILocalUser['id']; host: null; }, activity: any) { + const manager = new DeliverManager( + this.userEntityService, + this.followingsRepository, + this.queueService, + actor, + activity, + ); + manager.addFollowersRecipe(); + await manager.execute(); + } + + /** + * Deliver activity to user + * @param activity Activity + * @param to Target user + */ + public async deliverToUser(actor: { id: ILocalUser['id']; host: null; }, activity: any, to: IRemoteUser) { + const manager = new DeliverManager( + this.userEntityService, + this.followingsRepository, + this.queueService, + actor, + activity, + ); + manager.addDirectRecipe(to); + await manager.execute(); + } + + public createDeliverManager(actor: { id: User['id']; host: null; }, activity: any) { + return new DeliverManager( + this.userEntityService, + this.followingsRepository, + this.queueService, + + actor, + activity, + ); + } +} + +class DeliverManager { private actor: { id: User['id']; host: null; }; private activity: any; private recipes: IRecipe[] = []; @@ -34,7 +99,14 @@ export default class DeliverManager { * @param actor Actor * @param activity Activity to deliver */ - constructor(actor: { id: User['id']; host: null; }, activity: any) { + constructor( + private userEntityService: UserEntityService, + private followingsRepository: FollowingsRepository, + private queueService: QueueService, + + actor: { id: User['id']; host: null; }, + activity: any, + ) { this.actor = actor; this.activity = activity; } @@ -75,7 +147,7 @@ export default class DeliverManager { * Execute delivers */ public async execute() { - if (!Users.isLocalUser(this.actor)) return; + if (!this.userEntityService.isLocalUser(this.actor)) return; const inboxes = new Set(); @@ -89,7 +161,7 @@ export default class DeliverManager { // followers deliver // TODO: SELECT DISTINCT ON ("followerSharedInbox") "followerSharedInbox" みたいな問い合わせにすればよりパフォーマンス向上できそう // ただ、sharedInboxがnullなリモートユーザーも稀におり、その対応ができなさそう? - const followers = await Followings.find({ + const followers = await this.followingsRepository.find({ where: { followeeId: this.actor.id, followerHost: Not(IsNull()), @@ -104,7 +176,7 @@ export default class DeliverManager { }[]; for (const following of followers) { - const inbox = following.followerSharedInbox || following.followerInbox; + const inbox = following.followerSharedInbox ?? following.followerInbox; inboxes.add(inbox); } } @@ -117,35 +189,11 @@ export default class DeliverManager { // check that they actually have an inbox && recipe.to.inbox != null, ) - .forEach(recipe => inboxes.add(recipe.to.inbox!)); + .forEach(recipe => inboxes.add(recipe.to.inbox!)); // deliver for (const inbox of inboxes) { - deliver(this.actor, this.activity, inbox); + this.queueService.deliver(this.actor, this.activity, inbox); } } } - -//#region Utilities -/** - * Deliver activity to followers - * @param activity Activity - * @param from Followee - */ -export async function deliverToFollowers(actor: { id: ILocalUser['id']; host: null; }, activity: any) { - const manager = new DeliverManager(actor, activity); - manager.addFollowersRecipe(); - await manager.execute(); -} - -/** - * Deliver activity to user - * @param activity Activity - * @param to Target user - */ -export async function deliverToUser(actor: { id: ILocalUser['id']; host: null; }, activity: any, to: IRemoteUser) { - const manager = new DeliverManager(actor, activity); - manager.addDirectRecipe(to); - await manager.execute(); -} -//#endregion diff --git a/packages/backend/src/core/remote/activitypub/ApInboxService.ts b/packages/backend/src/core/remote/activitypub/ApInboxService.ts new file mode 100644 index 000000000..0482e029d --- /dev/null +++ b/packages/backend/src/core/remote/activitypub/ApInboxService.ts @@ -0,0 +1,735 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import type { CacheableRemoteUser } from '@/models/entities/User.js'; +import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { ReactionService } from '@/core/ReactionService.js'; +import { RelayService } from '@/core/RelayService.js'; +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 { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js'; +import { AppLockService } from '@/core/AppLockService.js'; +import type Logger from '@/logger.js'; +import { MetaService } from '@/core/MetaService.js'; +import { IdService } from '@/core/IdService.js'; +import { StatusError } from '@/misc/status-error.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { QueueService } from '@/core/QueueService.js'; +import { MessagingService } from '@/core/MessagingService.js'; +import { getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isPost, isRead, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; +import { ApNoteService } from './models/ApNoteService.js'; +import { ApLoggerService } from './ApLoggerService.js'; +import { ApDbResolverService } from './ApDbResolverService.js'; +import { ApResolverService } from './ApResolverService.js'; +import { ApAudienceService } from './ApAudienceService.js'; +import { ApPersonService } from './models/ApPersonService.js'; +import { ApQuestionService } from './models/ApQuestionService.js'; +import type { Resolver } from './ApResolverService.js'; +import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IRead, IReject, IRemove, IUndo, IUpdate } from './type.js'; + +@Injectable() +export class ApInboxService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, + + @Inject(DI.abuseUserReportsRepository) + private abuseUserReportsRepository: AbuseUserReportsRepository, + + @Inject(DI.followRequestsRepository) + private followRequestsRepository: FollowRequestsRepository, + + private userEntityService: UserEntityService, + private noteEntityService: NoteEntityService, + private utilityService: UtilityService, + private idService: IdService, + private metaService: MetaService, + private userFollowingService: UserFollowingService, + private apAudienceService: ApAudienceService, + private reactionService: ReactionService, + private relayService: RelayService, + private notePiningService: NotePiningService, + private userBlockingService: UserBlockingService, + private noteCreateService: NoteCreateService, + private noteDeleteService: NoteDeleteService, + private appLockService: AppLockService, + private apResolverService: ApResolverService, + private apDbResolverService: ApDbResolverService, + private apLoggerService: ApLoggerService, + private apNoteService: ApNoteService, + private apPersonService: ApPersonService, + private apQuestionService: ApQuestionService, + private queueService: QueueService, + private messagingService: MessagingService, + ) { + this.logger = this.apLoggerService.logger; + } + + public async performActivity(actor: CacheableRemoteUser, activity: IObject) { + if (isCollectionOrOrderedCollection(activity)) { + const resolver = this.apResolverService.createResolver(); + for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) { + const act = await resolver.resolve(item); + try { + await this.performOneActivity(actor, act); + } catch (err) { + if (err instanceof Error || typeof err === 'string') { + this.logger.error(err); + } + } + } + } else { + await this.performOneActivity(actor, activity); + } + + // ついでにリモートユーザーの情報が古かったら更新しておく + if (actor.uri) { + if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { + setImmediate(() => { + this.apPersonService.updatePerson(actor.uri!); + }); + } + } + } + + public async performOneActivity(actor: CacheableRemoteUser, activity: IObject): Promise { + if (actor.isSuspended) return; + + if (isCreate(activity)) { + await this.create(actor, activity); + } else if (isDelete(activity)) { + await this.delete(actor, activity); + } else if (isUpdate(activity)) { + await this.update(actor, activity); + } else if (isRead(activity)) { + await this.read(actor, activity); + } else if (isFollow(activity)) { + await this.follow(actor, activity); + } else if (isAccept(activity)) { + await this.accept(actor, activity); + } else if (isReject(activity)) { + await this.reject(actor, activity); + } else if (isAdd(activity)) { + await this.add(actor, activity).catch(err => this.logger.error(err)); + } else if (isRemove(activity)) { + await this.remove(actor, activity).catch(err => this.logger.error(err)); + } else if (isAnnounce(activity)) { + await this.announce(actor, activity); + } else if (isLike(activity)) { + await this.like(actor, activity); + } else if (isUndo(activity)) { + await this.undo(actor, activity); + } else if (isBlock(activity)) { + await this.block(actor, activity); + } else if (isFlag(activity)) { + await this.flag(actor, activity); + } else { + this.logger.warn(`unrecognized activity type: ${(activity as any).type}`); + } + } + + private async follow(actor: CacheableRemoteUser, activity: IFollow): Promise { + const followee = await this.apDbResolverService.getUserFromApId(activity.object); + + if (followee == null) { + return 'skip: followee not found'; + } + + if (followee.host != null) { + return 'skip: フォローしようとしているユーザーはローカルユーザーではありません'; + } + + await this.userFollowingService.follow(actor, followee, activity.id); + return 'ok'; + } + + private async like(actor: CacheableRemoteUser, activity: ILike): Promise { + const targetUri = getApId(activity.object); + + const note = await this.apNoteService.fetchNote(targetUri); + if (!note) return `skip: target note not found ${targetUri}`; + + await this.apNoteService.extractEmojis(activity.tag ?? [], actor.host).catch(() => null); + + return await this.reactionService.create(actor, note, activity._misskey_reaction ?? activity.content ?? activity.name).catch(err => { + if (err.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') { + return 'skip: already reacted'; + } else { + throw err; + } + }).then(() => 'ok'); + } + + private async read(actor: CacheableRemoteUser, activity: IRead): Promise { + const id = await getApId(activity.object); + + if (!this.utilityService.isSelfHost(this.utilityService.extractDbHost(id))) { + return `skip: Read to foreign host (${id})`; + } + + const messageId = id.split('/').pop(); + + const message = await this.messagingMessagesRepository.findOneBy({ id: messageId }); + if (message == null) { + return 'skip: message not found'; + } + + if (actor.id !== message.recipientId) { + return 'skip: actor is not a message recipient'; + } + + await this.messagingService.readUserMessagingMessage(message.recipientId!, message.userId, [message.id]); + return `ok: mark as read (${message.userId} => ${message.recipientId} ${message.id})`; + } + + private async accept(actor: CacheableRemoteUser, activity: IAccept): Promise { + const uri = activity.id ?? activity; + + this.logger.info(`Accept: ${uri}`); + + const resolver = this.apResolverService.createResolver(); + + const object = await resolver.resolve(activity.object).catch(err => { + this.logger.error(`Resolution failed: ${err}`); + throw err; + }); + + if (isFollow(object)) return await this.acceptFollow(actor, object); + + return `skip: Unknown Accept type: ${getApType(object)}`; + } + + private async acceptFollow(actor: CacheableRemoteUser, activity: IFollow): Promise { + // ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある + + const follower = await this.apDbResolverService.getUserFromApId(activity.actor); + + if (follower == null) { + return 'skip: follower not found'; + } + + if (follower.host != null) { + return 'skip: follower is not a local user'; + } + + // relay + const match = activity.id?.match(/follow-relay\/(\w+)/); + if (match) { + return await this.relayService.relayAccepted(match[1]); + } + + await this.userFollowingService.acceptFollowRequest(actor, follower); + return 'ok'; + } + + private async add(actor: CacheableRemoteUser, activity: IAdd): Promise { + if ('actor' in activity && actor.uri !== activity.actor) { + throw new Error('invalid actor'); + } + + if (activity.target == null) { + throw new Error('target is null'); + } + + if (activity.target === actor.featured) { + const note = await this.apNoteService.resolveNote(activity.object); + if (note == null) throw new Error('note not found'); + await this.notePiningService.addPinned(actor, note.id); + return; + } + + throw new Error(`unknown target: ${activity.target}`); + } + + private async announce(actor: CacheableRemoteUser, activity: IAnnounce): Promise { + const uri = getApId(activity); + + this.logger.info(`Announce: ${uri}`); + + const targetUri = getApId(activity.object); + + this.announceNote(actor, activity, targetUri); + } + + private async announceNote(actor: CacheableRemoteUser, activity: IAnnounce, targetUri: string): Promise { + const uri = getApId(activity); + + if (actor.isSuspended) { + return; + } + + // アナウンス先をブロックしてたら中断 + const meta = await this.metaService.fetch(); + if (meta.blockedHosts.includes(this.utilityService.extractDbHost(uri))) return; + + const unlock = await this.appLockService.getApLock(uri); + + try { + // 既に同じURIを持つものが登録されていないかチェック + const exist = await this.apNoteService.fetchNote(uri); + if (exist) { + return; + } + + // Announce対象をresolve + let renote; + try { + renote = await this.apNoteService.resolveNote(targetUri); + } catch (err) { + // 対象が4xxならスキップ + if (err instanceof StatusError) { + if (err.isClientError) { + this.logger.warn(`Ignored announce target ${targetUri} - ${err.statusCode}`); + return; + } + + this.logger.warn(`Error in announce target ${targetUri} - ${err.statusCode ?? err}`); + } + throw err; + } + + if (!await this.noteEntityService.isVisibleForMe(renote, actor.id)) return 'skip: invalid actor for this activity'; + + this.logger.info(`Creating the (Re)Note: ${uri}`); + + const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc); + + await this.noteCreateService.create(actor, { + createdAt: activity.published ? new Date(activity.published) : null, + renote, + visibility: activityAudience.visibility, + visibleUsers: activityAudience.visibleUsers, + uri, + }); + } finally { + unlock(); + } + } + + private async block(actor: CacheableRemoteUser, activity: IBlock): Promise { + // ※ activity.objectにブロック対象があり、それは存在するローカルユーザーのはず + + const blockee = await this.apDbResolverService.getUserFromApId(activity.object); + + if (blockee == null) { + return 'skip: blockee not found'; + } + + if (blockee.host != null) { + return 'skip: ブロックしようとしているユーザーはローカルユーザーではありません'; + } + + await this.userBlockingService.block(await this.usersRepository.findOneByOrFail({ id: actor.id }), await this.usersRepository.findOneByOrFail({ id: blockee.id })); + return 'ok'; + } + + private async create(actor: CacheableRemoteUser, activity: ICreate): Promise { + const uri = getApId(activity); + + this.logger.info(`Create: ${uri}`); + + // copy audiences between activity <=> object. + if (typeof activity.object === 'object') { + const to = unique(concat([toArray(activity.to), toArray(activity.object.to)])); + const cc = unique(concat([toArray(activity.cc), toArray(activity.object.cc)])); + + activity.to = to; + activity.cc = cc; + activity.object.to = to; + activity.object.cc = cc; + } + + // If there is no attributedTo, use Activity actor. + if (typeof activity.object === 'object' && !activity.object.attributedTo) { + activity.object.attributedTo = activity.actor; + } + + const resolver = this.apResolverService.createResolver(); + + const object = await resolver.resolve(activity.object).catch(e => { + this.logger.error(`Resolution failed: ${e}`); + throw e; + }); + + if (isPost(object)) { + this.createNote(resolver, actor, object, false, activity); + } else { + this.logger.warn(`Unknown type: ${getApType(object)}`); + } + } + + private async createNote(resolver: Resolver, actor: CacheableRemoteUser, note: IObject, silent = false, activity?: ICreate): 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 exist = await this.apNoteService.fetchNote(note); + if (exist) return 'skip: note exists'; + + await this.apNoteService.createNote(note, resolver, silent); + return 'ok'; + } catch (err) { + if (err instanceof StatusError && err.isClientError) { + return `skip ${err.statusCode}`; + } else { + throw err; + } + } finally { + unlock(); + } + } + + private async delete(actor: CacheableRemoteUser, activity: IDelete): Promise { + if ('actor' in activity && actor.uri !== activity.actor) { + throw new Error('invalid actor'); + } + + // 削除対象objectのtype + let formerType: string | undefined; + + if (typeof activity.object === 'string') { + // typeが不明だけど、どうせ消えてるのでremote resolveしない + formerType = undefined; + } else { + const object = activity.object as IObject; + if (isTombstone(object)) { + formerType = toSingle(object.formerType); + } else { + formerType = toSingle(object.type); + } + } + + const uri = getApId(activity.object); + + // type不明でもactorとobjectが同じならばそれはPersonに違いない + if (!formerType && actor.uri === uri) { + formerType = 'Person'; + } + + // それでもなかったらおそらくNote + if (!formerType) { + formerType = 'Note'; + } + + if (validPost.includes(formerType)) { + return await this.deleteNote(actor, uri); + } else if (validActor.includes(formerType)) { + return await this.deleteActor(actor, uri); + } else { + return `Unknown type ${formerType}`; + } + } + + private async deleteActor(actor: CacheableRemoteUser, uri: string): Promise { + this.logger.info(`Deleting the Actor: ${uri}`); + + if (actor.uri !== uri) { + return `skip: delete actor ${actor.uri} !== ${uri}`; + } + + const user = await this.usersRepository.findOneByOrFail({ id: actor.id }); + if (user.isDeleted) { + this.logger.info('skip: already deleted'); + } + + const job = await this.queueService.createDeleteAccountJob(actor); + + await this.usersRepository.update(actor.id, { + isDeleted: true, + }); + + return `ok: queued ${job.name} ${job.id}`; + } + + private async deleteNote(actor: CacheableRemoteUser, uri: string): Promise { + this.logger.info(`Deleting the Note: ${uri}`); + + const unlock = await this.appLockService.getApLock(uri); + + try { + const note = await this.apDbResolverService.getNoteFromApId(uri); + + if (note == null) { + const message = await this.apDbResolverService.getMessageFromApId(uri); + if (message == null) return 'message not found'; + + if (message.userId !== actor.id) { + return '投稿を削除しようとしているユーザーは投稿の作成者ではありません'; + } + + await this.messagingService.deleteMessage(message); + + return 'ok: message deleted'; + } + + if (note.userId !== actor.id) { + return '投稿を削除しようとしているユーザーは投稿の作成者ではありません'; + } + + await this.noteDeleteService.delete(actor, note); + return 'ok: note deleted'; + } finally { + unlock(); + } + } + + private async flag(actor: CacheableRemoteUser, activity: IFlag): Promise { + // objectは `(User|Note) | (User|Note)[]` だけど、全パターンDBスキーマと対応させられないので + // 対象ユーザーは一番最初のユーザー として あとはコメントとして格納する + const uris = getApIds(activity.object); + + const userIds = uris.filter(uri => uri.startsWith(this.config.url + '/users/')).map(uri => uri.split('/').pop()!); + const users = await this.usersRepository.findBy({ + id: In(userIds), + }); + if (users.length < 1) return 'skip'; + + await this.abuseUserReportsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + targetUserId: users[0].id, + targetUserHost: users[0].host, + reporterId: actor.id, + reporterHost: actor.host, + comment: `${activity.content}\n${JSON.stringify(uris, null, 2)}`, + }); + + return 'ok'; + } + + private async reject(actor: CacheableRemoteUser, activity: IReject): Promise { + const uri = activity.id ?? activity; + + this.logger.info(`Reject: ${uri}`); + + const resolver = this.apResolverService.createResolver(); + + const object = await resolver.resolve(activity.object).catch(e => { + this.logger.error(`Resolution failed: ${e}`); + throw e; + }); + + if (isFollow(object)) return await this.rejectFollow(actor, object); + + return `skip: Unknown Reject type: ${getApType(object)}`; + } + + private async rejectFollow(actor: CacheableRemoteUser, activity: IFollow): Promise { + // ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある + + const follower = await this.apDbResolverService.getUserFromApId(activity.actor); + + if (follower == null) { + return 'skip: follower not found'; + } + + if (!this.userEntityService.isLocalUser(follower)) { + return 'skip: follower is not a local user'; + } + + // relay + const match = activity.id?.match(/follow-relay\/(\w+)/); + if (match) { + return await this.relayService.relayRejected(match[1]); + } + + await this.userFollowingService.remoteReject(actor, follower); + return 'ok'; + } + + private async remove(actor: CacheableRemoteUser, activity: IRemove): Promise { + if ('actor' in activity && actor.uri !== activity.actor) { + throw new Error('invalid actor'); + } + + if (activity.target == null) { + throw new Error('target is null'); + } + + if (activity.target === actor.featured) { + const note = await this.apNoteService.resolveNote(activity.object); + if (note == null) throw new Error('note not found'); + await this.notePiningService.removePinned(actor, note.id); + return; + } + + throw new Error(`unknown target: ${activity.target}`); + } + + private async undo(actor: CacheableRemoteUser, activity: IUndo): Promise { + if ('actor' in activity && actor.uri !== activity.actor) { + throw new Error('invalid actor'); + } + + const uri = activity.id ?? activity; + + this.logger.info(`Undo: ${uri}`); + + const resolver = this.apResolverService.createResolver(); + + const object = await resolver.resolve(activity.object).catch(e => { + this.logger.error(`Resolution failed: ${e}`); + throw e; + }); + + if (isFollow(object)) return await this.undoFollow(actor, object); + if (isBlock(object)) return await this.undoBlock(actor, object); + if (isLike(object)) return await this.undoLike(actor, object); + if (isAnnounce(object)) return await this.undoAnnounce(actor, object); + if (isAccept(object)) return await this.undoAccept(actor, object); + + return `skip: unknown object type ${getApType(object)}`; + } + + private async undoAccept(actor: CacheableRemoteUser, activity: IAccept): Promise { + const follower = await this.apDbResolverService.getUserFromApId(activity.object); + if (follower == null) { + return 'skip: follower not found'; + } + + const following = await this.followingsRepository.findOneBy({ + followerId: follower.id, + followeeId: actor.id, + }); + + if (following) { + await this.userFollowingService.unfollow(follower, actor); + return 'ok: unfollowed'; + } + + return 'skip: フォローされていない'; + } + + private async undoAnnounce(actor: CacheableRemoteUser, activity: IAnnounce): Promise { + const uri = getApId(activity); + + const note = await this.notesRepository.findOneBy({ + uri, + userId: actor.id, + }); + + if (!note) return 'skip: no such Announce'; + + await this.noteDeleteService.delete(actor, note); + return 'ok: deleted'; + } + + private async undoBlock(actor: CacheableRemoteUser, activity: IBlock): Promise { + const blockee = await this.apDbResolverService.getUserFromApId(activity.object); + + if (blockee == null) { + return 'skip: blockee not found'; + } + + if (blockee.host != null) { + return 'skip: ブロック解除しようとしているユーザーはローカルユーザーではありません'; + } + + await this.userBlockingService.unblock(await this.usersRepository.findOneByOrFail({ id: actor.id }), blockee); + return 'ok'; + } + + private async undoFollow(actor: CacheableRemoteUser, activity: IFollow): Promise { + const followee = await this.apDbResolverService.getUserFromApId(activity.object); + if (followee == null) { + return 'skip: followee not found'; + } + + if (followee.host != null) { + return 'skip: フォロー解除しようとしているユーザーはローカルユーザーではありません'; + } + + const req = await this.followRequestsRepository.findOneBy({ + followerId: actor.id, + followeeId: followee.id, + }); + + const following = await this.followingsRepository.findOneBy({ + followerId: actor.id, + followeeId: followee.id, + }); + + if (req) { + await this.userFollowingService.cancelFollowRequest(followee, actor); + return 'ok: follow request canceled'; + } + + if (following) { + await this.userFollowingService.unfollow(actor, followee); + return 'ok: unfollowed'; + } + + return 'skip: リクエストもフォローもされていない'; + } + + private async undoLike(actor: CacheableRemoteUser, activity: ILike): Promise { + const targetUri = getApId(activity.object); + + const note = await this.apNoteService.fetchNote(targetUri); + if (!note) return `skip: target note not found ${targetUri}`; + + await this.reactionService.delete(actor, note).catch(e => { + if (e.id === '60527ec9-b4cb-4a88-a6bd-32d3ad26817d') return; + throw e; + }); + + return 'ok'; + } + + private async update(actor: CacheableRemoteUser, activity: IUpdate): Promise { + if ('actor' in activity && actor.uri !== activity.actor) { + return 'skip: invalid actor'; + } + + this.logger.debug('Update'); + + const resolver = this.apResolverService.createResolver(); + + const object = await resolver.resolve(activity.object).catch(e => { + this.logger.error(`Resolution failed: ${e}`); + throw e; + }); + + if (isActor(object)) { + await this.apPersonService.updatePerson(actor.uri!, resolver, object); + return 'ok: Person updated'; + } else if (getApType(object) === 'Question') { + await this.apQuestionService.updateQuestion(object).catch(err => console.error(err)); + return 'ok: Question updated'; + } else { + return `skip: Unknown type: ${getApType(object)}`; + } + } +} diff --git a/packages/backend/src/core/remote/activitypub/ApLoggerService.ts b/packages/backend/src/core/remote/activitypub/ApLoggerService.ts new file mode 100644 index 000000000..82fd7c5f1 --- /dev/null +++ b/packages/backend/src/core/remote/activitypub/ApLoggerService.ts @@ -0,0 +1,14 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type Logger from '@/logger.js'; +import { RemoteLoggerService } from '@/core/remote/RemoteLoggerService.js'; + +@Injectable() +export class ApLoggerService { + public logger: Logger; + + constructor( + private remoteLoggerService: RemoteLoggerService, + ) { + this.logger = this.remoteLoggerService.logger.createSubLogger('ap', 'magenta'); + } +} diff --git a/packages/backend/src/core/remote/activitypub/ApMfmService.ts b/packages/backend/src/core/remote/activitypub/ApMfmService.ts new file mode 100644 index 000000000..3c3b98b13 --- /dev/null +++ b/packages/backend/src/core/remote/activitypub/ApMfmService.ts @@ -0,0 +1,30 @@ +import { Inject, Injectable } from '@nestjs/common'; +import * as mfm from 'mfm-js'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import { MfmService } from '@/core/MfmService.js'; +import type { Note } from '@/models/entities/Note.js'; +import { extractApHashtagObjects } from './models/tag.js'; +import type { IObject } from './type.js'; + +@Injectable() +export class ApMfmService { + constructor( + @Inject(DI.config) + private config: Config, + + private mfmService: MfmService, + ) { + } + + public htmlToMfm(html: string, tag?: IObject | IObject[]) { + const hashtagNames = extractApHashtagObjects(tag).map(x => x.name).filter((x): x is string => x != null); + + return this.mfmService.fromHtml(html, hashtagNames); + } + + public getNoteHtml(note: Note) { + if (!note.text) return ''; + return this.mfmService.toHtml(mfm.parse(note.text), JSON.parse(note.mentionedRemoteUsers)); + } +} diff --git a/packages/backend/src/core/remote/activitypub/ApRendererService.ts b/packages/backend/src/core/remote/activitypub/ApRendererService.ts new file mode 100644 index 000000000..5a4cef63e --- /dev/null +++ b/packages/backend/src/core/remote/activitypub/ApRendererService.ts @@ -0,0 +1,702 @@ +import { createPublicKey } from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import { In, IsNull } from 'typeorm'; +import { v4 as uuid } from 'uuid'; +import * as mfm from 'mfm-js'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import type { ILocalUser, IRemoteUser, User } from '@/models/entities/User.js'; +import type { IMentionedRemoteUsers, Note } from '@/models/entities/Note.js'; +import type { Blocking } from '@/models/entities/Blocking.js'; +import type { Relay } from '@/models/entities/Relay.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import type { NoteReaction } from '@/models/entities/NoteReaction.js'; +import type { Emoji } from '@/models/entities/Emoji.js'; +import type { Poll } from '@/models/entities/Poll.js'; +import type { MessagingMessage } from '@/models/entities/MessagingMessage.js'; +import type { PollVote } from '@/models/entities/PollVote.js'; +import { UserKeypairStoreService } from '@/core/UserKeypairStoreService.js'; +import { MfmService } from '@/core/MfmService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import type { UserKeypair } from '@/models/entities/UserKeypair.js'; +import { LdSignatureService } from './LdSignatureService.js'; +import { ApMfmService } from './ApMfmService.js'; +import type { IActivity } from './type.js'; +import type { IIdentifier } from './models/identifier.js'; + +@Injectable() +export class ApRendererService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, + + @Inject(DI.pollsRepository) + private pollsRepository: PollsRepository, + + private userEntityService: UserEntityService, + private driveFileEntityService: DriveFileEntityService, + private ldSignatureService: LdSignatureService, + private userKeypairStoreService: UserKeypairStoreService, + private apMfmService: ApMfmService, + private mfmService: MfmService, + ) { + } + + public renderAccept(object: any, user: { id: User['id']; host: null }) { + return { + type: 'Accept', + actor: `${this.config.url}/users/${user.id}`, + object, + }; + } + + public renderAdd(user: ILocalUser, target: any, object: any) { + return { + type: 'Add', + actor: `${this.config.url}/users/${user.id}`, + target, + object, + }; + } + + public renderAnnounce(object: any, note: Note) { + const attributedTo = `${this.config.url}/users/${note.userId}`; + + let to: string[] = []; + let cc: string[] = []; + + if (note.visibility === 'public') { + to = ['https://www.w3.org/ns/activitystreams#Public']; + cc = [`${attributedTo}/followers`]; + } else if (note.visibility === 'home') { + to = [`${attributedTo}/followers`]; + cc = ['https://www.w3.org/ns/activitystreams#Public']; + } else { + return null; + } + + return { + id: `${this.config.url}/notes/${note.id}/activity`, + actor: `${this.config.url}/users/${note.userId}`, + type: 'Announce', + published: note.createdAt.toISOString(), + to, + cc, + object, + }; + } + + /** + * Renders a block into its ActivityPub representation. + * + * @param block The block to be rendered. The blockee relation must be loaded. + */ + public renderBlock(block: Blocking) { + if (block.blockee?.uri == null) { + throw new Error('renderBlock: missing blockee uri'); + } + + return { + type: 'Block', + id: `${this.config.url}/blocks/${block.id}`, + actor: `${this.config.url}/users/${block.blockerId}`, + object: block.blockee.uri, + }; + } + + public renderCreate(object: any, note: Note) { + const activity = { + id: `${this.config.url}/notes/${note.id}/activity`, + actor: `${this.config.url}/users/${note.userId}`, + type: 'Create', + published: note.createdAt.toISOString(), + object, + } as any; + + if (object.to) activity.to = object.to; + if (object.cc) activity.cc = object.cc; + + return activity; + } + + public renderDelete(object: any, user: { id: User['id']; host: null }) { + return { + type: 'Delete', + actor: `${this.config.url}/users/${user.id}`, + object, + published: new Date().toISOString(), + }; + } + + public renderDocument(file: DriveFile) { + return { + type: 'Document', + mediaType: file.type, + url: this.driveFileEntityService.getPublicUrl(file), + name: file.comment, + }; + } + + public renderEmoji(emoji: Emoji) { + return { + id: `${this.config.url}/emojis/${emoji.name}`, + type: 'Emoji', + name: `:${emoji.name}:`, + updated: emoji.updatedAt != null ? emoji.updatedAt.toISOString() : new Date().toISOString, + icon: { + type: 'Image', + mediaType: emoji.type ?? 'image/png', + url: emoji.publicUrl ?? emoji.originalUrl, // ?? emoji.originalUrl してるのは後方互換性のため + }, + }; + } + + // to anonymise reporters, the reporting actor must be a system user + // object has to be a uri or array of uris + public renderFlag(user: ILocalUser, object: [string], content: string) { + return { + type: 'Flag', + actor: `${this.config.url}/users/${user.id}`, + content, + object, + }; + } + + public renderFollowRelay(relay: Relay, relayActor: ILocalUser) { + const follow = { + id: `${this.config.url}/activities/follow-relay/${relay.id}`, + type: 'Follow', + actor: `${this.config.url}/users/${relayActor.id}`, + object: 'https://www.w3.org/ns/activitystreams#Public', + }; + + return follow; + } + + /** + * Convert (local|remote)(Follower|Followee)ID to URL + * @param id Follower|Followee ID + */ + public async renderFollowUser(id: User['id']) { + const user = await this.usersRepository.findOneByOrFail({ id: id }); + return this.userEntityService.isLocalUser(user) ? `${this.config.url}/users/${user.id}` : user.uri; + } + + public renderFollow( + follower: { id: User['id']; host: User['host']; uri: User['host'] }, + followee: { id: User['id']; host: User['host']; uri: User['host'] }, + requestId?: string, + ) { + const follow = { + id: requestId ?? `${this.config.url}/follows/${follower.id}/${followee.id}`, + type: 'Follow', + actor: this.userEntityService.isLocalUser(follower) ? `${this.config.url}/users/${follower.id}` : follower.uri, + object: this.userEntityService.isLocalUser(followee) ? `${this.config.url}/users/${followee.id}` : followee.uri, + } as any; + + return follow; + } + + public renderHashtag(tag: string) { + return { + type: 'Hashtag', + href: `${this.config.url}/tags/${encodeURIComponent(tag)}`, + name: `#${tag}`, + }; + } + + public renderImage(file: DriveFile) { + return { + type: 'Image', + url: this.driveFileEntityService.getPublicUrl(file), + sensitive: file.isSensitive, + name: file.comment, + }; + } + + public renderKey(user: ILocalUser, key: UserKeypair, postfix?: string) { + return { + id: `${this.config.url}/users/${user.id}${postfix ?? '/publickey'}`, + type: 'Key', + owner: `${this.config.url}/users/${user.id}`, + publicKeyPem: createPublicKey(key.publicKey).export({ + type: 'spki', + format: 'pem', + }), + }; + } + + public async renderLike(noteReaction: NoteReaction, note: Note) { + const reaction = noteReaction.reaction; + + const object = { + type: 'Like', + id: `${this.config.url}/likes/${noteReaction.id}`, + actor: `${this.config.url}/users/${noteReaction.userId}`, + object: note.uri ? note.uri : `${this.config.url}/notes/${noteReaction.noteId}`, + content: reaction, + _misskey_reaction: reaction, + } as any; + + if (reaction.startsWith(':')) { + const name = reaction.replace(/:/g, ''); + const emoji = await this.emojisRepository.findOneBy({ + name, + host: IsNull(), + }); + + if (emoji) object.tag = [this.renderEmoji(emoji)]; + } + + return object; + } + + public renderMention(mention: User) { + return { + type: 'Mention', + href: this.userEntityService.isRemoteUser(mention) ? mention.uri : `${this.config.url}/users/${(mention as ILocalUser).id}`, + name: this.userEntityService.isRemoteUser(mention) ? `@${mention.username}@${mention.host}` : `@${(mention as ILocalUser).username}`, + }; + } + + public async renderNote(note: Note, dive = true, isTalk = false): Promise> { + const getPromisedFiles = async (ids: string[]) => { + if (!ids || ids.length === 0) return []; + const items = await this.driveFilesRepository.findBy({ id: In(ids) }); + return ids.map(id => items.find(item => item.id === id)).filter(item => item != null) as DriveFile[]; + }; + + let inReplyTo; + let inReplyToNote: Note | null; + + if (note.replyId) { + inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId }); + + if (inReplyToNote != null) { + const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId }); + + if (inReplyToUser != null) { + if (inReplyToNote.uri) { + inReplyTo = inReplyToNote.uri; + } else { + if (dive) { + inReplyTo = await this.renderNote(inReplyToNote, false); + } else { + inReplyTo = `${this.config.url}/notes/${inReplyToNote.id}`; + } + } + } + } + } else { + inReplyTo = null; + } + + let quote; + + if (note.renoteId) { + const renote = await this.notesRepository.findOneBy({ id: note.renoteId }); + + if (renote) { + quote = renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`; + } + } + + const attributedTo = `${this.config.url}/users/${note.userId}`; + + const mentions = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri); + + let to: string[] = []; + let cc: string[] = []; + + if (note.visibility === 'public') { + to = ['https://www.w3.org/ns/activitystreams#Public']; + cc = [`${attributedTo}/followers`].concat(mentions); + } else if (note.visibility === 'home') { + to = [`${attributedTo}/followers`]; + cc = ['https://www.w3.org/ns/activitystreams#Public'].concat(mentions); + } else if (note.visibility === 'followers') { + to = [`${attributedTo}/followers`]; + cc = mentions; + } else { + to = mentions; + } + + const mentionedUsers = note.mentions.length > 0 ? await this.usersRepository.findBy({ + id: In(note.mentions), + }) : []; + + const hashtagTags = (note.tags ?? []).map(tag => this.renderHashtag(tag)); + const mentionTags = mentionedUsers.map(u => this.renderMention(u)); + + const files = await getPromisedFiles(note.fileIds); + + const text = note.text ?? ''; + let poll: Poll | null = null; + + if (note.hasPoll) { + poll = await this.pollsRepository.findOneBy({ noteId: note.id }); + } + + let apText = text; + + if (quote) { + apText += `\n\nRE: ${quote}`; + } + + const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; + + const content = this.apMfmService.getNoteHtml(Object.assign({}, note, { + text: apText, + })); + + const emojis = await this.getEmojis(note.emojis); + const apemojis = emojis.map(emoji => this.renderEmoji(emoji)); + + const tag = [ + ...hashtagTags, + ...mentionTags, + ...apemojis, + ]; + + const asPoll = poll ? { + type: 'Question', + content: this.apMfmService.getNoteHtml(Object.assign({}, note, { + text: text, + })), + [poll.expiresAt && poll.expiresAt < new Date() ? 'closed' : 'endTime']: poll.expiresAt, + [poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({ + type: 'Note', + name: text, + replies: { + type: 'Collection', + totalItems: poll!.votes[i], + }, + })), + } : {}; + + const asTalk = isTalk ? { + _misskey_talk: true, + } : {}; + + return { + id: `${this.config.url}/notes/${note.id}`, + type: 'Note', + attributedTo, + summary, + content, + _misskey_content: text, + source: { + content: text, + mediaType: 'text/x.misskeymarkdown', + }, + _misskey_quote: quote, + quoteUrl: quote, + published: note.createdAt.toISOString(), + to, + cc, + inReplyTo, + attachment: files.map(x => this.renderDocument(x)), + sensitive: note.cw != null || files.some(file => file.isSensitive), + tag, + ...asPoll, + ...asTalk, + }; + } + + public async renderPerson(user: ILocalUser) { + const id = `${this.config.url}/users/${user.id}`; + const isSystem = !!user.username.match(/\./); + + const [avatar, banner, profile] = await Promise.all([ + user.avatarId ? this.driveFilesRepository.findOneBy({ id: user.avatarId }) : Promise.resolve(undefined), + user.bannerId ? this.driveFilesRepository.findOneBy({ id: user.bannerId }) : Promise.resolve(undefined), + this.userProfilesRepository.findOneByOrFail({ userId: user.id }), + ]); + + const attachment: { + type: 'PropertyValue', + name: string, + value: string, + identifier?: IIdentifier, + }[] = []; + + if (profile.fields) { + for (const field of profile.fields) { + attachment.push({ + type: 'PropertyValue', + name: field.name, + value: (field.value != null && field.value.match(/^https?:/)) + ? `${new URL(field.value).href}` + : field.value, + }); + } + } + + const emojis = await this.getEmojis(user.emojis); + const apemojis = emojis.map(emoji => this.renderEmoji(emoji)); + + const hashtagTags = (user.tags ?? []).map(tag => this.renderHashtag(tag)); + + const tag = [ + ...apemojis, + ...hashtagTags, + ]; + + const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); + + const person = { + type: isSystem ? 'Application' : user.isBot ? 'Service' : 'Person', + id, + inbox: `${id}/inbox`, + outbox: `${id}/outbox`, + followers: `${id}/followers`, + following: `${id}/following`, + featured: `${id}/collections/featured`, + sharedInbox: `${this.config.url}/inbox`, + endpoints: { sharedInbox: `${this.config.url}/inbox` }, + url: `${this.config.url}/@${user.username}`, + preferredUsername: user.username, + name: user.name, + summary: profile.description ? this.mfmService.toHtml(mfm.parse(profile.description)) : null, + icon: avatar ? this.renderImage(avatar) : null, + image: banner ? this.renderImage(banner) : null, + tag, + manuallyApprovesFollowers: user.isLocked, + discoverable: !!user.isExplorable, + publicKey: this.renderKey(user, keypair, '#main-key'), + isCat: user.isCat, + attachment: attachment.length ? attachment : undefined, + } as any; + + if (profile.birthday) { + person['vcard:bday'] = profile.birthday; + } + + if (profile.location) { + person['vcard:Address'] = profile.location; + } + + return person; + } + + public async renderQuestion(user: { id: User['id'] }, note: Note, poll: Poll) { + const question = { + type: 'Question', + id: `${this.config.url}/questions/${note.id}`, + actor: `${this.config.url}/users/${user.id}`, + content: note.text ?? '', + [poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({ + name: text, + _misskey_votes: poll.votes[i], + replies: { + type: 'Collection', + totalItems: poll.votes[i], + }, + })), + }; + + return question; + } + + public renderRead(user: { id: User['id'] }, message: MessagingMessage) { + return { + type: 'Read', + actor: `${this.config.url}/users/${user.id}`, + object: message.uri, + }; + } + + public renderReject(object: any, user: { id: User['id'] }) { + return { + type: 'Reject', + actor: `${this.config.url}/users/${user.id}`, + object, + }; + } + + public renderRemove(user: { id: User['id'] }, target: any, object: any) { + return { + type: 'Remove', + actor: `${this.config.url}/users/${user.id}`, + target, + object, + }; + } + + public renderTombstone(id: string) { + return { + id, + type: 'Tombstone', + }; + } + + public renderUndo(object: any, user: { id: User['id'] }) { + if (object == null) return null; + const id = typeof object.id === 'string' && object.id.startsWith(this.config.url) ? `${object.id}/undo` : undefined; + + return { + type: 'Undo', + ...(id ? { id } : {}), + actor: `${this.config.url}/users/${user.id}`, + object, + published: new Date().toISOString(), + }; + } + + public renderUpdate(object: any, user: { id: User['id'] }) { + const activity = { + id: `${this.config.url}/users/${user.id}#updates/${new Date().getTime()}`, + actor: `${this.config.url}/users/${user.id}`, + type: 'Update', + to: ['https://www.w3.org/ns/activitystreams#Public'], + object, + published: new Date().toISOString(), + } as any; + + return activity; + } + + public renderVote(user: { id: User['id'] }, vote: PollVote, note: Note, poll: Poll, pollOwner: IRemoteUser) { + return { + id: `${this.config.url}/users/${user.id}#votes/${vote.id}/activity`, + actor: `${this.config.url}/users/${user.id}`, + type: 'Create', + to: [pollOwner.uri], + published: new Date().toISOString(), + object: { + id: `${this.config.url}/users/${user.id}#votes/${vote.id}`, + type: 'Note', + attributedTo: `${this.config.url}/users/${user.id}`, + to: [pollOwner.uri], + inReplyTo: note.uri, + name: poll.choices[vote.choice], + }, + }; + } + + public renderActivity(x: any): IActivity | null { + if (x == null) return null; + + if (typeof x === 'object' && x.id == null) { + x.id = `${this.config.url}/${uuid()}`; + } + + return Object.assign({ + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + { + // as non-standards + manuallyApprovesFollowers: 'as:manuallyApprovesFollowers', + sensitive: 'as:sensitive', + Hashtag: 'as:Hashtag', + quoteUrl: 'as:quoteUrl', + // Mastodon + toot: 'http://joinmastodon.org/ns#', + Emoji: 'toot:Emoji', + featured: 'toot:featured', + discoverable: 'toot:discoverable', + // schema + schema: 'http://schema.org#', + PropertyValue: 'schema:PropertyValue', + value: 'schema:value', + // Misskey + misskey: 'https://misskey-hub.net/ns#', + '_misskey_content': 'misskey:_misskey_content', + '_misskey_quote': 'misskey:_misskey_quote', + '_misskey_reaction': 'misskey:_misskey_reaction', + '_misskey_votes': 'misskey:_misskey_votes', + '_misskey_talk': 'misskey:_misskey_talk', + 'isCat': 'misskey:isCat', + // vcard + vcard: 'http://www.w3.org/2006/vcard/ns#', + }, + ], + }, x); + } + + public async attachLdSignature(activity: any, user: { id: User['id']; host: null; }): Promise { + const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); + + const ldSignature = this.ldSignatureService.use(); + ldSignature.debug = false; + activity = await ldSignature.signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`); + + return activity; + } + + /** + * Render OrderedCollectionPage + * @param id URL of self + * @param totalItems Number of total items + * @param orderedItems Items + * @param partOf URL of base + * @param prev URL of prev page (optional) + * @param next URL of next page (optional) + */ + public renderOrderedCollectionPage(id: string, totalItems: any, orderedItems: any, partOf: string, prev?: string, next?: string) { + const page = { + id, + partOf, + type: 'OrderedCollectionPage', + totalItems, + orderedItems, + } as any; + + if (prev) page.prev = prev; + if (next) page.next = next; + + return page; + } + + /** + * Render OrderedCollection + * @param id URL of self + * @param totalItems Total number of items + * @param first URL of first page (optional) + * @param last URL of last page (optional) + * @param orderedItems attached objects (optional) + */ + public renderOrderedCollection(id: string | null, totalItems: any, first?: string, last?: string, orderedItems?: Record[]) { + const page: any = { + id, + type: 'OrderedCollection', + totalItems, + }; + + if (first) page.first = first; + if (last) page.last = last; + if (orderedItems) page.orderedItems = orderedItems; + + return page; + } + + private async getEmojis(names: string[]): Promise { + if (names == null || names.length === 0) return []; + + const emojis = await Promise.all( + names.map(name => this.emojisRepository.findOneBy({ + name, + host: IsNull(), + })), + ); + + return emojis.filter(emoji => emoji != null) as Emoji[]; + } +} diff --git a/packages/backend/src/core/remote/activitypub/ApRequestService.ts b/packages/backend/src/core/remote/activitypub/ApRequestService.ts new file mode 100644 index 000000000..2abaca06a --- /dev/null +++ b/packages/backend/src/core/remote/activitypub/ApRequestService.ts @@ -0,0 +1,182 @@ +import * as crypto from 'node:crypto'; +import { URL } from 'node:url'; +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import type { User } from '@/models/entities/User.js'; +import { UserKeypairStoreService } from '@/core/UserKeypairStoreService.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; + +type Request = { + url: string; + method: string; + headers: Record; +}; + +type Signed = { + request: Request; + signingString: string; + signature: string; + signatureHeader: string; +}; + +type PrivateKey = { + privateKeyPem: string; + keyId: string; +}; + +@Injectable() +export class ApRequestService { + constructor( + @Inject(DI.config) + private config: Config, + + private userKeypairStoreService: UserKeypairStoreService, + private httpRequestService: HttpRequestService, + ) { + } + + private createSignedPost(args: { key: PrivateKey, url: string, body: string, additionalHeaders: Record }): Signed { + const u = new URL(args.url); + const digestHeader = `SHA-256=${crypto.createHash('sha256').update(args.body).digest('base64')}`; + + const request: Request = { + url: u.href, + method: 'POST', + headers: this.objectAssignWithLcKey({ + 'Date': new Date().toUTCString(), + 'Host': u.hostname, + 'Content-Type': 'application/activity+json', + 'Digest': digestHeader, + }, args.additionalHeaders), + }; + + const result = this.signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'digest']); + + return { + request, + signingString: result.signingString, + signature: result.signature, + signatureHeader: result.signatureHeader, + }; + } + + private createSignedGet(args: { key: PrivateKey, url: string, additionalHeaders: Record }): Signed { + const u = new URL(args.url); + + const request: Request = { + url: u.href, + method: 'GET', + headers: this.objectAssignWithLcKey({ + 'Accept': 'application/activity+json, application/ld+json', + 'Date': new Date().toUTCString(), + 'Host': new URL(args.url).hostname, + }, args.additionalHeaders), + }; + + const result = this.signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'accept']); + + return { + request, + signingString: result.signingString, + signature: result.signature, + signatureHeader: result.signatureHeader, + }; + } + + private signToRequest(request: Request, key: PrivateKey, includeHeaders: string[]): Signed { + const signingString = this.genSigningString(request, includeHeaders); + const signature = crypto.sign('sha256', Buffer.from(signingString), key.privateKeyPem).toString('base64'); + const signatureHeader = `keyId="${key.keyId}",algorithm="rsa-sha256",headers="${includeHeaders.join(' ')}",signature="${signature}"`; + + request.headers = this.objectAssignWithLcKey(request.headers, { + Signature: signatureHeader, + }); + + return { + request, + signingString, + signature, + signatureHeader, + }; + } + + private genSigningString(request: Request, includeHeaders: string[]): string { + request.headers = this.lcObjectKey(request.headers); + + const results: string[] = []; + + for (const key of includeHeaders.map(x => x.toLowerCase())) { + if (key === '(request-target)') { + results.push(`(request-target): ${request.method.toLowerCase()} ${new URL(request.url).pathname}`); + } else { + results.push(`${key}: ${request.headers[key]}`); + } + } + + return results.join('\n'); + } + + private lcObjectKey(src: Record): Record { + const dst: Record = {}; + for (const key of Object.keys(src).filter(x => x !== '__proto__' && typeof src[x] === 'string')) dst[key.toLowerCase()] = src[key]; + return dst; + } + + private objectAssignWithLcKey(a: Record, b: Record): Record { + return Object.assign(this.lcObjectKey(a), this.lcObjectKey(b)); + } + + public async signedPost(user: { id: User['id'] }, url: string, object: any) { + const body = JSON.stringify(object); + + const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); + + const req = this.createSignedPost({ + key: { + privateKeyPem: keypair.privateKey, + keyId: `${this.config.url}/users/${user.id}#main-key`, + }, + url, + body, + additionalHeaders: { + 'User-Agent': this.config.userAgent, + }, + }); + + await this.httpRequestService.getResponse({ + url, + method: req.request.method, + headers: req.request.headers, + body, + }); + } + + /** + * Get AP object with http-signature + * @param user http-signature user + * @param url URL to fetch + */ + public async signedGet(url: string, user: { id: User['id'] }) { + const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); + + const req = this.createSignedGet({ + key: { + privateKeyPem: keypair.privateKey, + keyId: `${this.config.url}/users/${user.id}#main-key`, + }, + url, + additionalHeaders: { + 'User-Agent': this.config.userAgent, + }, + }); + + const res = await this.httpRequestService.getResponse({ + url, + method: req.request.method, + headers: req.request.headers, + }); + + return await res.json(); + } +} diff --git a/packages/backend/src/core/remote/activitypub/ApResolverService.ts b/packages/backend/src/core/remote/activitypub/ApResolverService.ts new file mode 100644 index 000000000..9d8e17775 --- /dev/null +++ b/packages/backend/src/core/remote/activitypub/ApResolverService.ts @@ -0,0 +1,190 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { ILocalUser } from '@/models/entities/User.js'; +import { InstanceActorService } from '@/core/InstanceActorService.js'; +import { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import { MetaService } from '@/core/MetaService.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { DI } from '@/di-symbols.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { isCollectionOrOrderedCollection } from './type.js'; +import { ApDbResolverService } from './ApDbResolverService.js'; +import { ApRendererService } from './ApRendererService.js'; +import { ApRequestService } from './ApRequestService.js'; +import type { IObject, ICollection, IOrderedCollection } from './type.js'; + +@Injectable() +export class ApResolverService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.pollsRepository) + private pollsRepository: PollsRepository, + + @Inject(DI.noteReactionsRepository) + private noteReactionsRepository: NoteReactionsRepository, + + private utilityService: UtilityService, + private instanceActorService: InstanceActorService, + private metaService: MetaService, + private apRequestService: ApRequestService, + private httpRequestService: HttpRequestService, + private apRendererService: ApRendererService, + private apDbResolverService: ApDbResolverService, + ) { + } + + public createResolver(): Resolver { + return new Resolver( + this.config, + this.usersRepository, + this.notesRepository, + this.pollsRepository, + this.noteReactionsRepository, + this.utilityService, + this.instanceActorService, + this.metaService, + this.apRequestService, + this.httpRequestService, + this.apRendererService, + this.apDbResolverService, + ); + } +} + +export class Resolver { + private history: Set; + private user?: ILocalUser; + + constructor( + private config: Config, + private usersRepository: UsersRepository, + private notesRepository: NotesRepository, + private pollsRepository: PollsRepository, + private noteReactionsRepository: NoteReactionsRepository, + private utilityService: UtilityService, + private instanceActorService: InstanceActorService, + private metaService: MetaService, + private apRequestService: ApRequestService, + private httpRequestService: HttpRequestService, + private apRendererService: ApRendererService, + private apDbResolverService: ApDbResolverService, + ) { + this.history = new Set(); + } + + public getHistory(): string[] { + return Array.from(this.history); + } + + public async resolveCollection(value: string | IObject): Promise { + const collection = typeof value === 'string' + ? await this.resolve(value) + : value; + + if (isCollectionOrOrderedCollection(collection)) { + return collection; + } else { + throw new Error(`unrecognized collection type: ${collection.type}`); + } + } + + public async resolve(value: string | IObject): Promise { + if (value == null) { + throw new Error('resolvee is null (or undefined)'); + } + + if (typeof value !== 'string') { + return value; + } + + if (value.includes('#')) { + // URLs with fragment parts cannot be resolved correctly because + // the fragment part does not get transmitted over HTTP(S). + // Avoid strange behaviour by not trying to resolve these at all. + throw new Error(`cannot resolve URL with fragment: ${value}`); + } + + if (this.history.has(value)) { + throw new Error('cannot resolve already resolved one'); + } + + this.history.add(value); + + const host = this.utilityService.extractDbHost(value); + if (this.utilityService.isSelfHost(host)) { + return await this.resolveLocal(value); + } + + const meta = await this.metaService.fetch(); + if (meta.blockedHosts.includes(host)) { + throw new Error('Instance is blocked'); + } + + if (this.config.signToActivityPubGet && !this.user) { + this.user = await this.instanceActorService.getInstanceActor(); + } + + const object = (this.user + ? await this.apRequestService.signedGet(value, this.user) + : await this.httpRequestService.getJson(value, 'application/activity+json, application/ld+json')) as IObject; + + if (object == null || ( + Array.isArray(object['@context']) ? + !(object['@context'] as unknown[]).includes('https://www.w3.org/ns/activitystreams') : + object['@context'] !== 'https://www.w3.org/ns/activitystreams' + )) { + throw new Error('invalid response'); + } + + return object; + } + + private resolveLocal(url: string): Promise { + const parsed = this.apDbResolverService.parseUri(url); + if (!parsed.local) throw new Error('resolveLocal: not local'); + + switch (parsed.type) { + case 'notes': + return this.notesRepository.findOneByOrFail({ id: parsed.id }) + .then(note => { + if (parsed.rest === 'activity') { + // this refers to the create activity and not the note itself + return this.apRendererService.renderActivity(this.apRendererService.renderCreate(this.apRendererService.renderNote(note))); + } else { + return this.apRendererService.renderNote(note); + } + }); + case 'users': + return this.usersRepository.findOneByOrFail({ id: parsed.id }) + .then(user => this.apRendererService.renderPerson(user as ILocalUser)); + case 'questions': + // Polls are indexed by the note they are attached to. + return Promise.all([ + this.notesRepository.findOneByOrFail({ id: parsed.id }), + this.pollsRepository.findOneByOrFail({ noteId: parsed.id }), + ]) + .then(([note, poll]) => this.apRendererService.renderQuestion({ id: note.userId }, note, poll)); + case 'likes': + return this.noteReactionsRepository.findOneByOrFail({ id: parsed.id }).then(reaction => + this.apRendererService.renderActivity(this.apRendererService.renderLike(reaction, { uri: null }))); + case 'follows': + // rest should be + if (parsed.rest == null || !/^\w+$/.test(parsed.rest)) throw new Error('resolveLocal: invalid follow URI'); + + return Promise.all( + [parsed.id, parsed.rest].map(id => this.usersRepository.findOneByOrFail({ id })), + ) + .then(([follower, followee]) => this.apRendererService.renderActivity(this.apRendererService.renderFollow(follower, followee, url))); + default: + throw new Error(`resolveLocal: type ${type} unhandled`); + } + } +} diff --git a/packages/backend/src/remote/activitypub/misc/ld-signature.ts b/packages/backend/src/core/remote/activitypub/LdSignatureService.ts similarity index 83% rename from packages/backend/src/remote/activitypub/misc/ld-signature.ts rename to packages/backend/src/core/remote/activitypub/LdSignatureService.ts index 362a543ec..ea0d2daf3 100644 --- a/packages/backend/src/remote/activitypub/misc/ld-signature.ts +++ b/packages/backend/src/core/remote/activitypub/LdSignatureService.ts @@ -1,17 +1,32 @@ import * as crypto from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; import jsonld from 'jsonld'; -import { CONTEXTS } from './contexts.js'; import fetch from 'node-fetch'; -import { httpAgent, httpsAgent } from '@/misc/fetch.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { CONTEXTS } from './misc/contexts.js'; // RsaSignature2017 based from https://github.com/transmute-industries/RsaSignature2017 -export class LdSignature { +@Injectable() +export class LdSignatureService { + constructor( + private httpRequestService: HttpRequestService, + ) { + } + + public use(): LdSignature { + return new LdSignature(this.httpRequestService); + } +} + +class LdSignature { public debug = false; public preLoad = true; public loderTimeout = 10 * 1000; - constructor() { + constructor( + private httpRequestService: HttpRequestService, + ) { } public async signRsaSignature2017(data: any, privateKey: string, creator: string, domain?: string, created?: Date): Promise { @@ -20,7 +35,7 @@ export class LdSignature { creator, domain, nonce: crypto.randomBytes(16).toString('hex'), - created: (created || new Date()).toISOString(), + created: (created ?? new Date()).toISOString(), } as { type: string; creator: string; @@ -115,7 +130,7 @@ export class LdSignature { }, // TODO //timeout: this.loderTimeout, - agent: u => u.protocol === 'http:' ? httpAgent : httpsAgent, + agent: u => u.protocol === 'http:' ? this.httpRequestService.httpAgent : this.httpRequestService.httpsAgent, }).then(res => { if (!res.ok) { throw `${res.status} ${res.statusText}`; diff --git a/packages/backend/src/remote/activitypub/misc/contexts.ts b/packages/backend/src/core/remote/activitypub/misc/contexts.ts similarity index 100% rename from packages/backend/src/remote/activitypub/misc/contexts.ts rename to packages/backend/src/core/remote/activitypub/misc/contexts.ts diff --git a/packages/backend/src/remote/activitypub/misc/get-note-html.ts b/packages/backend/src/core/remote/activitypub/misc/get-note-html.ts similarity index 62% rename from packages/backend/src/remote/activitypub/misc/get-note-html.ts rename to packages/backend/src/core/remote/activitypub/misc/get-note-html.ts index 389039ebe..af23a04a7 100644 --- a/packages/backend/src/remote/activitypub/misc/get-note-html.ts +++ b/packages/backend/src/core/remote/activitypub/misc/get-note-html.ts @@ -1,6 +1,6 @@ import * as mfm from 'mfm-js'; -import { Note } from '@/models/entities/note.js'; -import { toHtml } from '../../../mfm/to-html.js'; +import type { Note } from '@/models/entities/Note.js'; +import { toHtml } from '../../../../mfm/to-html.js'; export default function(note: Note) { if (!note.text) return ''; diff --git a/packages/backend/src/core/remote/activitypub/models/ApImageService.ts b/packages/backend/src/core/remote/activitypub/models/ApImageService.ts new file mode 100644 index 000000000..da6ed61c5 --- /dev/null +++ b/packages/backend/src/core/remote/activitypub/models/ApImageService.ts @@ -0,0 +1,90 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { DriveFilesRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type { CacheableRemoteUser } from '@/models/entities/User.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import { MetaService } from '@/core/MetaService.js'; +import { truncate } from '@/misc/truncate.js'; +import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js'; +import { DriveService } from '@/core/DriveService.js'; +import type Logger from '@/logger.js'; +import { ApResolverService } from '../ApResolverService.js'; +import { ApLoggerService } from '../ApLoggerService.js'; + +@Injectable() +export class ApImageService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private metaService: MetaService, + private apResolverService: ApResolverService, + private driveService: DriveService, + private apLoggerService: ApLoggerService, + ) { + this.logger = this.apLoggerService.logger; + } + + /** + * Imageを作成します。 + */ + public async createImage(actor: CacheableRemoteUser, value: any): Promise { + // 投稿者が凍結されていたらスキップ + if (actor.isSuspended) { + throw new Error('actor has been suspended'); + } + + const image = await this.apResolverService.createResolver().resolve(value) as any; + + if (image.url == null) { + throw new Error('invalid image: url not privided'); + } + + this.logger.info(`Creating the Image: ${image.url}`); + + const instance = await this.metaService.fetch(); + + let file = await this.driveService.uploadFromUrl({ + url: image.url, + user: actor, + uri: image.url, + sensitive: image.sensitive, + isLink: !instance.cacheRemoteFiles, + comment: truncate(image.name, DB_MAX_IMAGE_COMMENT_LENGTH), + }); + + if (file.isLink) { + // URLが異なっている場合、同じ画像が以前に異なるURLで登録されていたということなので、 + // URLを更新する + if (file.url !== image.url) { + await this.driveFilesRepository.update({ id: file.id }, { + url: image.url, + uri: image.url, + }); + + file = await this.driveFilesRepository.findOneByOrFail({ id: file.id }); + } + } + + return file; + } + + /** + * Imageを解決します。 + * + * Misskeyに対象のImageが登録されていればそれを返し、そうでなければ + * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 + */ + public async resolveImage(actor: CacheableRemoteUser, value: any): Promise { + // TODO + + // リモートサーバーからフェッチしてきて登録 + return await this.createImage(actor, value); + } +} diff --git a/packages/backend/src/core/remote/activitypub/models/ApMentionService.ts b/packages/backend/src/core/remote/activitypub/models/ApMentionService.ts new file mode 100644 index 000000000..898da07a2 --- /dev/null +++ b/packages/backend/src/core/remote/activitypub/models/ApMentionService.ts @@ -0,0 +1,41 @@ +import { Inject, Injectable } from '@nestjs/common'; +import promiseLimit from 'promise-limit'; +import { DI } from '@/di-symbols.js'; +import type { UsersRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import { toArray, unique } from '@/misc/prelude/array.js'; +import type { CacheableUser } from '@/models/entities/User.js'; +import { isMention } from '../type.js'; +import { ApResolverService } from '../ApResolverService.js'; +import { ApPersonService } from './ApPersonService.js'; +import type { IObject, IApMention } from '../type.js'; + +@Injectable() +export class ApMentionService { + constructor( + @Inject(DI.config) + private config: Config, + + private apResolverService: ApResolverService, + private apPersonService: ApPersonService, + ) { + } + + public async extractApMentions(tags: IObject | IObject[] | null | undefined) { + const hrefs = unique(this.extractApMentionObjects(tags).map(x => x.href as string)); + + const resolver = this.apResolverService.createResolver(); + + const limit = promiseLimit(2); + const mentionedUsers = (await Promise.all( + hrefs.map(x => limit(() => this.apPersonService.resolvePerson(x, resolver).catch(() => null))), + )).filter((x): x is CacheableUser => x != null); + + return mentionedUsers; + } + + public extractApMentionObjects(tags: IObject | IObject[] | null | undefined): IApMention[] { + if (tags == null) return []; + return toArray(tags).filter(isMention); + } +} diff --git a/packages/backend/src/core/remote/activitypub/models/ApNoteService.ts b/packages/backend/src/core/remote/activitypub/models/ApNoteService.ts new file mode 100644 index 000000000..1efe62333 --- /dev/null +++ b/packages/backend/src/core/remote/activitypub/models/ApNoteService.ts @@ -0,0 +1,403 @@ +import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import promiseLimit from 'promise-limit'; +import { DI } from '@/di-symbols.js'; +import { MessagingMessagesRepository, PollsRepository, EmojisRepository } from '@/models/index.js'; +import type { UsersRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type { CacheableRemoteUser } from '@/models/entities/User.js'; +import type { Note } from '@/models/entities/Note.js'; +import { toArray, toSingle, unique } from '@/misc/prelude/array.js'; +import type { Emoji } from '@/models/entities/Emoji.js'; +import { MetaService } from '@/core/MetaService.js'; +import { AppLockService } from '@/core/AppLockService.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import { NoteCreateService } from '@/core/NoteCreateService.js'; +import type Logger from '@/logger.js'; +import { IdService } from '@/core/IdService.js'; +import { PollService } from '@/core/PollService.js'; +import { StatusError } from '@/misc/status-error.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { MessagingService } from '@/core/MessagingService.js'; +import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { ApLoggerService } from '../ApLoggerService.js'; +import { ApMfmService } from '../ApMfmService.js'; +import { ApDbResolverService } from '../ApDbResolverService.js'; +import { ApResolverService } from '../ApResolverService.js'; +import { ApAudienceService } from '../ApAudienceService.js'; +import { ApPersonService } from './ApPersonService.js'; +import { extractApHashtags } from './tag.js'; +import { ApMentionService } from './ApMentionService.js'; +import { ApQuestionService } from './ApQuestionService.js'; +import { ApImageService } from './ApImageService.js'; +import type { Resolver } from '../ApResolverService.js'; +import type { IObject, IPost } from '../type.js'; + +@Injectable() +export class ApNoteService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.pollsRepository) + private pollsRepository: PollsRepository, + + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, + + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, + + private idService: IdService, + private apMfmService: ApMfmService, + private apResolverService: ApResolverService, + + // 循環参照のため / for circular dependency + @Inject(forwardRef(() => ApPersonService)) + private apPersonService: ApPersonService, + + private utilityService: UtilityService, + private apAudienceService: ApAudienceService, + private apMentionService: ApMentionService, + private apImageService: ApImageService, + private apQuestionService: ApQuestionService, + private metaService: MetaService, + private messagingService: MessagingService, + private appLockService: AppLockService, + private pollService: PollService, + private noteCreateService: NoteCreateService, + private apDbResolverService: ApDbResolverService, + private apLoggerService: ApLoggerService, + ) { + this.logger = this.apLoggerService.logger; + } + + public validateNote(object: any, uri: string) { + const expectHost = this.utilityService.extractDbHost(uri); + + if (object == null) { + return new Error('invalid Note: object is null'); + } + + if (!validPost.includes(getApType(object))) { + return new Error(`invalid Note: invalid object type ${getApType(object)}`); + } + + if (object.id && this.utilityService.extractDbHost(object.id) !== expectHost) { + return new Error(`invalid Note: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`); + } + + if (object.attributedTo && this.utilityService.extractDbHost(getOneApId(object.attributedTo)) !== expectHost) { + return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.attributedTo)}`); + } + + return null; + } + + /** + * Noteをフェッチします。 + * + * Misskeyに対象のNoteが登録されていればそれを返します。 + */ + public async fetchNote(object: string | IObject): Promise { + return await this.apDbResolverService.getNoteFromApId(object); + } + + /** + * Noteを作成します。 + */ + public async createNote(value: string | IObject, resolver?: Resolver, silent = false): Promise { + if (resolver == null) resolver = this.apResolverService.createResolver(); + + const object: any = 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: value, + object: object, + }); + throw new Error('invalid note'); + } + + const note: IPost = object; + + this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); + + this.logger.info(`Creating the Note: ${note.id}`); + + // 投稿者をフェッチ + const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo), resolver) as CacheableRemoteUser; + + // 投稿者が凍結されていたらスキップ + if (actor.isSuspended) { + throw new Error('actor has been suspended'); + } + + const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc); + let visibility = noteAudience.visibility; + const visibleUsers = noteAudience.visibleUsers; + + // Audience (to, cc) が指定されてなかった場合 + if (visibility === 'specified' && visibleUsers.length === 0) { + if (typeof value === 'string') { // 入力がstringならばresolverでGETが発生している + // こちらから匿名GET出来たものならばpublic + visibility = 'public'; + } + } + + let isMessaging = note._misskey_talk && visibility === 'specified'; + + const apMentions = await this.apMentionService.extractApMentions(note.tag); + const apHashtags = await extractApHashtags(note.tag); + + // 添付ファイル + // TODO: attachmentは必ずしもImageではない + // TODO: attachmentは必ずしも配列ではない + // Noteがsensitiveなら添付もsensitiveにする + const limit = promiseLimit(2); + + note.attachment = Array.isArray(note.attachment) ? note.attachment : note.attachment ? [note.attachment] : []; + const files = note.attachment + .map(attach => attach.sensitive = note.sensitive) + ? (await Promise.all(note.attachment.map(x => limit(() => this.apImageService.resolveImage(actor, x)) as Promise))) + .filter(image => image != null) + : []; + + // リプライ + const reply: Note | null = note.inReplyTo + ? await this.resolveNote(note.inReplyTo, resolver).then(x => { + if (x == null) { + this.logger.warn('Specified inReplyTo, but nout found'); + throw new Error('inReplyTo not found'); + } else { + return x; + } + }).catch(async err => { + // トークだったらinReplyToのエラーは無視 + const uri = getApId(note.inReplyTo); + if (uri.startsWith(this.config.url + '/')) { + const id = uri.split('/').pop(); + const talk = await this.messagingMessagesRepository.findOneBy({ id }); + if (talk) { + isMessaging = true; + return null; + } + } + + this.logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${err.statusCode ?? err}`); + throw err; + }) + : null; + + // 引用 + let quote: Note | undefined | null; + + if (note._misskey_quote || note.quoteUrl) { + const tryResolveNote = async (uri: string): Promise<{ + status: 'ok'; + res: Note | null; + } | { + status: 'permerror' | 'temperror'; + }> => { + if (typeof uri !== 'string' || !uri.match(/^https?:/)) return { status: 'permerror' }; + try { + const res = await this.resolveNote(uri); + if (res) { + return { + status: 'ok', + res, + }; + } else { + return { + status: 'permerror', + }; + } + } catch (e) { + return { + status: (e instanceof StatusError && e.isClientError) ? 'permerror' : 'temperror', + }; + } + }; + + const uris = unique([note._misskey_quote, note.quoteUrl].filter((x): x is string => typeof x === 'string')); + const results = await Promise.all(uris.map(uri => tryResolveNote(uri))); + + quote = results.filter((x): x is { status: 'ok', res: Note | null } => x.status === 'ok').map(x => x.res).find(x => x); + if (!quote) { + if (results.some(x => x.status === 'temperror')) { + throw 'quote resolve failed'; + } + } + } + + 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); + } + + // vote + if (reply && reply.hasPoll) { + const poll = await this.pollsRepository.findOneByOrFail({ noteId: reply.id }); + + const tryCreateVote = async (name: string, index: number): Promise => { + if (poll.expiresAt && Date.now() > new Date(poll.expiresAt).getTime()) { + this.logger.warn(`vote to expired poll from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`); + } else if (index >= 0) { + this.logger.info(`vote from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`); + await this.pollService.vote(actor, reply, index); + + // リモートフォロワーにUpdate配信 + this.pollService.deliverQuestionUpdate(reply.id); + } + return null; + }; + + if (note.name) { + return await tryCreateVote(note.name, poll.choices.findIndex(x => x === note.name)); + } + } + + const emojis = await this.extractEmojis(note.tag ?? [], actor.host).catch(e => { + this.logger.info(`extractEmojis: ${e}`); + return [] as Emoji[]; + }); + + const apEmojis = emojis.map(emoji => emoji.name); + + const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined); + + if (isMessaging) { + for (const recipient of visibleUsers) { + await this.messagingService.createMessage(actor, recipient, undefined, text ?? undefined, (files && files.length > 0) ? files[0] : null, object.id); + return null; + } + } + + return await this.noteCreateService.create(actor, { + createdAt: note.published ? new Date(note.published) : null, + files, + reply, + renote: quote, + name: note.name, + cw, + text, + localOnly: false, + visibility, + visibleUsers, + apMentions, + apHashtags, + apEmojis, + poll, + uri: note.id, + url: getOneApHrefNullable(note.url), + }, silent); + } + + /** + * Noteを解決します。 + * + * Misskeyに対象のNoteが登録されていればそれを返し、そうでなければ + * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 + */ + public async resolveNote(value: string | IObject, resolver?: Resolver): Promise { + const uri = typeof value === 'string' ? value : value.id; + if (uri == null) throw new Error('missing uri'); + + // ブロックしてたら中断 + const meta = await this.metaService.fetch(); + if (meta.blockedHosts.includes(this.utilityService.extractDbHost(uri))) throw { statusCode: 451 }; + + const unlock = await this.appLockService.getApLock(uri); + + try { + //#region このサーバーに既に登録されていたらそれを返す + const exist = await this.fetchNote(uri); + + if (exist) { + return exist; + } + //#endregion + + if (uri.startsWith(this.config.url)) { + throw new StatusError('cannot resolve local note', 400, 'cannot resolve local note'); + } + + // リモートサーバーからフェッチしてきて登録 + // ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにノートが生成されるが + // 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。 + return await this.createNote(uri, resolver, true); + } finally { + unlock(); + } + } + + public async extractEmojis(tags: IObject | IObject[], host: string): Promise { + host = this.utilityService.toPuny(host); + + if (!tags) return []; + + const eomjiTags = toArray(tags).filter(isEmoji); + + return await Promise.all(eomjiTags.map(async tag => { + const name = tag.name!.replace(/^:/, '').replace(/:$/, ''); + tag.icon = toSingle(tag.icon); + + const exists = await this.emojisRepository.findOneBy({ + host, + name, + }); + + if (exists) { + if ((tag.updated != null && exists.updatedAt == null) + || (tag.id != null && exists.uri == null) + || (tag.updated != null && exists.updatedAt != null && new Date(tag.updated) > exists.updatedAt) + || (tag.icon!.url !== exists.originalUrl) + ) { + await this.emojisRepository.update({ + host, + name, + }, { + uri: tag.id, + originalUrl: tag.icon!.url, + publicUrl: tag.icon!.url, + updatedAt: new Date(), + }); + + return await this.emojisRepository.findOneBy({ + host, + name, + }) as Emoji; + } + + return exists; + } + + this.logger.info(`register emoji host=${host}, name=${name}`); + + return await this.emojisRepository.insert({ + id: this.idService.genId(), + host, + name, + uri: tag.id, + originalUrl: tag.icon!.url, + publicUrl: tag.icon!.url, + updatedAt: new Date(), + aliases: [], + } as Partial).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); + })); + } +} diff --git a/packages/backend/src/core/remote/activitypub/models/ApPersonService.ts b/packages/backend/src/core/remote/activitypub/models/ApPersonService.ts new file mode 100644 index 000000000..d088fa555 --- /dev/null +++ b/packages/backend/src/core/remote/activitypub/models/ApPersonService.ts @@ -0,0 +1,594 @@ +import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import promiseLimit from 'promise-limit'; +import { DataSource } from 'typeorm'; +import { ModuleRef } from '@nestjs/core'; +import { DI } from '@/di-symbols.js'; +import { FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type { CacheableUser, IRemoteUser } from '@/models/entities/User.js'; +import { User } from '@/models/entities/User.js'; +import { truncate } from '@/misc/truncate.js'; +import type { UserCacheService } from '@/core/UserCacheService.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; +import type Logger from '@/logger.js'; +import type { Note } from '@/models/entities/Note.js'; +import type { IdService } from '@/core/IdService.js'; +import type { MfmService } from '@/core/MfmService.js'; +import type { Emoji } from '@/models/entities/Emoji.js'; +import { toArray } from '@/misc/prelude/array.js'; +import type { GlobalEventService } from '@/core/GlobalEventService.js'; +import type { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; +import type { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; +import { UserProfile } from '@/models/entities/UserProfile.js'; +import { UserPublickey } from '@/models/entities/UserPublickey.js'; +import type UsersChart from '@/core/chart/charts/users.js'; +import type InstanceChart from '@/core/chart/charts/instance.js'; +import type { HashtagService } from '@/core/HashtagService.js'; +import { UserNotePining } from '@/models/entities/UserNotePining.js'; +import { StatusError } from '@/misc/status-error.js'; +import type { UtilityService } from '@/core/UtilityService.js'; +import type { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; +import { extractApHashtags } from './tag.js'; +import type { OnModuleInit } from '@nestjs/common'; +import type { ApNoteService } from './ApNoteService.js'; +import type { ApMfmService } from '../ApMfmService.js'; +import type { ApResolverService, Resolver } from '../ApResolverService.js'; +import type { ApLoggerService } from '../ApLoggerService.js'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import type { ApImageService } from './ApImageService.js'; +import type { IActor, IObject, IApPropertyValue } from '../type.js'; + +const nameLength = 128; +const summaryLength = 2048; + +const services: { + [x: string]: (id: string, username: string) => any +} = { + 'misskey:authentication:twitter': (userId, screenName) => ({ userId, screenName }), + 'misskey:authentication:github': (id, login) => ({ id, login }), + 'misskey:authentication:discord': (id, name) => $discord(id, name), +}; + +const $discord = (id: string, name: string) => { + if (typeof name !== 'string') { + name = 'unknown#0000'; + } + const [username, discriminator] = name.split('#'); + return { id, username, discriminator }; +}; + +function addService(target: { [x: string]: any }, source: IApPropertyValue) { + const service = services[source.name]; + + if (typeof source.value !== 'string') { + source.value = 'unknown'; + } + + const [id, username] = source.value.split('@'); + + if (service) { + target[source.name.split(':')[2]] = service(id, username); + } +} + +@Injectable() +export class ApPersonService implements OnModuleInit { + private utilityService: UtilityService; + private userEntityService: UserEntityService; + private idService: IdService; + private globalEventService: GlobalEventService; + private federatedInstanceService: FederatedInstanceService; + private fetchInstanceMetadataService: FetchInstanceMetadataService; + private userCacheService: UserCacheService; + private apResolverService: ApResolverService; + private apNoteService: ApNoteService; + private apImageService: ApImageService; + private apMfmService: ApMfmService; + private mfmService: MfmService; + private hashtagService: HashtagService; + private usersChart: UsersChart; + private instanceChart: InstanceChart; + private apLoggerService: ApLoggerService; + private logger: Logger; + + constructor( + private moduleRef: ModuleRef, + + @Inject(DI.config) + private config: Config, + + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.userPublickeysRepository) + private userPublickeysRepository: UserPublickeysRepository, + + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + //private utilityService: UtilityService, + //private userEntityService: UserEntityService, + //private idService: IdService, + //private globalEventService: GlobalEventService, + //private federatedInstanceService: FederatedInstanceService, + //private fetchInstanceMetadataService: FetchInstanceMetadataService, + //private userCacheService: UserCacheService, + //private apResolverService: ApResolverService, + //private apNoteService: ApNoteService, + //private apImageService: ApImageService, + //private apMfmService: ApMfmService, + //private mfmService: MfmService, + //private hashtagService: HashtagService, + //private usersChart: UsersChart, + //private instanceChart: InstanceChart, + //private apLoggerService: ApLoggerService, + ) { + } + + onModuleInit() { + this.utilityService = this.moduleRef.get('UtilityService'); + this.userEntityService = this.moduleRef.get('UserEntityService'); + this.idService = this.moduleRef.get('IdService'); + this.globalEventService = this.moduleRef.get('GlobalEventService'); + this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService'); + this.fetchInstanceMetadataService = this.moduleRef.get('FetchInstanceMetadataService'); + this.userCacheService = this.moduleRef.get('UserCacheService'); + this.apResolverService = this.moduleRef.get('ApResolverService'); + this.apNoteService = this.moduleRef.get('ApNoteService'); + this.apImageService = this.moduleRef.get('ApImageService'); + this.apMfmService = this.moduleRef.get('ApMfmService'); + this.mfmService = this.moduleRef.get('MfmService'); + this.hashtagService = this.moduleRef.get('HashtagService'); + this.usersChart = this.moduleRef.get('UsersChart'); + this.instanceChart = this.moduleRef.get('InstanceChart'); + this.apLoggerService = this.moduleRef.get('ApLoggerService'); + this.logger = this.apLoggerService.logger; + } + + /** + * Validate and convert to actor object + * @param x Fetched object + * @param uri Fetch target URI + */ + private validateActor(x: IObject, uri: string): IActor { + const expectHost = this.utilityService.toPuny(new URL(uri).hostname); + + if (x == null) { + throw new Error('invalid Actor: object is null'); + } + + if (!isActor(x)) { + throw new Error(`invalid Actor type '${x.type}'`); + } + + if (!(typeof x.id === 'string' && x.id.length > 0)) { + throw new Error('invalid Actor: wrong id'); + } + + if (!(typeof x.inbox === 'string' && x.inbox.length > 0)) { + throw new Error('invalid Actor: wrong inbox'); + } + + if (!(typeof x.preferredUsername === 'string' && x.preferredUsername.length > 0 && x.preferredUsername.length <= 128 && /^\w([\w-.]*\w)?$/.test(x.preferredUsername))) { + throw new Error('invalid Actor: wrong username'); + } + + // These fields are only informational, and some AP software allows these + // fields to be very long. If they are too long, we cut them off. This way + // we can at least see these users and their activities. + if (x.name) { + if (!(typeof x.name === 'string' && x.name.length > 0)) { + throw new Error('invalid Actor: wrong name'); + } + x.name = truncate(x.name, nameLength); + } + if (x.summary) { + if (!(typeof x.summary === 'string' && x.summary.length > 0)) { + throw new Error('invalid Actor: wrong summary'); + } + x.summary = truncate(x.summary, summaryLength); + } + + const idHost = this.utilityService.toPuny(new URL(x.id!).hostname); + if (idHost !== expectHost) { + throw new Error('invalid Actor: id has different host'); + } + + if (x.publicKey) { + if (typeof x.publicKey.id !== 'string') { + throw new Error('invalid Actor: publicKey.id is not a string'); + } + + const publicKeyIdHost = this.utilityService.toPuny(new URL(x.publicKey.id).hostname); + if (publicKeyIdHost !== expectHost) { + throw new Error('invalid Actor: publicKey.id has different host'); + } + } + + return x; + } + + /** + * Personをフェッチします。 + * + * Misskeyに対象のPersonが登録されていればそれを返します。 + */ + public async fetchPerson(uri: string, resolver?: Resolver): Promise { + if (typeof uri !== 'string') throw new Error('uri is not string'); + + const cached = this.userCacheService.uriPersonCache.get(uri); + if (cached) return cached; + + // URIがこのサーバーを指しているならデータベースからフェッチ + if (uri.startsWith(this.config.url + '/')) { + const id = uri.split('/').pop(); + const u = await this.usersRepository.findOneBy({ id }); + if (u) this.userCacheService.uriPersonCache.set(uri, u); + return u; + } + + //#region このサーバーに既に登録されていたらそれを返す + const exist = await this.usersRepository.findOneBy({ uri }); + + if (exist) { + this.userCacheService.uriPersonCache.set(uri, exist); + return exist; + } + //#endregion + + return null; + } + + /** + * Personを作成します。 + */ + public async createPerson(uri: string, resolver?: Resolver): Promise { + if (typeof uri !== 'string') throw new Error('uri is not string'); + + if (uri.startsWith(this.config.url)) { + throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user'); + } + + if (resolver == null) resolver = this.apResolverService.createResolver(); + + const object = await resolver.resolve(uri) as any; + + const person = this.validateActor(object, uri); + + this.logger.info(`Creating the Person: ${person.id}`); + + const host = this.utilityService.toPuny(new URL(object.id).hostname); + + const { fields } = this.analyzeAttachments(person.attachment ?? []); + + const tags = extractApHashtags(person.tag).map(tag => normalizeForSearch(tag)).splice(0, 32); + + const isBot = getApType(object) === 'Service'; + + const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); + + // Create user + let user: IRemoteUser; + try { + // Start transaction + await this.db.transaction(async transactionalEntityManager => { + user = await transactionalEntityManager.save(new User({ + id: this.idService.genId(), + avatarId: null, + bannerId: null, + createdAt: new Date(), + lastFetchedAt: new Date(), + name: truncate(person.name, nameLength), + isLocked: !!person.manuallyApprovesFollowers, + isExplorable: !!person.discoverable, + username: person.preferredUsername, + usernameLower: person.preferredUsername!.toLowerCase(), + host, + inbox: person.inbox, + sharedInbox: person.sharedInbox ?? (person.endpoints ? person.endpoints.sharedInbox : undefined), + followersUri: person.followers ? getApId(person.followers) : undefined, + featured: person.featured ? getApId(person.featured) : undefined, + uri: person.id, + tags, + isBot, + isCat: (person as any).isCat === true, + showTimelineReplies: false, + })) as IRemoteUser; + + await transactionalEntityManager.save(new UserProfile({ + userId: user.id, + description: person.summary ? this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null, + url: getOneApHrefNullable(person.url), + fields, + birthday: bday ? bday[0] : null, + location: person['vcard:Address'] ?? null, + userHost: host, + })); + + if (person.publicKey) { + await transactionalEntityManager.save(new UserPublickey({ + userId: user.id, + keyId: person.publicKey.id, + keyPem: person.publicKey.publicKeyPem, + })); + } + }); + } catch (e) { + // duplicate key error + if (isDuplicateKeyValueError(e)) { + // /users/@a => /users/:id のように入力がaliasなときにエラーになることがあるのを対応 + const u = await this.usersRepository.findOneBy({ + uri: person.id, + }); + + if (u) { + user = u as IRemoteUser; + } else { + throw new Error('already registered'); + } + } else { + this.logger.error(e instanceof Error ? e : new Error(e as string)); + throw e; + } + } + + // Register host + this.federatedInstanceService.registerOrFetchInstanceDoc(host).then(i => { + this.instancesRepository.increment({ id: i.id }, 'usersCount', 1); + this.instanceChart.newUser(i.host); + this.fetchInstanceMetadataService.fetchInstanceMetadata(i); + }); + + this.usersChart.update(user!, true); + + // ハッシュタグ更新 + this.hashtagService.updateUsertags(user!, tags); + + //#region アバターとヘッダー画像をフェッチ + const [avatar, banner] = await Promise.all([ + person.icon, + person.image, + ].map(img => + img == null + ? Promise.resolve(null) + : this.apImageService.resolveImage(user!, img).catch(() => null), + )); + + const avatarId = avatar ? avatar.id : null; + const bannerId = banner ? banner.id : null; + + await this.usersRepository.update(user!.id, { + avatarId, + bannerId, + }); + + user!.avatarId = avatarId; + user!.bannerId = bannerId; + //#endregion + + //#region カスタム絵文字取得 + const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host).catch(err => { + this.logger.info(`extractEmojis: ${err}`); + return [] as Emoji[]; + }); + + const emojiNames = emojis.map(emoji => emoji.name); + + await this.usersRepository.update(user!.id, { + emojis: emojiNames, + }); + //#endregion + + await this.updateFeatured(user!.id).catch(err => this.logger.error(err)); + + return user!; + } + + /** + * Personの情報を更新します。 + * Misskeyに対象のPersonが登録されていなければ無視します。 + * @param uri URI of Person + * @param resolver Resolver + * @param hint Hint of Person object (この値が正当なPersonの場合、Remote resolveをせずに更新に利用します) + */ + public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject): Promise { + if (typeof uri !== 'string') throw new Error('uri is not string'); + + // URIがこのサーバーを指しているならスキップ + if (uri.startsWith(this.config.url + '/')) { + return; + } + + //#region このサーバーに既に登録されているか + const exist = await this.usersRepository.findOneBy({ uri }) as IRemoteUser; + + if (exist == null) { + return; + } + //#endregion + + if (resolver == null) resolver = this.apResolverService.createResolver(); + + const object = hint ?? await resolver.resolve(uri); + + const person = this.validateActor(object, uri); + + this.logger.info(`Updating the Person: ${person.id}`); + + // アバターとヘッダー画像をフェッチ + const [avatar, banner] = await Promise.all([ + person.icon, + person.image, + ].map(img => + img == null + ? Promise.resolve(null) + : this.apImageService.resolveImage(exist, img).catch(() => null), + )); + + // カスタム絵文字取得 + const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], exist.host).catch(e => { + this.logger.info(`extractEmojis: ${e}`); + return [] as Emoji[]; + }); + + const emojiNames = emojis.map(emoji => emoji.name); + + const { fields } = this.analyzeAttachments(person.attachment ?? []); + + const tags = extractApHashtags(person.tag).map(tag => normalizeForSearch(tag)).splice(0, 32); + + const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); + + const updates = { + lastFetchedAt: new Date(), + inbox: person.inbox, + sharedInbox: person.sharedInbox ?? (person.endpoints ? person.endpoints.sharedInbox : undefined), + followersUri: person.followers ? getApId(person.followers) : undefined, + featured: person.featured, + emojis: emojiNames, + name: truncate(person.name, nameLength), + tags, + isBot: getApType(object) === 'Service', + isCat: (person as any).isCat === true, + isLocked: !!person.manuallyApprovesFollowers, + isExplorable: !!person.discoverable, + } as Partial; + + if (avatar) { + updates.avatarId = avatar.id; + } + + if (banner) { + updates.bannerId = banner.id; + } + + // Update user + await this.usersRepository.update(exist.id, updates); + + if (person.publicKey) { + await this.userPublickeysRepository.update({ userId: exist.id }, { + keyId: person.publicKey.id, + keyPem: person.publicKey.publicKeyPem, + }); + } + + await this.userProfilesRepository.update({ userId: exist.id }, { + url: getOneApHrefNullable(person.url), + fields, + description: person.summary ? this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null, + birthday: bday ? bday[0] : null, + location: person['vcard:Address'] ?? null, + }); + + this.globalEventService.publishInternalEvent('remoteUserUpdated', { id: exist.id }); + + // ハッシュタグ更新 + this.hashtagService.updateUsertags(exist, tags); + + // 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする + await this.followingsRepository.update({ + followerId: exist.id, + }, { + followerSharedInbox: person.sharedInbox ?? (person.endpoints ? person.endpoints.sharedInbox : undefined), + }); + + await this.updateFeatured(exist.id).catch(err => this.logger.error(err)); + } + + /** + * Personを解決します。 + * + * Misskeyに対象のPersonが登録されていればそれを返し、そうでなければ + * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 + */ + public async resolvePerson(uri: string, resolver?: Resolver): Promise { + if (typeof uri !== 'string') throw new Error('uri is not string'); + + //#region このサーバーに既に登録されていたらそれを返す + const exist = await this.fetchPerson(uri); + + if (exist) { + return exist; + } + //#endregion + + // リモートサーバーからフェッチしてきて登録 + if (resolver == null) resolver = this.apResolverService.createResolver(); + return await this.createPerson(uri, resolver); + } + + public analyzeAttachments(attachments: IObject | IObject[] | undefined) { + const fields: { + name: string, + value: string + }[] = []; + const services: { [x: string]: any } = {}; + + if (Array.isArray(attachments)) { + for (const attachment of attachments.filter(isPropertyValue)) { + if (isPropertyValue(attachment.identifier)) { + addService(services, attachment.identifier); + } else { + fields.push({ + name: attachment.name, + value: this.mfmService.fromHtml(attachment.value), + }); + } + } + } + + return { fields, services }; + } + + public async updateFeatured(userId: User['id']) { + const user = await this.usersRepository.findOneByOrFail({ id: userId }); + if (!this.userEntityService.isRemoteUser(user)) return; + if (!user.featured) return; + + this.logger.info(`Updating the featured: ${user.uri}`); + + const resolver = this.apResolverService.createResolver(); + + // Resolve to (Ordered)Collection Object + const collection = await resolver.resolveCollection(user.featured); + if (!isCollectionOrOrderedCollection(collection)) throw new Error('Object is not Collection or OrderedCollection'); + + // Resolve to Object(may be Note) arrays + const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems; + const items = await Promise.all(toArray(unresolvedItems).map(x => resolver.resolve(x))); + + // Resolve and regist Notes + const limit = promiseLimit(2); + const featuredNotes = await Promise.all(items + .filter(item => getApType(item) === 'Note') // TODO: Noteでなくてもいいかも + .slice(0, 5) + .map(item => limit(() => this.apNoteService.resolveNote(item, resolver)))); + + await this.db.transaction(async transactionalEntityManager => { + await transactionalEntityManager.delete(UserNotePining, { userId: user.id }); + + // とりあえずidを別の時間で生成して順番を維持 + let td = 0; + for (const note of featuredNotes.filter(note => note != null)) { + td -= 1000; + transactionalEntityManager.insert(UserNotePining, { + id: this.idService.genId(new Date(Date.now() + td)), + createdAt: new Date(), + userId: user.id, + noteId: note!.id, + }); + } + }); + } +} diff --git a/packages/backend/src/core/remote/activitypub/models/ApQuestionService.ts b/packages/backend/src/core/remote/activitypub/models/ApQuestionService.ts new file mode 100644 index 000000000..2b89cb030 --- /dev/null +++ b/packages/backend/src/core/remote/activitypub/models/ApQuestionService.ts @@ -0,0 +1,109 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { NotesRepository, PollsRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type { IPoll } from '@/models/entities/Poll.js'; +import type Logger from '@/logger.js'; +import { isQuestion } from '../type.js'; +import { ApLoggerService } from '../ApLoggerService.js'; +import { ApResolverService } from '../ApResolverService.js'; +import type { Resolver } from '../ApResolverService.js'; +import type { IObject, IQuestion } from '../type.js'; + +@Injectable() +export class ApQuestionService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.pollsRepository) + private pollsRepository: PollsRepository, + + private apResolverService: ApResolverService, + private apLoggerService: ApLoggerService, + ) { + this.logger = this.apLoggerService.logger; + } + + public async extractPollFromQuestion(source: string | IObject, resolver?: Resolver): Promise { + if (resolver == null) resolver = this.apResolverService.createResolver(); + + const question = await resolver.resolve(source); + + if (!isQuestion(question)) { + throw new Error('invalid type'); + } + + const multiple = !question.oneOf; + const expiresAt = question.endTime ? new Date(question.endTime) : question.closed ? new Date(question.closed) : null; + + if (multiple && !question.anyOf) { + throw new Error('invalid question'); + } + + const choices = question[multiple ? 'anyOf' : 'oneOf']! + .map((x, i) => x.name!); + + const votes = question[multiple ? 'anyOf' : 'oneOf']! + .map((x, i) => x.replies && x.replies.totalItems || x._misskey_votes || 0); + + return { + choices, + votes, + multiple, + expiresAt, + }; + } + + /** + * Update votes of Question + * @param uri URI of AP Question object + * @returns true if updated + */ + public async updateQuestion(value: any) { + const uri = typeof value === 'string' ? value : value.id; + + // URIがこのサーバーを指しているならスキップ + if (uri.startsWith(this.config.url + '/')) throw new Error('uri points local'); + + //#region このサーバーに既に登録されているか + const note = await this.notesRepository.findOneBy({ uri }); + if (note == null) throw new Error('Question is not registed'); + + const poll = await this.pollsRepository.findOneBy({ noteId: note.id }); + if (poll == null) throw new Error('Question is not registed'); + //#endregion + + // resolve new Question object + const resolver = this.apResolverService.createResolver(); + const question = await resolver.resolve(value) as IQuestion; + this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`); + + if (question.type !== 'Question') throw new Error('object is not a Question'); + + const apChoices = question.oneOf ?? question.anyOf; + + let changed = false; + + for (const choice of poll.choices) { + const oldCount = poll.votes[poll.choices.indexOf(choice)]; + const newCount = apChoices!.filter(ap => ap.name === choice)[0].replies!.totalItems; + + if (oldCount !== newCount) { + changed = true; + poll.votes[poll.choices.indexOf(choice)] = newCount; + } + } + + await this.pollsRepository.update({ noteId: note.id }, { + votes: poll.votes, + }); + + return changed; + } +} diff --git a/packages/backend/src/remote/activitypub/models/icon.ts b/packages/backend/src/core/remote/activitypub/models/icon.ts similarity index 100% rename from packages/backend/src/remote/activitypub/models/icon.ts rename to packages/backend/src/core/remote/activitypub/models/icon.ts diff --git a/packages/backend/src/remote/activitypub/models/identifier.ts b/packages/backend/src/core/remote/activitypub/models/identifier.ts similarity index 100% rename from packages/backend/src/remote/activitypub/models/identifier.ts rename to packages/backend/src/core/remote/activitypub/models/identifier.ts diff --git a/packages/backend/src/remote/activitypub/models/tag.ts b/packages/backend/src/core/remote/activitypub/models/tag.ts similarity index 76% rename from packages/backend/src/remote/activitypub/models/tag.ts rename to packages/backend/src/core/remote/activitypub/models/tag.ts index 964dabad0..803846a0b 100644 --- a/packages/backend/src/remote/activitypub/models/tag.ts +++ b/packages/backend/src/core/remote/activitypub/models/tag.ts @@ -1,5 +1,6 @@ -import { toArray } from '@/prelude/array.js'; -import { IObject, isHashtag, IApHashtag } from '../type.js'; +import { toArray } from '@/misc/prelude/array.js'; +import { isHashtag } from '../type.js'; +import type { IObject, IApHashtag } from '../type.js'; export function extractApHashtags(tags: IObject | IObject[] | null | undefined) { if (tags == null) return []; diff --git a/packages/backend/src/remote/activitypub/type.ts b/packages/backend/src/core/remote/activitypub/type.ts similarity index 100% rename from packages/backend/src/remote/activitypub/type.ts rename to packages/backend/src/core/remote/activitypub/type.ts diff --git a/packages/backend/src/daemons/DaemonModule.ts b/packages/backend/src/daemons/DaemonModule.ts new file mode 100644 index 000000000..683f9cbfe --- /dev/null +++ b/packages/backend/src/daemons/DaemonModule.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { CoreModule } from '@/core/CoreModule.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { JanitorService } from './JanitorService.js'; +import { QueueStatsService } from './QueueStatsService.js'; +import { ServerStatsService } from './ServerStatsService.js'; + +@Module({ + imports: [ + GlobalModule, + CoreModule, + ], + providers: [ + JanitorService, + QueueStatsService, + ServerStatsService, + ], + exports: [ + JanitorService, + QueueStatsService, + ServerStatsService, + ], +}) +export class DaemonModule {} diff --git a/packages/backend/src/daemons/JanitorService.ts b/packages/backend/src/daemons/JanitorService.ts new file mode 100644 index 000000000..a51f57072 --- /dev/null +++ b/packages/backend/src/daemons/JanitorService.ts @@ -0,0 +1,37 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { LessThan } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { AttestationChallengesRepository } from '@/models/index.js'; +import type { OnApplicationShutdown } from '@nestjs/common'; + +const interval = 30 * 60 * 1000; + +@Injectable() +export class JanitorService implements OnApplicationShutdown { + private intervalId: NodeJS.Timer; + + constructor( + @Inject(DI.attestationChallengesRepository) + private attestationChallengesRepository: AttestationChallengesRepository, + ) { + } + + /** + * Clean up database occasionally + */ + public start(): void { + const tick = async () => { + await this.attestationChallengesRepository.delete({ + createdAt: LessThan(new Date(new Date().getTime() - 5 * 60 * 1000)), + }); + }; + + tick(); + + this.intervalId = setInterval(tick, interval); + } + + public onApplicationShutdown(signal?: string | undefined) { + clearInterval(this.intervalId); + } +} diff --git a/packages/backend/src/daemons/QueueStatsService.ts b/packages/backend/src/daemons/QueueStatsService.ts new file mode 100644 index 000000000..931de1906 --- /dev/null +++ b/packages/backend/src/daemons/QueueStatsService.ts @@ -0,0 +1,77 @@ +import { Inject, Injectable } from '@nestjs/common'; +import Xev from 'xev'; +import { DI } from '@/di-symbols.js'; +import { QueueService } from '@/core/QueueService.js'; +import type { OnApplicationShutdown } from '@nestjs/common'; + +const ev = new Xev(); + +const interval = 10000; + +@Injectable() +export class QueueStatsService implements OnApplicationShutdown { + private intervalId: NodeJS.Timer; + + constructor( + private queueService: QueueService, + ) { + } + + /** + * Report queue stats regularly + */ + public start(): void { + const log = [] as any[]; + + ev.on('requestQueueStatsLog', x => { + ev.emit(`queueStatsLog:${x.id}`, log.slice(0, x.length ?? 50)); + }); + + let activeDeliverJobs = 0; + let activeInboxJobs = 0; + + this.queueService.deliverQueue.on('global:active', () => { + activeDeliverJobs++; + }); + + this.queueService.inboxQueue.on('global:active', () => { + activeInboxJobs++; + }); + + const tick = async () => { + const deliverJobCounts = await this.queueService.deliverQueue.getJobCounts(); + const inboxJobCounts = await this.queueService.inboxQueue.getJobCounts(); + + const stats = { + deliver: { + activeSincePrevTick: activeDeliverJobs, + active: deliverJobCounts.active, + waiting: deliverJobCounts.waiting, + delayed: deliverJobCounts.delayed, + }, + inbox: { + activeSincePrevTick: activeInboxJobs, + active: inboxJobCounts.active, + waiting: inboxJobCounts.waiting, + delayed: inboxJobCounts.delayed, + }, + }; + + ev.emit('queueStats', stats); + + log.unshift(stats); + if (log.length > 200) log.pop(); + + activeDeliverJobs = 0; + activeInboxJobs = 0; + }; + + tick(); + + this.intervalId = setInterval(tick, interval); + } + + public onApplicationShutdown(signal?: string | undefined) { + clearInterval(this.intervalId); + } +} diff --git a/packages/backend/src/daemons/ServerStatsService.ts b/packages/backend/src/daemons/ServerStatsService.ts new file mode 100644 index 000000000..e40912442 --- /dev/null +++ b/packages/backend/src/daemons/ServerStatsService.ts @@ -0,0 +1,95 @@ +import { Inject, Injectable } from '@nestjs/common'; +import si from 'systeminformation'; +import Xev from 'xev'; +import * as osUtils from 'os-utils'; +import { DI } from '@/di-symbols.js'; +import type { OnApplicationShutdown } from '@nestjs/common'; + +const ev = new Xev(); + +const interval = 2000; + +const roundCpu = (num: number) => Math.round(num * 1000) / 1000; +const round = (num: number) => Math.round(num * 10) / 10; + +@Injectable() +export class ServerStatsService implements OnApplicationShutdown { + private intervalId: NodeJS.Timer; + + constructor( + ) { + } + + /** + * Report server stats regularly + */ + public start(): void { + const log = [] as any[]; + + ev.on('requestServerStatsLog', x => { + ev.emit(`serverStatsLog:${x.id}`, log.slice(0, x.length ?? 50)); + }); + + const tick = async () => { + const cpu = await cpuUsage(); + const memStats = await mem(); + const netStats = await net(); + const fsStats = await fs(); + + const stats = { + cpu: roundCpu(cpu), + mem: { + used: round(memStats.used - memStats.buffers - memStats.cached), + active: round(memStats.active), + }, + net: { + rx: round(Math.max(0, netStats.rx_sec)), + tx: round(Math.max(0, netStats.tx_sec)), + }, + fs: { + r: round(Math.max(0, fsStats.rIO_sec ?? 0)), + w: round(Math.max(0, fsStats.wIO_sec ?? 0)), + }, + }; + ev.emit('serverStats', stats); + log.unshift(stats); + if (log.length > 200) log.pop(); + }; + + tick(); + + this.intervalId = setInterval(tick, interval); + } + + public onApplicationShutdown(signal?: string | undefined) { + clearInterval(this.intervalId); + } +} + +// CPU STAT +function cpuUsage(): Promise { + return new Promise((res, rej) => { + osUtils.cpuUsage((cpuUsage) => { + res(cpuUsage); + }); + }); +} + +// MEMORY STAT +async function mem() { + const data = await si.mem(); + return data; +} + +// NETWORK STAT +async function net() { + const iface = await si.networkInterfaceDefault(); + const data = await si.networkStats(iface); + return data[0]; +} + +// FS STAT +async function fs() { + const data = await si.disksIO().catch(() => ({ rIO_sec: 0, wIO_sec: 0 })); + return data ?? { rIO_sec: 0, wIO_sec: 0 }; +} diff --git a/packages/backend/src/daemons/janitor.ts b/packages/backend/src/daemons/janitor.ts deleted file mode 100644 index f2a1bfcc2..000000000 --- a/packages/backend/src/daemons/janitor.ts +++ /dev/null @@ -1,20 +0,0 @@ -// TODO: 消したい - -const interval = 30 * 60 * 1000; -import { AttestationChallenges } from '@/models/index.js'; -import { LessThan } from 'typeorm'; - -/** - * Clean up database occasionally - */ -export default function() { - async function tick() { - await AttestationChallenges.delete({ - createdAt: LessThan(new Date(new Date().getTime() - 5 * 60 * 1000)), - }); - } - - tick(); - - setInterval(tick, interval); -} diff --git a/packages/backend/src/daemons/queue-stats.ts b/packages/backend/src/daemons/queue-stats.ts deleted file mode 100644 index 1535abc6a..000000000 --- a/packages/backend/src/daemons/queue-stats.ts +++ /dev/null @@ -1,60 +0,0 @@ -import Xev from 'xev'; -import { deliverQueue, inboxQueue } from '../queue/queues.js'; - -const ev = new Xev(); - -const interval = 10000; - -/** - * Report queue stats regularly - */ -export default function() { - const log = [] as any[]; - - ev.on('requestQueueStatsLog', x => { - ev.emit(`queueStatsLog:${x.id}`, log.slice(0, x.length || 50)); - }); - - let activeDeliverJobs = 0; - let activeInboxJobs = 0; - - deliverQueue.on('global:active', () => { - activeDeliverJobs++; - }); - - inboxQueue.on('global:active', () => { - activeInboxJobs++; - }); - - async function tick() { - const deliverJobCounts = await deliverQueue.getJobCounts(); - const inboxJobCounts = await inboxQueue.getJobCounts(); - - const stats = { - deliver: { - activeSincePrevTick: activeDeliverJobs, - active: deliverJobCounts.active, - waiting: deliverJobCounts.waiting, - delayed: deliverJobCounts.delayed, - }, - inbox: { - activeSincePrevTick: activeInboxJobs, - active: inboxJobCounts.active, - waiting: inboxJobCounts.waiting, - delayed: inboxJobCounts.delayed, - }, - }; - - ev.emit('queueStats', stats); - - log.unshift(stats); - if (log.length > 200) log.pop(); - - activeDeliverJobs = 0; - activeInboxJobs = 0; - } - - tick(); - - setInterval(tick, interval); -} diff --git a/packages/backend/src/daemons/server-stats.ts b/packages/backend/src/daemons/server-stats.ts deleted file mode 100644 index faf4e6e4a..000000000 --- a/packages/backend/src/daemons/server-stats.ts +++ /dev/null @@ -1,79 +0,0 @@ -import si from 'systeminformation'; -import Xev from 'xev'; -import * as osUtils from 'os-utils'; - -const ev = new Xev(); - -const interval = 2000; - -const roundCpu = (num: number) => Math.round(num * 1000) / 1000; -const round = (num: number) => Math.round(num * 10) / 10; - -/** - * Report server stats regularly - */ -export default function() { - const log = [] as any[]; - - ev.on('requestServerStatsLog', x => { - ev.emit(`serverStatsLog:${x.id}`, log.slice(0, x.length || 50)); - }); - - async function tick() { - const cpu = await cpuUsage(); - const memStats = await mem(); - const netStats = await net(); - const fsStats = await fs(); - - const stats = { - cpu: roundCpu(cpu), - mem: { - used: round(memStats.used - memStats.buffers - memStats.cached), - active: round(memStats.active), - }, - net: { - rx: round(Math.max(0, netStats.rx_sec)), - tx: round(Math.max(0, netStats.tx_sec)), - }, - fs: { - r: round(Math.max(0, fsStats.rIO_sec ?? 0)), - w: round(Math.max(0, fsStats.wIO_sec ?? 0)), - }, - }; - ev.emit('serverStats', stats); - log.unshift(stats); - if (log.length > 200) log.pop(); - } - - tick(); - - setInterval(tick, interval); -} - -// CPU STAT -function cpuUsage(): Promise { - return new Promise((res, rej) => { - osUtils.cpuUsage((cpuUsage) => { - res(cpuUsage); - }); - }); -} - -// MEMORY STAT -async function mem() { - const data = await si.mem(); - return data; -} - -// NETWORK STAT -async function net() { - const iface = await si.networkInterfaceDefault(); - const data = await si.networkStats(iface); - return data[0]; -} - -// FS STAT -async function fs() { - const data = await si.disksIO().catch(() => ({ rIO_sec: 0, wIO_sec: 0 })); - return data || { rIO_sec: 0, wIO_sec: 0 }; -} diff --git a/packages/backend/src/db/elasticsearch.ts b/packages/backend/src/db/elasticsearch.ts deleted file mode 100644 index d98c5d180..000000000 --- a/packages/backend/src/db/elasticsearch.ts +++ /dev/null @@ -1,56 +0,0 @@ -import * as elasticsearch from '@elastic/elasticsearch'; -import config from '@/config/index.js'; - -const index = { - settings: { - analysis: { - analyzer: { - ngram: { - tokenizer: 'ngram', - }, - }, - }, - }, - mappings: { - properties: { - text: { - type: 'text', - index: true, - analyzer: 'ngram', - }, - userId: { - type: 'keyword', - index: true, - }, - userHost: { - type: 'keyword', - index: true, - }, - }, - }, -}; - -// Init ElasticSearch connection -const client = config.elasticsearch ? new elasticsearch.Client({ - node: `${config.elasticsearch.ssl ? 'https://' : 'http://'}${config.elasticsearch.host}:${config.elasticsearch.port}`, - auth: (config.elasticsearch.user && config.elasticsearch.pass) ? { - username: config.elasticsearch.user, - password: config.elasticsearch.pass, - } : undefined, - pingTimeout: 30000, -}) : null; - -if (client) { - client.indices.exists({ - index: config.elasticsearch.index || 'misskey_note', - }).then(exist => { - if (!exist.body) { - client.indices.create({ - index: config.elasticsearch.index || 'misskey_note', - body: index, - }); - } - }); -} - -export default client; diff --git a/packages/backend/src/db/logger.ts b/packages/backend/src/db/logger.ts deleted file mode 100644 index 22f4c6b1b..000000000 --- a/packages/backend/src/db/logger.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Logger from '@/services/logger.js'; - -export const dbLogger = new Logger('db'); diff --git a/packages/backend/src/db/postgre.ts b/packages/backend/src/db/postgre.ts deleted file mode 100644 index 94d55e431..000000000 --- a/packages/backend/src/db/postgre.ts +++ /dev/null @@ -1,256 +0,0 @@ -// https://github.com/typeorm/typeorm/issues/2400 -import pg from 'pg'; -pg.types.setTypeParser(20, Number); - -import { Logger, DataSource } from 'typeorm'; -import * as highlight from 'cli-highlight'; -import config from '@/config/index.js'; - -import { User } from '@/models/entities/user.js'; -import { DriveFile } from '@/models/entities/drive-file.js'; -import { DriveFolder } from '@/models/entities/drive-folder.js'; -import { AccessToken } from '@/models/entities/access-token.js'; -import { App } from '@/models/entities/app.js'; -import { PollVote } from '@/models/entities/poll-vote.js'; -import { Note } from '@/models/entities/note.js'; -import { NoteReaction } from '@/models/entities/note-reaction.js'; -import { NoteWatching } from '@/models/entities/note-watching.js'; -import { NoteThreadMuting } from '@/models/entities/note-thread-muting.js'; -import { NoteUnread } from '@/models/entities/note-unread.js'; -import { Notification } from '@/models/entities/notification.js'; -import { Meta } from '@/models/entities/meta.js'; -import { Following } from '@/models/entities/following.js'; -import { Instance } from '@/models/entities/instance.js'; -import { Muting } from '@/models/entities/muting.js'; -import { SwSubscription } from '@/models/entities/sw-subscription.js'; -import { Blocking } from '@/models/entities/blocking.js'; -import { UserList } from '@/models/entities/user-list.js'; -import { UserListJoining } from '@/models/entities/user-list-joining.js'; -import { UserGroup } from '@/models/entities/user-group.js'; -import { UserGroupJoining } from '@/models/entities/user-group-joining.js'; -import { UserGroupInvitation } from '@/models/entities/user-group-invitation.js'; -import { Hashtag } from '@/models/entities/hashtag.js'; -import { NoteFavorite } from '@/models/entities/note-favorite.js'; -import { AbuseUserReport } from '@/models/entities/abuse-user-report.js'; -import { RegistrationTicket } from '@/models/entities/registration-tickets.js'; -import { MessagingMessage } from '@/models/entities/messaging-message.js'; -import { Signin } from '@/models/entities/signin.js'; -import { AuthSession } from '@/models/entities/auth-session.js'; -import { FollowRequest } from '@/models/entities/follow-request.js'; -import { Emoji } from '@/models/entities/emoji.js'; -import { UserNotePining } from '@/models/entities/user-note-pining.js'; -import { Poll } from '@/models/entities/poll.js'; -import { UserKeypair } from '@/models/entities/user-keypair.js'; -import { UserPublickey } from '@/models/entities/user-publickey.js'; -import { UserProfile } from '@/models/entities/user-profile.js'; -import { UserSecurityKey } from '@/models/entities/user-security-key.js'; -import { AttestationChallenge } from '@/models/entities/attestation-challenge.js'; -import { Page } from '@/models/entities/page.js'; -import { PageLike } from '@/models/entities/page-like.js'; -import { GalleryPost } from '@/models/entities/gallery-post.js'; -import { GalleryLike } from '@/models/entities/gallery-like.js'; -import { ModerationLog } from '@/models/entities/moderation-log.js'; -import { UsedUsername } from '@/models/entities/used-username.js'; -import { Announcement } from '@/models/entities/announcement.js'; -import { AnnouncementRead } from '@/models/entities/announcement-read.js'; -import { Clip } from '@/models/entities/clip.js'; -import { ClipNote } from '@/models/entities/clip-note.js'; -import { Antenna } from '@/models/entities/antenna.js'; -import { AntennaNote } from '@/models/entities/antenna-note.js'; -import { PromoNote } from '@/models/entities/promo-note.js'; -import { PromoRead } from '@/models/entities/promo-read.js'; -import { Relay } from '@/models/entities/relay.js'; -import { MutedNote } from '@/models/entities/muted-note.js'; -import { Channel } from '@/models/entities/channel.js'; -import { ChannelFollowing } from '@/models/entities/channel-following.js'; -import { ChannelNotePining } from '@/models/entities/channel-note-pining.js'; -import { RegistryItem } from '@/models/entities/registry-item.js'; -import { Ad } from '@/models/entities/ad.js'; -import { PasswordResetRequest } from '@/models/entities/password-reset-request.js'; -import { UserPending } from '@/models/entities/user-pending.js'; -import { Webhook } from '@/models/entities/webhook.js'; -import { UserIp } from '@/models/entities/user-ip.js'; - -import { entities as charts } from '@/services/chart/entities.js'; -import { envOption } from '../env.js'; -import { dbLogger } from './logger.js'; -import { redisClient } from './redis.js'; - -const sqlLogger = dbLogger.createSubLogger('sql', 'gray', false); - -class MyCustomLogger implements Logger { - private highlight(sql: string) { - return highlight.highlight(sql, { - language: 'sql', ignoreIllegals: true, - }); - } - - public logQuery(query: string, parameters?: any[]) { - sqlLogger.info(this.highlight(query).substring(0, 100)); - } - - public logQueryError(error: string, query: string, parameters?: any[]) { - sqlLogger.error(this.highlight(query)); - } - - public logQuerySlow(time: number, query: string, parameters?: any[]) { - sqlLogger.warn(this.highlight(query)); - } - - public logSchemaBuild(message: string) { - sqlLogger.info(message); - } - - public log(message: string) { - sqlLogger.info(message); - } - - public logMigration(message: string) { - sqlLogger.info(message); - } -} - -export const entities = [ - Announcement, - AnnouncementRead, - Meta, - Instance, - App, - AuthSession, - AccessToken, - User, - UserProfile, - UserKeypair, - UserPublickey, - UserList, - UserListJoining, - UserGroup, - UserGroupJoining, - UserGroupInvitation, - UserNotePining, - UserSecurityKey, - UsedUsername, - AttestationChallenge, - Following, - FollowRequest, - Muting, - Blocking, - Note, - NoteFavorite, - NoteReaction, - NoteWatching, - NoteThreadMuting, - NoteUnread, - Page, - PageLike, - GalleryPost, - GalleryLike, - DriveFile, - DriveFolder, - Poll, - PollVote, - Notification, - Emoji, - Hashtag, - SwSubscription, - AbuseUserReport, - RegistrationTicket, - MessagingMessage, - Signin, - ModerationLog, - Clip, - ClipNote, - Antenna, - AntennaNote, - PromoNote, - PromoRead, - Relay, - MutedNote, - Channel, - ChannelFollowing, - ChannelNotePining, - RegistryItem, - Ad, - PasswordResetRequest, - UserPending, - Webhook, - UserIp, - ...charts, -]; - -const log = process.env.NODE_ENV !== 'production'; - -export const db = new DataSource({ - type: 'postgres', - host: config.db.host, - port: config.db.port, - username: config.db.user, - password: config.db.pass, - database: config.db.db, - extra: { - statement_timeout: 1000 * 10, - ...config.db.extra, - }, - synchronize: process.env.NODE_ENV === 'test', - dropSchema: process.env.NODE_ENV === 'test', - cache: !config.db.disableCache ? { - type: 'ioredis', - options: { - host: config.redis.host, - port: config.redis.port, - family: config.redis.family == null ? 0 : config.redis.family, - password: config.redis.pass, - keyPrefix: `${config.redis.prefix}:query:`, - db: config.redis.db || 0, - }, - } : false, - logging: log, - logger: log ? new MyCustomLogger() : undefined, - maxQueryExecutionTime: 300, - entities: entities, - migrations: ['../../migration/*.js'], -}); - -export async function initDb(force = false) { - if (force) { - if (db.isInitialized) { - await db.destroy(); - } - await db.initialize(); - return; - } - - if (db.isInitialized) { - // nop - } else { - await db.initialize(); - } -} - -export async function resetDb() { - const reset = async () => { - await redisClient.flushdb(); - const tables = await db.query(`SELECT relname AS "table" - FROM pg_class C LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace) - WHERE nspname NOT IN ('pg_catalog', 'information_schema') - AND C.relkind = 'r' - AND nspname !~ '^pg_toast';`); - for (const table of tables) { - await db.query(`DELETE FROM "${table.table}" CASCADE`); - } - }; - - for (let i = 1; i <= 3; i++) { - try { - await reset(); - } catch (e) { - if (i === 3) { - throw e; - } else { - await new Promise(resolve => setTimeout(resolve, 1000)); - continue; - } - } - break; - } -} diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts new file mode 100644 index 000000000..cc775a9c8 --- /dev/null +++ b/packages/backend/src/di-symbols.ts @@ -0,0 +1,72 @@ +export const DI = { + config: Symbol('config'), + db: Symbol('db'), + redis: Symbol('redis'), + redisSubscriber: Symbol('redisSubscriber'), + + //#region Repositories + usersRepository: Symbol('usersRepository'), + notesRepository: Symbol('notesRepository'), + announcementsRepository: Symbol('announcementsRepository'), + announcementReadsRepository: Symbol('announcementReadsRepository'), + appsRepository: Symbol('appsRepository'), + noteFavoritesRepository: Symbol('noteFavoritesRepository'), + noteThreadMutingsRepository: Symbol('noteThreadMutingsRepository'), + noteReactionsRepository: Symbol('noteReactionsRepository'), + noteUnreadsRepository: Symbol('noteUnreadsRepository'), + pollsRepository: Symbol('pollsRepository'), + pollVotesRepository: Symbol('pollVotesRepository'), + userProfilesRepository: Symbol('userProfilesRepository'), + userKeypairsRepository: Symbol('userKeypairsRepository'), + userPendingsRepository: Symbol('userPendingsRepository'), + attestationChallengesRepository: Symbol('attestationChallengesRepository'), + userSecurityKeysRepository: Symbol('userSecurityKeysRepository'), + userPublickeysRepository: Symbol('userPublickeysRepository'), + userListsRepository: Symbol('userListsRepository'), + userListJoiningsRepository: Symbol('userListJoiningsRepository'), + userGroupsRepository: Symbol('userGroupsRepository'), + userGroupJoiningsRepository: Symbol('userGroupJoiningsRepository'), + userGroupInvitationsRepository: Symbol('userGroupInvitationsRepository'), + userNotePiningsRepository: Symbol('userNotePiningsRepository'), + userIpsRepository: Symbol('userIpsRepository'), + usedUsernamesRepository: Symbol('usedUsernamesRepository'), + followingsRepository: Symbol('followingsRepository'), + followRequestsRepository: Symbol('followRequestsRepository'), + instancesRepository: Symbol('instancesRepository'), + emojisRepository: Symbol('emojisRepository'), + driveFilesRepository: Symbol('driveFilesRepository'), + driveFoldersRepository: Symbol('driveFoldersRepository'), + notificationsRepository: Symbol('notificationsRepository'), + metasRepository: Symbol('metasRepository'), + mutingsRepository: Symbol('mutingsRepository'), + blockingsRepository: Symbol('blockingsRepository'), + swSubscriptionsRepository: Symbol('swSubscriptionsRepository'), + hashtagsRepository: Symbol('hashtagsRepository'), + abuseUserReportsRepository: Symbol('abuseUserReportsRepository'), + registrationTicketsRepository: Symbol('registrationTicketsRepository'), + authSessionsRepository: Symbol('authSessionsRepository'), + accessTokensRepository: Symbol('accessTokensRepository'), + signinsRepository: Symbol('signinsRepository'), + messagingMessagesRepository: Symbol('messagingMessagesRepository'), + pagesRepository: Symbol('pagesRepository'), + pageLikesRepository: Symbol('pageLikesRepository'), + galleryPostsRepository: Symbol('galleryPostsRepository'), + galleryLikesRepository: Symbol('galleryLikesRepository'), + moderationLogsRepository: Symbol('moderationLogsRepository'), + clipsRepository: Symbol('clipsRepository'), + clipNotesRepository: Symbol('clipNotesRepository'), + antennasRepository: Symbol('antennasRepository'), + antennaNotesRepository: Symbol('antennaNotesRepository'), + promoNotesRepository: Symbol('promoNotesRepository'), + promoReadsRepository: Symbol('promoReadsRepository'), + relaysRepository: Symbol('relaysRepository'), + mutedNotesRepository: Symbol('mutedNotesRepository'), + channelsRepository: Symbol('channelsRepository'), + channelFollowingsRepository: Symbol('channelFollowingsRepository'), + channelNotePiningsRepository: Symbol('channelNotePiningsRepository'), + registryItemsRepository: Symbol('registryItemsRepository'), + webhooksRepository: Symbol('webhooksRepository'), + adsRepository: Symbol('adsRepository'), + passwordResetRequestsRepository: Symbol('passwordResetRequestsRepository'), + //#endregion +}; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts deleted file mode 100644 index bd9c0098b..000000000 --- a/packages/backend/src/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Misskey Entry Point! - */ - -import { EventEmitter } from 'node:events'; -import boot from './boot/index.js'; - -Error.stackTraceLimit = Infinity; -EventEmitter.defaultMaxListeners = 128; - -boot().catch(err => { - console.error(err); -}); diff --git a/packages/backend/src/services/logger.ts b/packages/backend/src/logger.ts similarity index 86% rename from packages/backend/src/services/logger.ts rename to packages/backend/src/logger.ts index 89d6d5720..672222068 100644 --- a/packages/backend/src/services/logger.ts +++ b/packages/backend/src/logger.ts @@ -2,10 +2,7 @@ import cluster from 'node:cluster'; import chalk from 'chalk'; import { default as convertColor } from 'color-convert'; import { format as dateFormat } from 'date-fns'; -import { envOption } from '../env.js'; -import config from '@/config/index.js'; - -import * as SyslogPro from 'syslog-pro'; +import { envOption } from './env.js'; type Domain = { name: string; @@ -20,26 +17,13 @@ export default class Logger { private store: boolean; private syslogClient: any | null = null; - constructor(domain: string, color?: string, store = true) { + constructor(domain: string, color?: string, store = true, syslogClient = null) { this.domain = { name: domain, color: color, }; this.store = store; - - if (config.syslog) { - this.syslogClient = new SyslogPro.RFC5424({ - applacationName: 'Misskey', - timestamp: true, - encludeStructuredData: true, - color: true, - extendedColor: true, - server: { - target: config.syslog.host, - port: config.syslog.port, - }, - }); - } + this.syslogClient = syslogClient; } public createSubLogger(domain: string, color?: string, store = true): Logger { @@ -98,11 +82,11 @@ export default class Logger { public error(x: string | Error, data?: Record | null, important = false): void { // 実行を継続できない状況で使う if (x instanceof Error) { - data = data || {}; + data = data ?? {}; data.e = x; this.log('error', x.toString(), data, important); } else if (typeof x === 'object') { - this.log('error', `${(x as any).message || (x as any).name || x}`, data, important); + this.log('error', `${(x as any).message ?? (x as any).name ?? x}`, data, important); } else { this.log('error', `${x}`, data, important); } diff --git a/packages/backend/src/mfm/from-html.ts b/packages/backend/src/mfm/from-html.ts deleted file mode 100644 index 7751bac56..000000000 --- a/packages/backend/src/mfm/from-html.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { URL } from 'node:url'; -import * as parse5 from 'parse5'; -import * as TreeAdapter from '../../node_modules/parse5/dist/tree-adapters/default.js'; - -const treeAdapter = TreeAdapter.defaultTreeAdapter; - -const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/; -const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/; - -export function fromHtml(html: string, hashtagNames?: string[]): string { - // some AP servers like Pixelfed use br tags as well as newlines - html = html.replace(/\r?\n/gi, '\n'); - - const dom = parse5.parseFragment(html); - - let text = ''; - - for (const n of dom.childNodes) { - analyze(n); - } - - return text.trim(); - - function getText(node: TreeAdapter.Node): string { - if (treeAdapter.isTextNode(node)) return node.value; - if (!treeAdapter.isElementNode(node)) return ''; - if (node.nodeName === 'br') return '\n'; - - if (node.childNodes) { - return node.childNodes.map(n => getText(n)).join(''); - } - - return ''; - } - - function appendChildren(childNodes: TreeAdapter.ChildNode[]): void { - if (childNodes) { - for (const n of childNodes) { - analyze(n); - } - } - } - - function analyze(node: TreeAdapter.Node) { - if (treeAdapter.isTextNode(node)) { - text += node.value; - return; - } - - // Skip comment or document type node - if (!treeAdapter.isElementNode(node)) return; - - switch (node.nodeName) { - case 'br': { - text += '\n'; - break; - } - - case 'a': - { - const txt = getText(node); - const rel = node.attrs.find(x => x.name === 'rel'); - const href = node.attrs.find(x => x.name === 'href'); - - // ハッシュタグ - if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) { - text += txt; - // メンション - } else if (txt.startsWith('@') && !(rel && rel.value.match(/^me /))) { - const part = txt.split('@'); - - if (part.length === 2 && href) { - //#region ホスト名部分が省略されているので復元する - const acct = `${txt}@${(new URL(href.value)).hostname}`; - text += acct; - //#endregion - } else if (part.length === 3) { - text += txt; - } - // その他 - } else { - const generateLink = () => { - if (!href && !txt) { - return ''; - } - if (!href) { - return txt; - } - if (!txt || txt === href.value) { // #6383: Missing text node - if (href.value.match(urlRegexFull)) { - return href.value; - } else { - return `<${href.value}>`; - } - } - if (href.value.match(urlRegex) && !href.value.match(urlRegexFull)) { - return `[${txt}](<${href.value}>)`; // #6846 - } else { - return `[${txt}](${href.value})`; - } - }; - - text += generateLink(); - } - break; - } - - case 'h1': - { - text += '【'; - appendChildren(node.childNodes); - text += '】\n'; - break; - } - - case 'b': - case 'strong': - { - text += '**'; - appendChildren(node.childNodes); - text += '**'; - break; - } - - case 'small': - { - text += ''; - appendChildren(node.childNodes); - text += ''; - break; - } - - case 's': - case 'del': - { - text += '~~'; - appendChildren(node.childNodes); - text += '~~'; - break; - } - - case 'i': - case 'em': - { - text += ''; - appendChildren(node.childNodes); - text += ''; - break; - } - - // block code (
)
-			case 'pre': {
-				if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') {
-					text += '\n```\n';
-					text += getText(node.childNodes[0]);
-					text += '\n```\n';
-				} else {
-					appendChildren(node.childNodes);
-				}
-				break;
-			}
-
-			// inline code ()
-			case 'code': {
-				text += '`';
-				appendChildren(node.childNodes);
-				text += '`';
-				break;
-			}
-
-			case 'blockquote': {
-				const t = getText(node);
-				if (t) {
-					text += '\n> ';
-					text += t.split('\n').join('\n> ');
-				}
-				break;
-			}
-
-			case 'p':
-			case 'h2':
-			case 'h3':
-			case 'h4':
-			case 'h5':
-			case 'h6':
-			{
-				text += '\n\n';
-				appendChildren(node.childNodes);
-				break;
-			}
-
-			// other block elements
-			case 'div':
-			case 'header':
-			case 'footer':
-			case 'article':
-			case 'li':
-			case 'dt':
-			case 'dd':
-			{
-				text += '\n';
-				appendChildren(node.childNodes);
-				break;
-			}
-
-			default:	// includes inline elements
-			{
-				appendChildren(node.childNodes);
-				break;
-			}
-		}
-	}
-}
diff --git a/packages/backend/src/mfm/to-html.ts b/packages/backend/src/mfm/to-html.ts
deleted file mode 100644
index bcb5c86a3..000000000
--- a/packages/backend/src/mfm/to-html.ts
+++ /dev/null
@@ -1,159 +0,0 @@
-import { JSDOM } from 'jsdom';
-import * as mfm from 'mfm-js';
-import config from '@/config/index.js';
-import { intersperse } from '@/prelude/array.js';
-import { IMentionedRemoteUsers } from '@/models/entities/note.js';
-
-export function toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = []) {
-	if (nodes == null) {
-		return null;
-	}
-
-	const { window } = new JSDOM('');
-
-	const doc = window.document;
-
-	function appendChildren(children: mfm.MfmNode[], targetElement: any): void {
-		if (children) {
-			for (const child of children.map(x => (handlers as any)[x.type](x))) targetElement.appendChild(child);
-		}
-	}
-
-	const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType) => any } = {
-		bold(node) {
-			const el = doc.createElement('b');
-			appendChildren(node.children, el);
-			return el;
-		},
-
-		small(node) {
-			const el = doc.createElement('small');
-			appendChildren(node.children, el);
-			return el;
-		},
-
-		strike(node) {
-			const el = doc.createElement('del');
-			appendChildren(node.children, el);
-			return el;
-		},
-
-		italic(node) {
-			const el = doc.createElement('i');
-			appendChildren(node.children, el);
-			return el;
-		},
-
-		fn(node) {
-			const el = doc.createElement('i');
-			appendChildren(node.children, el);
-			return el;
-		},
-
-		blockCode(node) {
-			const pre = doc.createElement('pre');
-			const inner = doc.createElement('code');
-			inner.textContent = node.props.code;
-			pre.appendChild(inner);
-			return pre;
-		},
-
-		center(node) {
-			const el = doc.createElement('div');
-			appendChildren(node.children, el);
-			return el;
-		},
-
-		emojiCode(node) {
-			return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
-		},
-
-		unicodeEmoji(node) {
-			return doc.createTextNode(node.props.emoji);
-		},
-
-		hashtag(node) {
-			const a = doc.createElement('a');
-			a.href = `${config.url}/tags/${node.props.hashtag}`;
-			a.textContent = `#${node.props.hashtag}`;
-			a.setAttribute('rel', 'tag');
-			return a;
-		},
-
-		inlineCode(node) {
-			const el = doc.createElement('code');
-			el.textContent = node.props.code;
-			return el;
-		},
-
-		mathInline(node) {
-			const el = doc.createElement('code');
-			el.textContent = node.props.formula;
-			return el;
-		},
-
-		mathBlock(node) {
-			const el = doc.createElement('code');
-			el.textContent = node.props.formula;
-			return el;
-		},
-
-		link(node) {
-			const a = doc.createElement('a');
-			a.href = node.props.url;
-			appendChildren(node.children, a);
-			return a;
-		},
-
-		mention(node) {
-			const a = doc.createElement('a');
-			const { username, host, acct } = node.props;
-			const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
-			a.href = remoteUserInfo ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri) : `${config.url}/${acct}`;
-			a.className = 'u-url mention';
-			a.textContent = acct;
-			return a;
-		},
-
-		quote(node) {
-			const el = doc.createElement('blockquote');
-			appendChildren(node.children, el);
-			return el;
-		},
-
-		text(node) {
-			const el = doc.createElement('span');
-			const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => doc.createTextNode(x));
-
-			for (const x of intersperse('br', nodes)) {
-				el.appendChild(x === 'br' ? doc.createElement('br') : x);
-			}
-
-			return el;
-		},
-
-		url(node) {
-			const a = doc.createElement('a');
-			a.href = node.props.url;
-			a.textContent = node.props.url;
-			return a;
-		},
-
-		search(node) {
-			const a = doc.createElement('a');
-			a.href = `https://www.google.com/search?q=${node.props.query}`;
-			a.textContent = node.props.content;
-			return a;
-		},
-
-		plain(node) {
-			const el = doc.createElement('span');
-			appendChildren(node.children, el);
-			return el;
-		},
-	};
-
-	appendChildren(nodes, doc.body);
-
-	return `

${doc.body.innerHTML}

`; -} diff --git a/packages/backend/src/misc/acct.ts b/packages/backend/src/misc/acct.ts index c32cee86c..d1a6852a9 100644 --- a/packages/backend/src/misc/acct.ts +++ b/packages/backend/src/misc/acct.ts @@ -6,7 +6,7 @@ export type Acct = { export function parse(acct: string): Acct { if (acct.startsWith('@')) acct = acct.substr(1); const split = acct.split('@', 2); - return { username: split[0], host: split[1] || null }; + return { username: split[0], host: split[1] ?? null }; } export function toString(acct: Acct): string { diff --git a/packages/backend/src/misc/antenna-cache.ts b/packages/backend/src/misc/antenna-cache.ts deleted file mode 100644 index dcf96c161..000000000 --- a/packages/backend/src/misc/antenna-cache.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Antennas } from '@/models/index.js'; -import { Antenna } from '@/models/entities/antenna.js'; -import { subsdcriber } from '../db/redis.js'; - -let antennasFetched = false; -let antennas: Antenna[] = []; - -export async function getAntennas() { - if (!antennasFetched) { - antennas = await Antennas.find(); - antennasFetched = true; - } - - return antennas; -} - -subsdcriber.on('message', async (_, data) => { - const obj = JSON.parse(data); - - if (obj.channel === 'internal') { - const { type, body } = obj.message; - switch (type) { - case 'antennaCreated': - antennas.push(body); - break; - case 'antennaUpdated': - antennas[antennas.findIndex(a => a.id === body.id)] = body; - break; - case 'antennaDeleted': - antennas = antennas.filter(a => a.id !== body.id); - break; - default: - break; - } - } -}); diff --git a/packages/backend/src/misc/app-lock.ts b/packages/backend/src/misc/app-lock.ts deleted file mode 100644 index b5089cc6a..000000000 --- a/packages/backend/src/misc/app-lock.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { redisClient } from '../db/redis.js'; -import { promisify } from 'node:util'; -import redisLock from 'redis-lock'; - -/** - * Retry delay (ms) for lock acquisition - */ -const retryDelay = 100; - -const lock: (key: string, timeout?: number) => Promise<() => void> - = redisClient - ? promisify(redisLock(redisClient, retryDelay)) - : async () => () => { }; - -/** - * Get AP Object lock - * @param uri AP object ID - * @param timeout Lock timeout (ms), The timeout releases previous lock. - * @returns Unlock function - */ -export function getApLock(uri: string, timeout = 30 * 1000) { - return lock(`ap-object:${uri}`, timeout); -} - -export function getFetchInstanceMetadataLock(host: string, timeout = 30 * 1000) { - return lock(`instance:${host}`, timeout); -} - -export function getChartInsertLock(lockKey: string, timeout = 30 * 1000) { - return lock(`chart-insert:${lockKey}`, timeout); -} diff --git a/packages/backend/src/misc/before-shutdown.ts b/packages/backend/src/misc/before-shutdown.ts deleted file mode 100644 index 93ac7a1f3..000000000 --- a/packages/backend/src/misc/before-shutdown.ts +++ /dev/null @@ -1,94 +0,0 @@ -// https://gist.github.com/nfantone/1eaa803772025df69d07f4dbf5df7e58 - -'use strict'; - -/** - * @callback BeforeShutdownListener - * @param {string} [signalOrEvent] The exit signal or event name received on the process. - */ - -/** - * System signals the app will listen to initiate shutdown. - * @const {string[]} - */ -const SHUTDOWN_SIGNALS = ['SIGINT', 'SIGTERM']; - -/** - * Time in milliseconds to wait before forcing shutdown. - * @const {number} - */ -const SHUTDOWN_TIMEOUT = 15000; - -/** - * A queue of listener callbacks to execute before shutting - * down the process. - * @type {BeforeShutdownListener[]} - */ -const shutdownListeners: ((signalOrEvent: string) => void)[] = []; - -/** - * Listen for signals and execute given `fn` function once. - * @param {string[]} signals System signals to listen to. - * @param {function(string)} fn Function to execute on shutdown. - */ -const processOnce = (signals: string[], fn: (signalOrEvent: string) => void) => { - for (const sig of signals) { - process.once(sig, fn); - } -}; - -/** - * Sets a forced shutdown mechanism that will exit the process after `timeout` milliseconds. - * @param {number} timeout Time to wait before forcing shutdown (milliseconds) - */ -const forceExitAfter = (timeout: number) => () => { - setTimeout(() => { - // Force shutdown after timeout - console.warn(`Could not close resources gracefully after ${timeout}ms: forcing shutdown`); - return process.exit(1); - }, timeout).unref(); -}; - -/** - * Main process shutdown handler. Will invoke every previously registered async shutdown listener - * in the queue and exit with a code of `0`. Any `Promise` rejections from any listener will - * be logged out as a warning, but won't prevent other callbacks from executing. - * @param {string} signalOrEvent The exit signal or event name received on the process. - */ -async function shutdownHandler(signalOrEvent: string) { - if (process.env.NODE_ENV === 'test') return process.exit(0); - - console.warn(`Shutting down: received [${signalOrEvent}] signal`); - - for (const listener of shutdownListeners) { - try { - await listener(signalOrEvent); - } catch (err) { - if (err instanceof Error) { - console.warn(`A shutdown handler failed before completing with: ${err.message || err}`); - } - } - } - - return process.exit(0); -} - -/** - * Registers a new shutdown listener to be invoked before exiting - * the main process. Listener handlers are guaranteed to be called in the order - * they were registered. - * @param {BeforeShutdownListener} listener The shutdown listener to register. - * @returns {BeforeShutdownListener} Echoes back the supplied `listener`. - */ -export function beforeShutdown(listener: () => void) { - shutdownListeners.push(listener); - return listener; -} - -// Register shutdown callback that kills the process after `SHUTDOWN_TIMEOUT` milliseconds -// This prevents custom shutdown handlers from hanging the process indefinitely -processOnce(SHUTDOWN_SIGNALS, forceExitAfter(SHUTDOWN_TIMEOUT)); - -// Register process shutdown callback -// Will listen to incoming signal events and execute all registered handlers in the stack -processOnce(SHUTDOWN_SIGNALS, shutdownHandler); diff --git a/packages/backend/src/misc/captcha.ts b/packages/backend/src/misc/captcha.ts deleted file mode 100644 index 9a87a4a3c..000000000 --- a/packages/backend/src/misc/captcha.ts +++ /dev/null @@ -1,57 +0,0 @@ -import fetch from 'node-fetch'; -import { URLSearchParams } from 'node:url'; -import { getAgentByUrl } from './fetch.js'; -import config from '@/config/index.js'; - -export async function verifyRecaptcha(secret: string, response: string) { - const result = await getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(e => { - throw `recaptcha-request-failed: ${e}`; - }); - - if (result.success !== true) { - const errorCodes = result['error-codes'] ? result['error-codes']?.join(', ') : ''; - throw `recaptcha-failed: ${errorCodes}`; - } -} - -export async function verifyHcaptcha(secret: string, response: string) { - const result = await getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(e => { - throw `hcaptcha-request-failed: ${e}`; - }); - - if (result.success !== true) { - const errorCodes = result['error-codes'] ? result['error-codes']?.join(', ') : ''; - throw `hcaptcha-failed: ${errorCodes}`; - } -} - -type CaptchaResponse = { - success: boolean; - 'error-codes'?: string[]; -}; - -async function getCaptchaResponse(url: string, secret: string, response: string): Promise { - const params = new URLSearchParams({ - secret, - response, - }); - - const res = await fetch(url, { - method: 'POST', - body: params, - headers: { - 'User-Agent': config.userAgent, - }, - // TODO - //timeout: 10 * 1000, - agent: getAgentByUrl, - }).catch(e => { - throw `${e.message || e}`; - }); - - if (!res.ok) { - throw `${res.status}`; - } - - return await res.json() as CaptchaResponse; -} diff --git a/packages/backend/src/misc/check-hit-antenna.ts b/packages/backend/src/misc/check-hit-antenna.ts deleted file mode 100644 index d9cedee7d..000000000 --- a/packages/backend/src/misc/check-hit-antenna.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { Antenna } from '@/models/entities/antenna.js'; -import { Note } from '@/models/entities/note.js'; -import { User } from '@/models/entities/user.js'; -import { UserListJoinings, UserGroupJoinings, Blockings } from '@/models/index.js'; -import { getFullApAccount } from './convert-host.js'; -import * as Acct from '@/misc/acct.js'; -import { Packed } from './schema.js'; -import { Cache } from './cache.js'; - -const blockingCache = new Cache(1000 * 60 * 5); - -// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている - -/** - * noteUserFollowers / antennaUserFollowing はどちらか一方が指定されていればよい - */ -export async function checkHitAntenna(antenna: Antenna, note: (Note | Packed<'Note'>), noteUser: { id: User['id']; username: string; host: string | null; }, noteUserFollowers?: User['id'][], antennaUserFollowing?: User['id'][]): Promise { - if (note.visibility === 'specified') return false; - - // アンテナ作成者がノート作成者にブロックされていたらスキップ - const blockings = await blockingCache.fetch(noteUser.id, () => Blockings.findBy({ blockerId: noteUser.id }).then(res => res.map(x => x.blockeeId))); - if (blockings.some(blocking => blocking === antenna.userId)) return false; - - if (note.visibility === 'followers') { - if (noteUserFollowers && !noteUserFollowers.includes(antenna.userId)) return false; - if (antennaUserFollowing && !antennaUserFollowing.includes(note.userId)) return false; - } - - if (!antenna.withReplies && note.replyId != null) return false; - - if (antenna.src === 'home') { - if (noteUserFollowers && !noteUserFollowers.includes(antenna.userId)) return false; - if (antennaUserFollowing && !antennaUserFollowing.includes(note.userId)) return false; - } else if (antenna.src === 'list') { - const listUsers = (await UserListJoinings.findBy({ - userListId: antenna.userListId!, - })).map(x => x.userId); - - if (!listUsers.includes(note.userId)) return false; - } else if (antenna.src === 'group') { - const joining = await UserGroupJoinings.findOneByOrFail({ id: antenna.userGroupJoiningId! }); - - const groupUsers = (await UserGroupJoinings.findBy({ - userGroupId: joining.userGroupId, - })).map(x => x.userId); - - if (!groupUsers.includes(note.userId)) return false; - } else if (antenna.src === 'users') { - const accts = antenna.users.map(x => { - const { username, host } = Acct.parse(x); - return getFullApAccount(username, host).toLowerCase(); - }); - if (!accts.includes(getFullApAccount(noteUser.username, noteUser.host).toLowerCase())) return false; - } - - const keywords = antenna.keywords - // Clean up - .map(xs => xs.filter(x => x !== '')) - .filter(xs => xs.length > 0); - - if (keywords.length > 0) { - if (note.text == null) return false; - - const matched = keywords.some(and => - and.every(keyword => - antenna.caseSensitive - ? note.text!.includes(keyword) - : note.text!.toLowerCase().includes(keyword.toLowerCase()) - )); - - if (!matched) return false; - } - - const excludeKeywords = antenna.excludeKeywords - // Clean up - .map(xs => xs.filter(x => x !== '')) - .filter(xs => xs.length > 0); - - if (excludeKeywords.length > 0) { - if (note.text == null) return false; - - const matched = excludeKeywords.some(and => - and.every(keyword => - antenna.caseSensitive - ? note.text!.includes(keyword) - : note.text!.toLowerCase().includes(keyword.toLowerCase()) - )); - - if (matched) return false; - } - - if (antenna.withFile) { - if (note.fileIds && note.fileIds.length === 0) return false; - } - - // TODO: eval expression - - return true; -} diff --git a/packages/backend/src/misc/check-word-mute.ts b/packages/backend/src/misc/check-word-mute.ts index d7662820a..d10aca9e8 100644 --- a/packages/backend/src/misc/check-word-mute.ts +++ b/packages/backend/src/misc/check-word-mute.ts @@ -1,6 +1,6 @@ import RE2 from 're2'; -import { Note } from '@/models/entities/note.js'; -import { User } from '@/models/entities/user.js'; +import type { Note } from '@/models/entities/Note.js'; +import type { User } from '@/models/entities/User.js'; type NoteLike = { userId: Note['userId']; diff --git a/packages/backend/src/misc/convert-host.ts b/packages/backend/src/misc/convert-host.ts deleted file mode 100644 index 7eb940a7e..000000000 --- a/packages/backend/src/misc/convert-host.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { URL } from 'node:url'; -import config from '@/config/index.js'; -import { toASCII } from 'punycode'; - -export function getFullApAccount(username: string, host: string | null) { - return host ? `${username}@${toPuny(host)}` : `${username}@${toPuny(config.host)}`; -} - -export function isSelfHost(host: string) { - if (host == null) return true; - return toPuny(config.host) === toPuny(host); -} - -export function extractDbHost(uri: string) { - const url = new URL(uri); - return toPuny(url.hostname); -} - -export function toPuny(host: string) { - return toASCII(host.toLowerCase()); -} - -export function toPunyNullable(host: string | null | undefined): string | null { - if (host == null) return null; - return toASCII(host.toLowerCase()); -} diff --git a/packages/backend/src/misc/count-same-renotes.ts b/packages/backend/src/misc/count-same-renotes.ts deleted file mode 100644 index b7f8ce90c..000000000 --- a/packages/backend/src/misc/count-same-renotes.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Notes } from '@/models/index.js'; - -export async function countSameRenotes(userId: string, renoteId: string, excludeNoteId: string | undefined): Promise { - // 指定したユーザーの指定したノートのリノートがいくつあるか数える - const query = Notes.createQueryBuilder('note') - .where('note.userId = :userId', { userId }) - .andWhere('note.renoteId = :renoteId', { renoteId }); - - // 指定した投稿を除く - if (excludeNoteId) { - query.andWhere('note.id != :excludeNoteId', { excludeNoteId }); - } - - return await query.getCount(); -} diff --git a/packages/backend/src/misc/detect-url-mime.ts b/packages/backend/src/misc/detect-url-mime.ts deleted file mode 100644 index cd143cf2f..000000000 --- a/packages/backend/src/misc/detect-url-mime.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createTemp } from './create-temp.js'; -import { downloadUrl } from './download-url.js'; -import { detectType } from './get-file-info.js'; - -export async function detectUrlMime(url: string) { - const [path, cleanup] = await createTemp(); - - try { - await downloadUrl(url, path); - const { mime } = await detectType(path); - return mime; - } finally { - cleanup(); - } -} diff --git a/packages/backend/src/misc/download-text-file.ts b/packages/backend/src/misc/download-text-file.ts deleted file mode 100644 index c62c70ee3..000000000 --- a/packages/backend/src/misc/download-text-file.ts +++ /dev/null @@ -1,25 +0,0 @@ -import * as fs from 'node:fs'; -import * as util from 'node:util'; -import Logger from '@/services/logger.js'; -import { createTemp } from './create-temp.js'; -import { downloadUrl } from './download-url.js'; - -const logger = new Logger('download-text-file'); - -export async function downloadTextFile(url: string): Promise { - // Create temp file - const [path, cleanup] = await createTemp(); - - logger.info(`Temp file is ${path}`); - - try { - // write content at URL to temp file - await downloadUrl(url, path); - - const text = await util.promisify(fs.readFile)(path, 'utf8'); - - return text; - } finally { - cleanup(); - } -} diff --git a/packages/backend/src/misc/download-url.ts b/packages/backend/src/misc/download-url.ts deleted file mode 100644 index 7c57b140e..000000000 --- a/packages/backend/src/misc/download-url.ts +++ /dev/null @@ -1,89 +0,0 @@ -import * as fs from 'node:fs'; -import * as stream from 'node:stream'; -import * as util from 'node:util'; -import got, * as Got from 'got'; -import { httpAgent, httpsAgent, StatusError } from './fetch.js'; -import config from '@/config/index.js'; -import chalk from 'chalk'; -import Logger from '@/services/logger.js'; -import IPCIDR from 'ip-cidr'; -import PrivateIp from 'private-ip'; - -const pipeline = util.promisify(stream.pipeline); - -export async function downloadUrl(url: string, path: string): Promise { - const logger = new Logger('download'); - - logger.info(`Downloading ${chalk.cyan(url)} ...`); - - const timeout = 30 * 1000; - const operationTimeout = 60 * 1000; - const maxSize = config.maxFileSize || 262144000; - - const req = got.stream(url, { - headers: { - 'User-Agent': config.userAgent, - }, - timeout: { - lookup: timeout, - connect: timeout, - secureConnect: timeout, - socket: timeout, // read timeout - response: timeout, - send: timeout, - request: operationTimeout, // whole operation timeout - }, - agent: { - http: httpAgent, - https: httpsAgent, - }, - http2: false, // default - retry: { - limit: 0, - }, - }).on('response', (res: Got.Response) => { - if ((process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test') && !config.proxy && res.ip) { - if (isPrivateIp(res.ip)) { - logger.warn(`Blocked address: ${res.ip}`); - req.destroy(); - } - } - - const contentLength = res.headers['content-length']; - if (contentLength != null) { - const size = Number(contentLength); - if (size > maxSize) { - logger.warn(`maxSize exceeded (${size} > ${maxSize}) on response`); - req.destroy(); - } - } - }).on('downloadProgress', (progress: Got.Progress) => { - if (progress.transferred > maxSize) { - logger.warn(`maxSize exceeded (${progress.transferred} > ${maxSize}) on downloadProgress`); - req.destroy(); - } - }); - - try { - await pipeline(req, fs.createWriteStream(path)); - } catch (e) { - if (e instanceof Got.HTTPError) { - throw new StatusError(`${e.response.statusCode} ${e.response.statusMessage}`, e.response.statusCode, e.response.statusMessage); - } else { - throw e; - } - } - - logger.succ(`Download finished: ${chalk.cyan(url)}`); -} - -function isPrivateIp(ip: string): boolean { - for (const net of config.allowedPrivateNetworks || []) { - const cidr = new IPCIDR(net); - if (cidr.contains(ip)) { - return false; - } - } - - return PrivateIp(ip); -} diff --git a/packages/backend/src/misc/extract-custom-emojis-from-mfm.ts b/packages/backend/src/misc/extract-custom-emojis-from-mfm.ts index a0319d8dd..8fb3f4b19 100644 --- a/packages/backend/src/misc/extract-custom-emojis-from-mfm.ts +++ b/packages/backend/src/misc/extract-custom-emojis-from-mfm.ts @@ -1,5 +1,5 @@ import * as mfm from 'mfm-js'; -import { unique } from '@/prelude/array.js'; +import { unique } from '@/misc/prelude/array.js'; export function extractCustomEmojisFromMfm(nodes: mfm.MfmNode[]): string[] { const emojiNodes = mfm.extract(nodes, (node) => { diff --git a/packages/backend/src/misc/extract-hashtags.ts b/packages/backend/src/misc/extract-hashtags.ts index 0b0418eef..f8cabda3d 100644 --- a/packages/backend/src/misc/extract-hashtags.ts +++ b/packages/backend/src/misc/extract-hashtags.ts @@ -1,5 +1,5 @@ import * as mfm from 'mfm-js'; -import { unique } from '@/prelude/array.js'; +import { unique } from '@/misc/prelude/array.js'; export function extractHashtags(nodes: mfm.MfmNode[]): string[] { const hashtagNodes = mfm.extract(nodes, (node) => node.type === 'hashtag'); diff --git a/packages/backend/src/misc/extract-mentions.ts b/packages/backend/src/misc/extract-mentions.ts index cc19b161a..c8762e797 100644 --- a/packages/backend/src/misc/extract-mentions.ts +++ b/packages/backend/src/misc/extract-mentions.ts @@ -4,7 +4,7 @@ import * as mfm from 'mfm-js'; export function extractMentions(nodes: mfm.MfmNode[]): mfm.MfmMention['props'][] { // TODO: 重複を削除 - const mentionNodes = mfm.extract(nodes, (node) => node.type === 'mention'); + const mentionNodes = mfm.extract(nodes, (node) => node.type === 'mention') as mfm.MfmMention[]; const mentions = mentionNodes.map(x => x.props); return mentions; diff --git a/packages/backend/src/misc/fetch-meta.ts b/packages/backend/src/misc/fetch-meta.ts deleted file mode 100644 index e855ac28e..000000000 --- a/packages/backend/src/misc/fetch-meta.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { db } from '@/db/postgre.js'; -import { Meta } from '@/models/entities/meta.js'; - -let cache: Meta; - -export async function fetchMeta(noCache = false): Promise { - if (!noCache && cache) return cache; - - return await db.transaction(async transactionalEntityManager => { - // 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する - const metas = await transactionalEntityManager.find(Meta, { - order: { - id: 'DESC', - }, - }); - - const meta = metas[0]; - - if (meta) { - cache = meta; - return meta; - } else { - // metaが空のときfetchMetaが同時に呼ばれるとここが同時に呼ばれてしまうことがあるのでフェイルセーフなupsertを使う - const saved = await transactionalEntityManager - .upsert( - Meta, - { - id: 'x', - }, - ['id'], - ) - .then((x) => transactionalEntityManager.findOneByOrFail(Meta, x.identifiers[0])); - - cache = saved; - return saved; - } - }); -} - -setInterval(() => { - fetchMeta(true).then(meta => { - cache = meta; - }); -}, 1000 * 10); diff --git a/packages/backend/src/misc/fetch-proxy-account.ts b/packages/backend/src/misc/fetch-proxy-account.ts deleted file mode 100644 index b61bba264..000000000 --- a/packages/backend/src/misc/fetch-proxy-account.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { fetchMeta } from './fetch-meta.js'; -import { ILocalUser } from '@/models/entities/user.js'; -import { Users } from '@/models/index.js'; - -export async function fetchProxyAccount(): Promise { - const meta = await fetchMeta(); - if (meta.proxyAccountId == null) return null; - return await Users.findOneByOrFail({ id: meta.proxyAccountId }) as ILocalUser; -} diff --git a/packages/backend/src/misc/fetch.ts b/packages/backend/src/misc/fetch.ts deleted file mode 100644 index af6bf2fca..000000000 --- a/packages/backend/src/misc/fetch.ts +++ /dev/null @@ -1,141 +0,0 @@ -import * as http from 'node:http'; -import * as https from 'node:https'; -import { URL } from 'node:url'; -import CacheableLookup from 'cacheable-lookup'; -import fetch from 'node-fetch'; -import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent'; -import config from '@/config/index.js'; - -export async function getJson(url: string, accept = 'application/json, */*', timeout = 10000, headers?: Record) { - const res = await getResponse({ - url, - method: 'GET', - headers: Object.assign({ - 'User-Agent': config.userAgent, - Accept: accept, - }, headers || {}), - timeout, - }); - - return await res.json(); -} - -export async function getHtml(url: string, accept = 'text/html, */*', timeout = 10000, headers?: Record) { - const res = await getResponse({ - url, - method: 'GET', - headers: Object.assign({ - 'User-Agent': config.userAgent, - Accept: accept, - }, headers || {}), - timeout, - }); - - return await res.text(); -} - -export async function getResponse(args: { url: string, method: string, body?: string, headers: Record, timeout?: number, size?: number }) { - const timeout = args.timeout || 10 * 1000; - - const controller = new AbortController(); - setTimeout(() => { - controller.abort(); - }, timeout * 6); - - const res = await fetch(args.url, { - method: args.method, - headers: args.headers, - body: args.body, - timeout, - size: args.size || 10 * 1024 * 1024, - agent: getAgentByUrl, - signal: controller.signal, - }); - - if (!res.ok) { - throw new StatusError(`${res.status} ${res.statusText}`, res.status, res.statusText); - } - - return res; -} - -const cache = new CacheableLookup({ - maxTtl: 3600, // 1hours - errorTtl: 30, // 30secs - lookup: false, // nativeのdns.lookupにfallbackしない -}); - -/** - * Get http non-proxy agent - */ -const _http = new http.Agent({ - keepAlive: true, - keepAliveMsecs: 30 * 1000, - lookup: cache.lookup, -} as http.AgentOptions); - -/** - * Get https non-proxy agent - */ -const _https = new https.Agent({ - keepAlive: true, - keepAliveMsecs: 30 * 1000, - lookup: cache.lookup, -} as https.AgentOptions); - -const maxSockets = Math.max(256, config.deliverJobConcurrency || 128); - -/** - * Get http proxy or non-proxy agent - */ -export const httpAgent = config.proxy - ? new HttpProxyAgent({ - keepAlive: true, - keepAliveMsecs: 30 * 1000, - maxSockets, - maxFreeSockets: 256, - scheduling: 'lifo', - proxy: config.proxy, - }) - : _http; - -/** - * Get https proxy or non-proxy agent - */ -export const httpsAgent = config.proxy - ? new HttpsProxyAgent({ - keepAlive: true, - keepAliveMsecs: 30 * 1000, - maxSockets, - maxFreeSockets: 256, - scheduling: 'lifo', - proxy: config.proxy, - }) - : _https; - -/** - * Get agent by URL - * @param url URL - * @param bypassProxy Allways bypass proxy - */ -export function getAgentByUrl(url: URL, bypassProxy = false) { - if (bypassProxy || (config.proxyBypassHosts || []).includes(url.hostname)) { - return url.protocol === 'http:' ? _http : _https; - } else { - return url.protocol === 'http:' ? httpAgent : httpsAgent; - } -} - -export class StatusError extends Error { - public statusCode: number; - public statusMessage?: string; - public isClientError: boolean; - - constructor(message: string, statusCode: number, statusMessage?: string) { - super(message); - this.name = 'StatusError'; - this.statusCode = statusCode; - this.statusMessage = statusMessage; - this.isClientError = typeof this.statusCode === 'number' && this.statusCode >= 400 && this.statusCode < 500; - } -} diff --git a/packages/backend/src/misc/gen-id.ts b/packages/backend/src/misc/gen-id.ts deleted file mode 100644 index fcf476857..000000000 --- a/packages/backend/src/misc/gen-id.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ulid } from 'ulid'; -import { genAid } from './id/aid.js'; -import { genMeid } from './id/meid.js'; -import { genMeidg } from './id/meidg.js'; -import { genObjectId } from './id/object-id.js'; -import config from '@/config/index.js'; - -const metohd = config.id.toLowerCase(); - -export function genId(date?: Date): string { - if (!date || (date > new Date())) date = new Date(); - - switch (metohd) { - case 'aid': return genAid(date); - case 'meid': return genMeid(date); - case 'meidg': return genMeidg(date); - case 'ulid': return ulid(date.getTime()); - case 'objectid': return genObjectId(date); - default: throw new Error('unrecognized id generation method'); - } -} diff --git a/packages/backend/src/misc/gen-identicon.ts b/packages/backend/src/misc/gen-identicon.ts index 322ffee22..4a70d7a4b 100644 --- a/packages/backend/src/misc/gen-identicon.ts +++ b/packages/backend/src/misc/gen-identicon.ts @@ -3,9 +3,9 @@ * https://en.wikipedia.org/wiki/Identicon */ -import { WriteStream } from 'node:fs'; import * as p from 'pureimage'; import gen from 'random-seed'; +import type { WriteStream } from 'node:fs'; const size = 128; // px const n = 5; // resolution diff --git a/packages/backend/src/server/api/common/generate-native-user-token.ts b/packages/backend/src/misc/generate-native-user-token.ts similarity index 100% rename from packages/backend/src/server/api/common/generate-native-user-token.ts rename to packages/backend/src/misc/generate-native-user-token.ts diff --git a/packages/backend/src/misc/get-file-info.ts b/packages/backend/src/misc/get-file-info.ts deleted file mode 100644 index 1c988b248..000000000 --- a/packages/backend/src/misc/get-file-info.ts +++ /dev/null @@ -1,374 +0,0 @@ -import * as fs from 'node:fs'; -import * as crypto from 'node:crypto'; -import { join } from 'node:path'; -import * as stream from 'node:stream'; -import * as util from 'node:util'; -import { FSWatcher } from 'chokidar'; -import { fileTypeFromFile } from 'file-type'; -import FFmpeg from 'fluent-ffmpeg'; -import isSvg from 'is-svg'; -import probeImageSize from 'probe-image-size'; -import { type predictionType } from 'nsfwjs'; -import sharp from 'sharp'; -import { encode } from 'blurhash'; -import { detectSensitive } from '@/services/detect-sensitive.js'; -import { createTempDir } from './create-temp.js'; - -const pipeline = util.promisify(stream.pipeline); - -export type FileInfo = { - size: number; - md5: string; - type: { - mime: string; - ext: string | null; - }; - width?: number; - height?: number; - orientation?: number; - blurhash?: string; - sensitive: boolean; - porn: boolean; - warnings: string[]; -}; - -const TYPE_OCTET_STREAM = { - mime: 'application/octet-stream', - ext: null, -}; - -const TYPE_SVG = { - mime: 'image/svg+xml', - ext: 'svg', -}; - -/** - * Get file information - */ -export async function getFileInfo(path: string, opts: { - skipSensitiveDetection: boolean; - sensitiveThreshold?: number; - sensitiveThresholdForPorn?: number; - enableSensitiveMediaDetectionForVideos?: boolean; -}): Promise { - const warnings = [] as string[]; - - const size = await getFileSize(path); - const md5 = await calcHash(path); - - let type = await detectType(path); - - // image dimensions - let width: number | undefined; - let height: number | undefined; - let orientation: number | undefined; - - if (['image/jpeg', 'image/gif', 'image/png', 'image/apng', 'image/webp', 'image/bmp', 'image/tiff', 'image/svg+xml', 'image/vnd.adobe.photoshop'].includes(type.mime)) { - const imageSize = await detectImageSize(path).catch(e => { - warnings.push(`detectImageSize failed: ${e}`); - return undefined; - }); - - // うまく判定できない画像は octet-stream にする - if (!imageSize) { - warnings.push('cannot detect image dimensions'); - type = TYPE_OCTET_STREAM; - } else if (imageSize.wUnits === 'px') { - width = imageSize.width; - height = imageSize.height; - orientation = imageSize.orientation; - - // 制限を超えている画像は octet-stream にする - if (imageSize.width > 16383 || imageSize.height > 16383) { - warnings.push('image dimensions exceeds limits'); - type = TYPE_OCTET_STREAM; - } - } else { - warnings.push(`unsupported unit type: ${imageSize.wUnits}`); - } - } - - let blurhash: string | undefined; - - if (['image/jpeg', 'image/gif', 'image/png', 'image/apng', 'image/webp', 'image/svg+xml'].includes(type.mime)) { - blurhash = await getBlurhash(path).catch(e => { - warnings.push(`getBlurhash failed: ${e}`); - return undefined; - }); - } - - let sensitive = false; - let porn = false; - - if (!opts.skipSensitiveDetection) { - await detectSensitivity( - path, - type.mime, - opts.sensitiveThreshold ?? 0.5, - opts.sensitiveThresholdForPorn ?? 0.75, - opts.enableSensitiveMediaDetectionForVideos ?? false, - ).then(value => { - [sensitive, porn] = value; - }, error => { - warnings.push(`detectSensitivity failed: ${error}`); - }); - } - - return { - size, - md5, - type, - width, - height, - orientation, - blurhash, - sensitive, - porn, - warnings, - }; -} - -async function detectSensitivity(source: string, mime: string, sensitiveThreshold: number, sensitiveThresholdForPorn: number, analyzeVideo: boolean): Promise<[sensitive: boolean, porn: boolean]> { - let sensitive = false; - let porn = false; - - function judgePrediction(result: readonly predictionType[]): [sensitive: boolean, porn: boolean] { - let sensitive = false; - let porn = false; - - if ((result.find(x => x.className === 'Sexy')?.probability ?? 0) > sensitiveThreshold) sensitive = true; - if ((result.find(x => x.className === 'Hentai')?.probability ?? 0) > sensitiveThreshold) sensitive = true; - if ((result.find(x => x.className === 'Porn')?.probability ?? 0) > sensitiveThreshold) sensitive = true; - - if ((result.find(x => x.className === 'Porn')?.probability ?? 0) > sensitiveThresholdForPorn) porn = true; - - return [sensitive, porn]; - } - - if (['image/jpeg', 'image/png', 'image/webp'].includes(mime)) { - const result = await detectSensitive(source); - if (result) { - [sensitive, porn] = judgePrediction(result); - } - } else if (analyzeVideo && (mime === 'image/apng' || mime.startsWith('video/'))) { - const [outDir, disposeOutDir] = await createTempDir(); - try { - const command = FFmpeg() - .input(source) - .inputOptions([ - '-skip_frame', 'nokey', // 可能ならキーフレームのみを取得してほしいとする(そうなるとは限らない) - '-lowres', '3', // 元の画質でデコードする必要はないので 1/8 画質でデコードしてもよいとする(そうなるとは限らない) - ]) - .noAudio() - .videoFilters([ - { - filter: 'select', // フレームのフィルタリング - options: { - e: 'eq(pict_type,PICT_TYPE_I)', // I-Frame のみをフィルタする(VP9 とかはデコードしてみないとわからないっぽい) - }, - }, - { - filter: 'blackframe', // 暗いフレームの検出 - options: { - amount: '0', // 暗さに関わらず全てのフレームで測定値を取る - }, - }, - { - filter: 'metadata', - options: { - mode: 'select', // フレーム選択モード - key: 'lavfi.blackframe.pblack', // フレームにおける暗部の百分率(前のフィルタからのメタデータを参照する) - value: '50', - function: 'less', // 50% 未満のフレームを選択する(50% 以上暗部があるフレームだと誤検知を招くかもしれないので) - }, - }, - { - filter: 'scale', - options: { - w: 299, - h: 299, - }, - }, - ]) - .format('image2') - .output(join(outDir, '%d.png')) - .outputOptions(['-vsync', '0']); // 可変フレームレートにすることで穴埋めをさせない - const results: ReturnType[] = []; - let frameIndex = 0; - let targetIndex = 0; - let nextIndex = 1; - for await (const path of asyncIterateFrames(outDir, command)) { - try { - const index = frameIndex++; - if (index !== targetIndex) { - continue; - } - targetIndex = nextIndex; - nextIndex += index; // fibonacci sequence によってフレーム数制限を掛ける - const result = await detectSensitive(path); - if (result) { - results.push(judgePrediction(result)); - } - } finally { - fs.promises.unlink(path); - } - } - sensitive = results.filter(x => x[0]).length >= Math.ceil(results.length * sensitiveThreshold); - porn = results.filter(x => x[1]).length >= Math.ceil(results.length * sensitiveThresholdForPorn); - } finally { - disposeOutDir(); - } - } - - return [sensitive, porn]; -} - -async function* asyncIterateFrames(cwd: string, command: FFmpeg.FfmpegCommand): AsyncGenerator { - const watcher = new FSWatcher({ - cwd, - disableGlobbing: true, - }); - let finished = false; - command.once('end', () => { - finished = true; - watcher.close(); - }); - command.run(); - for (let i = 1; true; i++) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition - const current = `${i}.png`; - const next = `${i + 1}.png`; - const framePath = join(cwd, current); - if (await exists(join(cwd, next))) { - yield framePath; - } else if (!finished) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition - watcher.add(next); - await new Promise((resolve, reject) => { - watcher.on('add', function onAdd(path) { - if (path === next) { // 次フレームの書き出しが始まっているなら、現在フレームの書き出しは終わっている - watcher.unwatch(current); - watcher.off('add', onAdd); - resolve(); - } - }); - command.once('end', resolve); // 全てのフレームを処理し終わったなら、最終フレームである現在フレームの書き出しは終わっている - command.once('error', reject); - }); - yield framePath; - } else if (await exists(framePath)) { - yield framePath; - } else { - return; - } - } -} - -function exists(path: string): Promise { - return fs.promises.access(path).then(() => true, () => false); -} - -/** - * Detect MIME Type and extension - */ -export async function detectType(path: string): Promise<{ - mime: string; - ext: string | null; -}> { - // Check 0 byte - const fileSize = await getFileSize(path); - if (fileSize === 0) { - return TYPE_OCTET_STREAM; - } - - const type = await fileTypeFromFile(path); - - if (type) { - // XMLはSVGかもしれない - if (type.mime === 'application/xml' && await checkSvg(path)) { - return TYPE_SVG; - } - - return { - mime: type.mime, - ext: type.ext, - }; - } - - // 種類が不明でもSVGかもしれない - if (await checkSvg(path)) { - return TYPE_SVG; - } - - // それでも種類が不明なら application/octet-stream にする - return TYPE_OCTET_STREAM; -} - -/** - * Check the file is SVG or not - */ -export async function checkSvg(path: string) { - try { - const size = await getFileSize(path); - if (size > 1 * 1024 * 1024) return false; - return isSvg(fs.readFileSync(path)); - } catch { - return false; - } -} - -/** - * Get file size - */ -export async function getFileSize(path: string): Promise { - const getStat = util.promisify(fs.stat); - return (await getStat(path)).size; -} - -/** - * Calculate MD5 hash - */ -async function calcHash(path: string): Promise { - const hash = crypto.createHash('md5').setEncoding('hex'); - await pipeline(fs.createReadStream(path), hash); - return hash.read(); -} - -/** - * Detect dimensions of image - */ -async function detectImageSize(path: string): Promise<{ - width: number; - height: number; - wUnits: string; - hUnits: string; - orientation?: number; -}> { - const readable = fs.createReadStream(path); - const imageSize = await probeImageSize(readable); - readable.destroy(); - return imageSize; -} - -/** - * Calculate average color of image - */ -function getBlurhash(path: string): Promise { - return new Promise((resolve, reject) => { - sharp(path) - .raw() - .ensureAlpha() - .resize(64, 64, { fit: 'inside' }) - .toBuffer((err, buffer, { width, height }) => { - if (err) return reject(err); - - let hash; - - try { - hash = encode(new Uint8ClampedArray(buffer), width, height, 7, 7); - } catch (e) { - return reject(e); - } - - resolve(hash); - }); - }); -} diff --git a/packages/backend/src/misc/get-note-summary.ts b/packages/backend/src/misc/get-note-summary.ts index 3f35ccee8..85bc2ec94 100644 --- a/packages/backend/src/misc/get-note-summary.ts +++ b/packages/backend/src/misc/get-note-summary.ts @@ -1,4 +1,4 @@ -import { Packed } from './schema.js'; +import type { Packed } from './schema.js'; /** * 投稿を表す文字列を取得します。 @@ -6,11 +6,11 @@ import { Packed } from './schema.js'; */ export const getNoteSummary = (note: Packed<'Note'>): string => { if (note.deletedAt) { - return `(❌⛔)`; + return '(❌⛔)'; } if (note.isHidden) { - return `(⛔)`; + return '(⛔)'; } let summary = ''; @@ -23,13 +23,13 @@ export const getNoteSummary = (note: Packed<'Note'>): string => { } // ファイルが添付されているとき - if ((note.files || []).length !== 0) { + if ((note.files ?? []).length !== 0) { summary += ` (📎${note.files!.length})`; } // 投票が添付されているとき if (note.poll) { - summary += ` (📊)`; + summary += ' (📊)'; } // 返信のとき diff --git a/packages/backend/src/misc/identifiable-error.ts b/packages/backend/src/misc/identifiable-error.ts index 2d7c6bd0c..e394123f1 100644 --- a/packages/backend/src/misc/identifiable-error.ts +++ b/packages/backend/src/misc/identifiable-error.ts @@ -7,7 +7,7 @@ export class IdentifiableError extends Error { constructor(id: string, message?: string) { super(message); - this.message = message || ''; + this.message = message ?? ''; this.id = id; } } diff --git a/packages/backend/src/misc/is-instance-muted.ts b/packages/backend/src/misc/is-instance-muted.ts index a74ba524e..e11a18bb7 100644 --- a/packages/backend/src/misc/is-instance-muted.ts +++ b/packages/backend/src/misc/is-instance-muted.ts @@ -1,15 +1,15 @@ -import { Packed } from './schema.js'; +import type { Packed } from './schema.js'; export function isInstanceMuted(note: Packed<'Note'>, mutedInstances: Set): boolean { - if (mutedInstances.has(note?.user?.host ?? '')) return true; - if (mutedInstances.has(note?.reply?.user?.host ?? '')) return true; - if (mutedInstances.has(note?.renote?.user?.host ?? '')) return true; + if (mutedInstances.has(note.user.host ?? '')) return true; + if (mutedInstances.has(note.reply?.user.host ?? '')) return true; + if (mutedInstances.has(note.renote?.user.host ?? '')) return true; return false; } export function isUserFromMutedInstance(notif: Packed<'Notification'>, mutedInstances: Set): boolean { - if (mutedInstances.has(notif?.user?.host ?? '')) return true; + if (mutedInstances.has(notif.user?.host ?? '')) return true; return false; } diff --git a/packages/backend/src/server/api/common/is-native-token.ts b/packages/backend/src/misc/is-native-token.ts similarity index 100% rename from packages/backend/src/server/api/common/is-native-token.ts rename to packages/backend/src/misc/is-native-token.ts diff --git a/packages/backend/src/misc/is-quote.ts b/packages/backend/src/misc/is-quote.ts index 779f548b0..248b25a0b 100644 --- a/packages/backend/src/misc/is-quote.ts +++ b/packages/backend/src/misc/is-quote.ts @@ -1,4 +1,4 @@ -import { Note } from '@/models/entities/note.js'; +import type { Note } from '@/models/entities/Note.js'; export default function(note: Note): boolean { return note.renoteId != null && (note.text != null || note.hasPoll || (note.fileIds != null && note.fileIds.length > 0)); diff --git a/packages/backend/src/misc/keypair-store.ts b/packages/backend/src/misc/keypair-store.ts deleted file mode 100644 index 1183b9a78..000000000 --- a/packages/backend/src/misc/keypair-store.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { UserKeypairs } from '@/models/index.js'; -import { User } from '@/models/entities/user.js'; -import { UserKeypair } from '@/models/entities/user-keypair.js'; -import { Cache } from './cache.js'; - -const cache = new Cache(Infinity); - -export async function getUserKeypair(userId: User['id']): Promise { - return await cache.fetch(userId, () => UserKeypairs.findOneByOrFail({ userId: userId })); -} diff --git a/packages/backend/src/misc/populate-emojis.ts b/packages/backend/src/misc/populate-emojis.ts deleted file mode 100644 index 6a185d09f..000000000 --- a/packages/backend/src/misc/populate-emojis.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { In, IsNull } from 'typeorm'; -import { Emojis } from '@/models/index.js'; -import { Emoji } from '@/models/entities/emoji.js'; -import { Note } from '@/models/entities/note.js'; -import { Cache } from './cache.js'; -import { isSelfHost, toPunyNullable } from './convert-host.js'; -import { decodeReaction } from './reaction-lib.js'; -import config from '@/config/index.js'; -import { query } from '@/prelude/url.js'; - -const cache = new Cache(1000 * 60 * 60 * 12); - -/** - * 添付用絵文字情報 - */ -type PopulatedEmoji = { - name: string; - url: string; -}; - -function normalizeHost(src: string | undefined, noteUserHost: string | null): string | null { - // クエリに使うホスト - let host = src === '.' ? null // .はローカルホスト (ここがマッチするのはリアクションのみ) - : src === undefined ? noteUserHost // ノートなどでホスト省略表記の場合はローカルホスト (ここがリアクションにマッチすることはない) - : isSelfHost(src) ? null // 自ホスト指定 - : (src || noteUserHost); // 指定されたホスト || ノートなどの所有者のホスト (こっちがリアクションにマッチすることはない) - - host = toPunyNullable(host); - - return host; -} - -function parseEmojiStr(emojiName: string, noteUserHost: string | null) { - const match = emojiName.match(/^(\w+)(?:@([\w.-]+))?$/); - if (!match) return { name: null, host: null }; - - const name = match[1]; - - // ホスト正規化 - const host = toPunyNullable(normalizeHost(match[2], noteUserHost)); - - return { name, host }; -} - -/** - * 添付用絵文字情報を解決する - * @param emojiName ノートやユーザープロフィールに添付された、またはリアクションのカスタム絵文字名 (:は含めない, リアクションでローカルホストの場合は@.を付ける (これはdecodeReactionで可能)) - * @param noteUserHost ノートやユーザープロフィールの所有者のホスト - * @returns 絵文字情報, nullは未マッチを意味する - */ -export async function populateEmoji(emojiName: string, noteUserHost: string | null): Promise { - const { name, host } = parseEmojiStr(emojiName, noteUserHost); - if (name == null) return null; - - const queryOrNull = async () => (await Emojis.findOneBy({ - name, - host: host ?? IsNull(), - })) || null; - - const emoji = await cache.fetch(`${name} ${host}`, queryOrNull); - - if (emoji == null) return null; - - const isLocal = emoji.host == null; - const emojiUrl = emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため - const url = isLocal ? emojiUrl : `${config.url}/proxy/${encodeURIComponent((new URL(emojiUrl)).pathname)}?${query({ url: emojiUrl })}`; - - return { - name: emojiName, - url, - }; -} - -/** - * 複数の添付用絵文字情報を解決する (キャシュ付き, 存在しないものは結果から除外される) - */ -export async function populateEmojis(emojiNames: string[], noteUserHost: string | null): Promise { - const emojis = await Promise.all(emojiNames.map(x => populateEmoji(x, noteUserHost))); - return emojis.filter((x): x is PopulatedEmoji => x != null); -} - -export function aggregateNoteEmojis(notes: Note[]) { - let emojis: { name: string | null; host: string | null; }[] = []; - for (const note of notes) { - emojis = emojis.concat(note.emojis - .map(e => parseEmojiStr(e, note.userHost))); - if (note.renote) { - emojis = emojis.concat(note.renote.emojis - .map(e => parseEmojiStr(e, note.renote!.userHost))); - if (note.renote.user) { - emojis = emojis.concat(note.renote.user.emojis - .map(e => parseEmojiStr(e, note.renote!.userHost))); - } - } - const customReactions = Object.keys(note.reactions).map(x => decodeReaction(x)).filter(x => x.name != null) as typeof emojis; - emojis = emojis.concat(customReactions); - if (note.user) { - emojis = emojis.concat(note.user.emojis - .map(e => parseEmojiStr(e, note.userHost))); - } - } - return emojis.filter(x => x.name != null) as { name: string; host: string | null; }[]; -} - -/** - * 与えられた絵文字のリストをデータベースから取得し、キャッシュに追加します - */ -export async function prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise { - const notCachedEmojis = emojis.filter(emoji => cache.get(`${emoji.name} ${emoji.host}`) == null); - const emojisQuery: any[] = []; - const hosts = new Set(notCachedEmojis.map(e => e.host)); - for (const host of hosts) { - emojisQuery.push({ - name: In(notCachedEmojis.filter(e => e.host === host).map(e => e.name)), - host: host ?? IsNull(), - }); - } - const _emojis = emojisQuery.length > 0 ? await Emojis.find({ - where: emojisQuery, - select: ['name', 'host', 'originalUrl', 'publicUrl'], - }) : []; - for (const emoji of _emojis) { - cache.set(`${emoji.name} ${emoji.host}`, emoji); - } -} diff --git a/packages/backend/src/prelude/README.md b/packages/backend/src/misc/prelude/README.md similarity index 100% rename from packages/backend/src/prelude/README.md rename to packages/backend/src/misc/prelude/README.md diff --git a/packages/backend/src/prelude/array.ts b/packages/backend/src/misc/prelude/array.ts similarity index 100% rename from packages/backend/src/prelude/array.ts rename to packages/backend/src/misc/prelude/array.ts diff --git a/packages/backend/src/prelude/await-all.ts b/packages/backend/src/misc/prelude/await-all.ts similarity index 100% rename from packages/backend/src/prelude/await-all.ts rename to packages/backend/src/misc/prelude/await-all.ts diff --git a/packages/backend/src/prelude/math.ts b/packages/backend/src/misc/prelude/math.ts similarity index 100% rename from packages/backend/src/prelude/math.ts rename to packages/backend/src/misc/prelude/math.ts diff --git a/packages/backend/src/prelude/maybe.ts b/packages/backend/src/misc/prelude/maybe.ts similarity index 100% rename from packages/backend/src/prelude/maybe.ts rename to packages/backend/src/misc/prelude/maybe.ts diff --git a/packages/backend/src/prelude/relation.ts b/packages/backend/src/misc/prelude/relation.ts similarity index 100% rename from packages/backend/src/prelude/relation.ts rename to packages/backend/src/misc/prelude/relation.ts diff --git a/packages/backend/src/prelude/string.ts b/packages/backend/src/misc/prelude/string.ts similarity index 100% rename from packages/backend/src/prelude/string.ts rename to packages/backend/src/misc/prelude/string.ts diff --git a/packages/backend/src/prelude/symbol.ts b/packages/backend/src/misc/prelude/symbol.ts similarity index 100% rename from packages/backend/src/prelude/symbol.ts rename to packages/backend/src/misc/prelude/symbol.ts diff --git a/packages/backend/src/prelude/time.ts b/packages/backend/src/misc/prelude/time.ts similarity index 100% rename from packages/backend/src/prelude/time.ts rename to packages/backend/src/misc/prelude/time.ts diff --git a/packages/backend/src/prelude/url.ts b/packages/backend/src/misc/prelude/url.ts similarity index 100% rename from packages/backend/src/prelude/url.ts rename to packages/backend/src/misc/prelude/url.ts diff --git a/packages/backend/src/prelude/xml.ts b/packages/backend/src/misc/prelude/xml.ts similarity index 100% rename from packages/backend/src/prelude/xml.ts rename to packages/backend/src/misc/prelude/xml.ts diff --git a/packages/backend/src/misc/reaction-lib.ts b/packages/backend/src/misc/reaction-lib.ts deleted file mode 100644 index fefc2781f..000000000 --- a/packages/backend/src/misc/reaction-lib.ts +++ /dev/null @@ -1,131 +0,0 @@ -/* eslint-disable key-spacing */ -import { emojiRegex } from './emoji-regex.js'; -import { fetchMeta } from './fetch-meta.js'; -import { Emojis } from '@/models/index.js'; -import { toPunyNullable } from './convert-host.js'; -import { IsNull } from 'typeorm'; - -const legacies: Record = { - 'like': '👍', - 'love': '❤', // ここに記述する場合は異体字セレクタを入れない - 'laugh': '😆', - 'hmm': '🤔', - 'surprise': '😮', - 'congrats': '🎉', - 'angry': '💢', - 'confused': '😥', - 'rip': '😇', - 'pudding': '🍮', - 'star': '⭐', -}; - -export async function getFallbackReaction(): Promise { - const meta = await fetchMeta(); - return meta.useStarForReactionFallback ? '⭐' : '👍'; -} - -export function convertLegacyReactions(reactions: Record) { - const _reactions = {} as Record; - - for (const reaction of Object.keys(reactions)) { - if (reactions[reaction] <= 0) continue; - - if (Object.keys(legacies).includes(reaction)) { - if (_reactions[legacies[reaction]]) { - _reactions[legacies[reaction]] += reactions[reaction]; - } else { - _reactions[legacies[reaction]] = reactions[reaction]; - } - } else { - if (_reactions[reaction]) { - _reactions[reaction] += reactions[reaction]; - } else { - _reactions[reaction] = reactions[reaction]; - } - } - } - - const _reactions2 = {} as Record; - - for (const reaction of Object.keys(_reactions)) { - _reactions2[decodeReaction(reaction).reaction] = _reactions[reaction]; - } - - return _reactions2; -} - -export async function toDbReaction(reaction?: string | null, reacterHost?: string | null): Promise { - if (reaction == null) return await getFallbackReaction(); - - reacterHost = toPunyNullable(reacterHost); - - // 文字列タイプのリアクションを絵文字に変換 - if (Object.keys(legacies).includes(reaction)) return legacies[reaction]; - - // Unicode絵文字 - const match = emojiRegex.exec(reaction); - if (match) { - // 合字を含む1つの絵文字 - const unicode = match[0]; - - // 異体字セレクタ除去 - return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, ''); - } - - const custom = reaction.match(/^:([\w+-]+)(?:@\.)?:$/); - if (custom) { - const name = custom[1]; - const emoji = await Emojis.findOneBy({ - host: reacterHost ?? IsNull(), - name, - }); - - if (emoji) return reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`; - } - - return await getFallbackReaction(); -} - -type DecodedReaction = { - /** - * リアクション名 (Unicode Emoji or ':name@hostname' or ':name@.') - */ - reaction: string; - - /** - * name (カスタム絵文字の場合name, Emojiクエリに使う) - */ - name?: string; - - /** - * host (カスタム絵文字の場合host, Emojiクエリに使う) - */ - host?: string | null; -}; - -export function decodeReaction(str: string): DecodedReaction { - const custom = str.match(/^:([\w+-]+)(?:@([\w.-]+))?:$/); - - if (custom) { - const name = custom[1]; - const host = custom[2] || null; - - return { - reaction: `:${name}@${host || '.'}:`, // ローカル分は@以降を省略するのではなく.にする - name, - host, - }; - } - - return { - reaction: str, - name: undefined, - host: undefined, - }; -} - -export function convertLegacyReaction(reaction: string): string { - reaction = decodeReaction(reaction).reaction; - if (Object.keys(legacies).includes(reaction)) return legacies[reaction]; - return reaction; -} diff --git a/packages/backend/src/misc/reset-db.ts b/packages/backend/src/misc/reset-db.ts new file mode 100644 index 000000000..835cd2ba2 --- /dev/null +++ b/packages/backend/src/misc/reset-db.ts @@ -0,0 +1,28 @@ +import type { DataSource } from 'typeorm'; + +export async function resetDb(db: DataSource) { + const reset = async () => { + const tables = await db.query(`SELECT relname AS "table" + FROM pg_class C LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace) + WHERE nspname NOT IN ('pg_catalog', 'information_schema') + AND C.relkind = 'r' + AND nspname !~ '^pg_toast';`); + for (const table of tables) { + await db.query(`DELETE FROM "${table.table}" CASCADE`); + } + }; + + for (let i = 1; i <= 3; i++) { + try { + await reset(); + } catch (e) { + if (i === 3) { + throw e; + } else { + await new Promise(resolve => setTimeout(resolve, 1000)); + continue; + } + } + break; + } +} diff --git a/packages/backend/src/misc/show-machine-info.ts b/packages/backend/src/misc/show-machine-info.ts index bc71cfbe9..fa5a53e31 100644 --- a/packages/backend/src/misc/show-machine-info.ts +++ b/packages/backend/src/misc/show-machine-info.ts @@ -1,6 +1,6 @@ import * as os from 'node:os'; import sysUtils from 'systeminformation'; -import Logger from '@/services/logger.js'; +import type Logger from '@/logger.js'; export async function showMachineInfo(parentLogger: Logger) { const logger = parentLogger.createSubLogger('machine'); diff --git a/packages/backend/src/misc/status-error.ts b/packages/backend/src/misc/status-error.ts new file mode 100644 index 000000000..0a33f8aca --- /dev/null +++ b/packages/backend/src/misc/status-error.ts @@ -0,0 +1,13 @@ +export class StatusError extends Error { + public statusCode: number; + public statusMessage?: string; + public isClientError: boolean; + + constructor(message: string, statusCode: number, statusMessage?: string) { + super(message); + this.name = 'StatusError'; + this.statusCode = statusCode; + this.statusMessage = statusMessage; + this.isClientError = typeof this.statusCode === 'number' && this.statusCode >= 400 && this.statusCode < 500; + } +} diff --git a/packages/backend/src/misc/webhook-cache.ts b/packages/backend/src/misc/webhook-cache.ts deleted file mode 100644 index 4bd233366..000000000 --- a/packages/backend/src/misc/webhook-cache.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Webhooks } from '@/models/index.js'; -import { Webhook } from '@/models/entities/webhook.js'; -import { subsdcriber } from '../db/redis.js'; - -let webhooksFetched = false; -let webhooks: Webhook[] = []; - -export async function getActiveWebhooks() { - if (!webhooksFetched) { - webhooks = await Webhooks.findBy({ - active: true, - }); - webhooksFetched = true; - } - - return webhooks; -} - -subsdcriber.on('message', async (_, data) => { - const obj = JSON.parse(data); - - if (obj.channel === 'internal') { - const { type, body } = obj.message; - switch (type) { - case 'webhookCreated': - if (body.active) { - webhooks.push(body); - } - break; - case 'webhookUpdated': - if (body.active) { - const i = webhooks.findIndex(a => a.id === body.id); - if (i > -1) { - webhooks[i] = body; - } else { - webhooks.push(body); - } - } else { - webhooks = webhooks.filter(a => a.id !== body.id); - } - break; - case 'webhookDeleted': - webhooks = webhooks.filter(a => a.id !== body.id); - break; - default: - break; - } - } -}); diff --git a/packages/backend/src/models/entities/abuse-user-report.ts b/packages/backend/src/models/entities/AbuseUserReport.ts similarity index 96% rename from packages/backend/src/models/entities/abuse-user-report.ts rename to packages/backend/src/models/entities/AbuseUserReport.ts index 6ac563552..07305cf23 100644 --- a/packages/backend/src/models/entities/abuse-user-report.ts +++ b/packages/backend/src/models/entities/AbuseUserReport.ts @@ -1,6 +1,6 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.js'; import { id } from '../id.js'; +import { User } from './User.js'; @Entity() export class AbuseUserReport { @@ -52,7 +52,7 @@ export class AbuseUserReport { public resolved: boolean; @Column('boolean', { - default: false + default: false, }) public forwarded: boolean; diff --git a/packages/backend/src/models/entities/access-token.ts b/packages/backend/src/models/entities/AccessToken.ts similarity index 95% rename from packages/backend/src/models/entities/access-token.ts rename to packages/backend/src/models/entities/AccessToken.ts index c6e2141a4..8e987ffee 100644 --- a/packages/backend/src/models/entities/access-token.ts +++ b/packages/backend/src/models/entities/AccessToken.ts @@ -1,7 +1,7 @@ import { Entity, PrimaryColumn, Index, Column, ManyToOne, JoinColumn } from 'typeorm'; -import { User } from './user.js'; -import { App } from './app.js'; import { id } from '../id.js'; +import { User } from './User.js'; +import { App } from './App.js'; @Entity() export class AccessToken { diff --git a/packages/backend/src/models/entities/ad.ts b/packages/backend/src/models/entities/Ad.ts similarity index 100% rename from packages/backend/src/models/entities/ad.ts rename to packages/backend/src/models/entities/Ad.ts diff --git a/packages/backend/src/models/entities/announcement.ts b/packages/backend/src/models/entities/Announcement.ts similarity index 100% rename from packages/backend/src/models/entities/announcement.ts rename to packages/backend/src/models/entities/Announcement.ts diff --git a/packages/backend/src/models/entities/announcement-read.ts b/packages/backend/src/models/entities/AnnouncementRead.ts similarity index 89% rename from packages/backend/src/models/entities/announcement-read.ts rename to packages/backend/src/models/entities/AnnouncementRead.ts index e4d256a86..72cf68880 100644 --- a/packages/backend/src/models/entities/announcement-read.ts +++ b/packages/backend/src/models/entities/AnnouncementRead.ts @@ -1,7 +1,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.js'; -import { Announcement } from './announcement.js'; import { id } from '../id.js'; +import { User } from './User.js'; +import { Announcement } from './Announcement.js'; @Entity() @Index(['userId', 'announcementId'], { unique: true }) diff --git a/packages/backend/src/models/entities/antenna.ts b/packages/backend/src/models/entities/Antenna.ts similarity index 92% rename from packages/backend/src/models/entities/antenna.ts rename to packages/backend/src/models/entities/Antenna.ts index 6c8bb13e5..860fd9cf5 100644 --- a/packages/backend/src/models/entities/antenna.ts +++ b/packages/backend/src/models/entities/Antenna.ts @@ -1,8 +1,8 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.js'; import { id } from '../id.js'; -import { UserList } from './user-list.js'; -import { UserGroupJoining } from './user-group-joining.js'; +import { User } from './User.js'; +import { UserList } from './UserList.js'; +import { UserGroupJoining } from './UserGroupJoining.js'; @Entity() export class Antenna { diff --git a/packages/backend/src/models/entities/antenna-note.ts b/packages/backend/src/models/entities/AntennaNote.ts similarity index 90% rename from packages/backend/src/models/entities/antenna-note.ts rename to packages/backend/src/models/entities/AntennaNote.ts index fcca493fe..5524a8936 100644 --- a/packages/backend/src/models/entities/antenna-note.ts +++ b/packages/backend/src/models/entities/AntennaNote.ts @@ -1,7 +1,7 @@ import { Entity, Index, JoinColumn, Column, ManyToOne, PrimaryColumn } from 'typeorm'; -import { Note } from './note.js'; -import { Antenna } from './antenna.js'; import { id } from '../id.js'; +import { Note } from './Note.js'; +import { Antenna } from './Antenna.js'; @Entity() @Index(['noteId', 'antennaId'], { unique: true }) diff --git a/packages/backend/src/models/entities/app.ts b/packages/backend/src/models/entities/App.ts similarity index 97% rename from packages/backend/src/models/entities/app.ts rename to packages/backend/src/models/entities/App.ts index 46c11548a..3a1ea7732 100644 --- a/packages/backend/src/models/entities/app.ts +++ b/packages/backend/src/models/entities/App.ts @@ -1,6 +1,6 @@ import { Entity, PrimaryColumn, Column, Index, ManyToOne } from 'typeorm'; -import { User } from './user.js'; import { id } from '../id.js'; +import { User } from './User.js'; @Entity() export class App { diff --git a/packages/backend/src/models/entities/attestation-challenge.ts b/packages/backend/src/models/entities/AttestationChallenge.ts similarity index 96% rename from packages/backend/src/models/entities/attestation-challenge.ts rename to packages/backend/src/models/entities/AttestationChallenge.ts index c40df2329..479564265 100644 --- a/packages/backend/src/models/entities/attestation-challenge.ts +++ b/packages/backend/src/models/entities/AttestationChallenge.ts @@ -1,6 +1,6 @@ import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne, Index } from 'typeorm'; -import { User } from './user.js'; import { id } from '../id.js'; +import { User } from './User.js'; @Entity() export class AttestationChallenge { diff --git a/packages/backend/src/models/entities/auth-session.ts b/packages/backend/src/models/entities/AuthSession.ts similarity index 91% rename from packages/backend/src/models/entities/auth-session.ts rename to packages/backend/src/models/entities/AuthSession.ts index 295d1b486..6b2f50e8d 100644 --- a/packages/backend/src/models/entities/auth-session.ts +++ b/packages/backend/src/models/entities/AuthSession.ts @@ -1,7 +1,7 @@ import { Entity, PrimaryColumn, Index, Column, ManyToOne, JoinColumn } from 'typeorm'; -import { User } from './user.js'; -import { App } from './app.js'; import { id } from '../id.js'; +import { User } from './User.js'; +import { App } from './App.js'; @Entity() export class AuthSession { diff --git a/packages/backend/src/models/entities/blocking.ts b/packages/backend/src/models/entities/Blocking.ts similarity index 95% rename from packages/backend/src/models/entities/blocking.ts rename to packages/backend/src/models/entities/Blocking.ts index 4ac73a00b..9892ff308 100644 --- a/packages/backend/src/models/entities/blocking.ts +++ b/packages/backend/src/models/entities/Blocking.ts @@ -1,6 +1,6 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.js'; import { id } from '../id.js'; +import { User } from './User.js'; @Entity() @Index(['blockerId', 'blockeeId'], { unique: true }) diff --git a/packages/backend/src/models/entities/channel.ts b/packages/backend/src/models/entities/Channel.ts similarity index 94% rename from packages/backend/src/models/entities/channel.ts rename to packages/backend/src/models/entities/Channel.ts index abf6668bd..a6e32d54f 100644 --- a/packages/backend/src/models/entities/channel.ts +++ b/packages/backend/src/models/entities/Channel.ts @@ -1,7 +1,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.js'; import { id } from '../id.js'; -import { DriveFile } from './drive-file.js'; +import { User } from './User.js'; +import { DriveFile } from './DriveFile.js'; @Entity() export class Channel { diff --git a/packages/backend/src/models/entities/channel-following.ts b/packages/backend/src/models/entities/ChannelFollowing.ts similarity index 91% rename from packages/backend/src/models/entities/channel-following.ts rename to packages/backend/src/models/entities/ChannelFollowing.ts index 029dd6cf1..c65c38b67 100644 --- a/packages/backend/src/models/entities/channel-following.ts +++ b/packages/backend/src/models/entities/ChannelFollowing.ts @@ -1,7 +1,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.js'; import { id } from '../id.js'; -import { Channel } from './channel.js'; +import { User } from './User.js'; +import { Channel } from './Channel.js'; @Entity() @Index(['followerId', 'followeeId'], { unique: true }) diff --git a/packages/backend/src/models/entities/channel-note-pining.ts b/packages/backend/src/models/entities/ChannelNotePining.ts similarity index 90% rename from packages/backend/src/models/entities/channel-note-pining.ts rename to packages/backend/src/models/entities/ChannelNotePining.ts index 23be3b69d..ab5796626 100644 --- a/packages/backend/src/models/entities/channel-note-pining.ts +++ b/packages/backend/src/models/entities/ChannelNotePining.ts @@ -1,7 +1,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { Note } from './note.js'; -import { Channel } from './channel.js'; import { id } from '../id.js'; +import { Note } from './Note.js'; +import { Channel } from './Channel.js'; @Entity() @Index(['channelId', 'noteId'], { unique: true }) diff --git a/packages/backend/src/models/entities/clip.ts b/packages/backend/src/models/entities/Clip.ts similarity index 95% rename from packages/backend/src/models/entities/clip.ts rename to packages/backend/src/models/entities/Clip.ts index 1386684c3..57a310ac0 100644 --- a/packages/backend/src/models/entities/clip.ts +++ b/packages/backend/src/models/entities/Clip.ts @@ -1,6 +1,6 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.js'; import { id } from '../id.js'; +import { User } from './User.js'; @Entity() export class Clip { diff --git a/packages/backend/src/models/entities/clip-note.ts b/packages/backend/src/models/entities/ClipNote.ts similarity index 90% rename from packages/backend/src/models/entities/clip-note.ts rename to packages/backend/src/models/entities/ClipNote.ts index 6f3688550..bc9ef4b87 100644 --- a/packages/backend/src/models/entities/clip-note.ts +++ b/packages/backend/src/models/entities/ClipNote.ts @@ -1,7 +1,7 @@ import { Entity, Index, JoinColumn, Column, ManyToOne, PrimaryColumn } from 'typeorm'; -import { Note } from './note.js'; -import { Clip } from './clip.js'; import { id } from '../id.js'; +import { Note } from './Note.js'; +import { Clip } from './Clip.js'; @Entity() @Index(['noteId', 'clipId'], { unique: true }) diff --git a/packages/backend/src/models/entities/drive-file.ts b/packages/backend/src/models/entities/DriveFile.ts similarity index 97% rename from packages/backend/src/models/entities/drive-file.ts rename to packages/backend/src/models/entities/DriveFile.ts index d410b1d42..7b9670fb9 100644 --- a/packages/backend/src/models/entities/drive-file.ts +++ b/packages/backend/src/models/entities/DriveFile.ts @@ -1,7 +1,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; import { id } from '../id.js'; -import { User } from './user.js'; -import { DriveFolder } from './drive-folder.js'; +import { User } from './User.js'; +import { DriveFolder } from './DriveFolder.js'; @Entity() @Index(['userId', 'folderId', 'id']) diff --git a/packages/backend/src/models/entities/drive-folder.ts b/packages/backend/src/models/entities/DriveFolder.ts similarity index 96% rename from packages/backend/src/models/entities/drive-folder.ts rename to packages/backend/src/models/entities/DriveFolder.ts index d4022c6eb..2a73a0875 100644 --- a/packages/backend/src/models/entities/drive-folder.ts +++ b/packages/backend/src/models/entities/DriveFolder.ts @@ -1,6 +1,6 @@ import { JoinColumn, ManyToOne, Entity, PrimaryColumn, Index, Column } from 'typeorm'; -import { User } from './user.js'; import { id } from '../id.js'; +import { User } from './User.js'; @Entity() export class DriveFolder { diff --git a/packages/backend/src/models/entities/emoji.ts b/packages/backend/src/models/entities/Emoji.ts similarity index 100% rename from packages/backend/src/models/entities/emoji.ts rename to packages/backend/src/models/entities/Emoji.ts diff --git a/packages/backend/src/models/entities/follow-request.ts b/packages/backend/src/models/entities/FollowRequest.ts similarity index 98% rename from packages/backend/src/models/entities/follow-request.ts rename to packages/backend/src/models/entities/FollowRequest.ts index 89946f6d3..0988e7e50 100644 --- a/packages/backend/src/models/entities/follow-request.ts +++ b/packages/backend/src/models/entities/FollowRequest.ts @@ -1,6 +1,6 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.js'; import { id } from '../id.js'; +import { User } from './User.js'; @Entity() @Index(['followerId', 'followeeId'], { unique: true }) diff --git a/packages/backend/src/models/entities/following.ts b/packages/backend/src/models/entities/Following.ts similarity index 97% rename from packages/backend/src/models/entities/following.ts rename to packages/backend/src/models/entities/Following.ts index b283ca7e8..112afd7e6 100644 --- a/packages/backend/src/models/entities/following.ts +++ b/packages/backend/src/models/entities/Following.ts @@ -1,6 +1,6 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.js'; import { id } from '../id.js'; +import { User } from './User.js'; @Entity() @Index(['followerId', 'followeeId'], { unique: true }) diff --git a/packages/backend/src/models/entities/gallery-like.ts b/packages/backend/src/models/entities/GalleryLike.ts similarity index 88% rename from packages/backend/src/models/entities/gallery-like.ts rename to packages/backend/src/models/entities/GalleryLike.ts index 4ce166d19..cc54b528e 100644 --- a/packages/backend/src/models/entities/gallery-like.ts +++ b/packages/backend/src/models/entities/GalleryLike.ts @@ -1,7 +1,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.js'; import { id } from '../id.js'; -import { GalleryPost } from './gallery-post.js'; +import { User } from './User.js'; +import { GalleryPost } from './GalleryPost.js'; @Entity() @Index(['userId', 'postId'], { unique: true }) diff --git a/packages/backend/src/models/entities/gallery-post.ts b/packages/backend/src/models/entities/GalleryPost.ts similarity index 94% rename from packages/backend/src/models/entities/gallery-post.ts rename to packages/backend/src/models/entities/GalleryPost.ts index 774cb946e..36e879afa 100644 --- a/packages/backend/src/models/entities/gallery-post.ts +++ b/packages/backend/src/models/entities/GalleryPost.ts @@ -1,7 +1,7 @@ import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; -import { User } from './user.js'; import { id } from '../id.js'; -import { DriveFile } from './drive-file.js'; +import { User } from './User.js'; +import type { DriveFile } from './DriveFile.js'; @Entity() export class GalleryPost { diff --git a/packages/backend/src/models/entities/hashtag.ts b/packages/backend/src/models/entities/Hashtag.ts similarity index 97% rename from packages/backend/src/models/entities/hashtag.ts rename to packages/backend/src/models/entities/Hashtag.ts index 6bd991f62..2d6bfaa04 100644 --- a/packages/backend/src/models/entities/hashtag.ts +++ b/packages/backend/src/models/entities/Hashtag.ts @@ -1,6 +1,6 @@ import { Entity, PrimaryColumn, Index, Column } from 'typeorm'; -import { User } from './user.js'; import { id } from '../id.js'; +import type { User } from './User.js'; @Entity() export class Hashtag { diff --git a/packages/backend/src/models/entities/instance.ts b/packages/backend/src/models/entities/Instance.ts similarity index 100% rename from packages/backend/src/models/entities/instance.ts rename to packages/backend/src/models/entities/Instance.ts diff --git a/packages/backend/src/models/entities/messaging-message.ts b/packages/backend/src/models/entities/MessagingMessage.ts similarity index 92% rename from packages/backend/src/models/entities/messaging-message.ts rename to packages/backend/src/models/entities/MessagingMessage.ts index 099fb7aa0..69fc9815d 100644 --- a/packages/backend/src/models/entities/messaging-message.ts +++ b/packages/backend/src/models/entities/MessagingMessage.ts @@ -1,8 +1,8 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.js'; -import { DriveFile } from './drive-file.js'; import { id } from '../id.js'; -import { UserGroup } from './user-group.js'; +import { User } from './User.js'; +import { DriveFile } from './DriveFile.js'; +import { UserGroup } from './UserGroup.js'; @Entity() export class MessagingMessage { diff --git a/packages/backend/src/models/entities/meta.ts b/packages/backend/src/models/entities/Meta.ts similarity index 99% rename from packages/backend/src/models/entities/meta.ts rename to packages/backend/src/models/entities/Meta.ts index d33ff2519..f528b7ac0 100644 --- a/packages/backend/src/models/entities/meta.ts +++ b/packages/backend/src/models/entities/Meta.ts @@ -1,7 +1,7 @@ import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm'; import { id } from '../id.js'; -import { User } from './user.js'; -import { Clip } from './clip.js'; +import { User } from './User.js'; +import type { Clip } from './Clip.js'; @Entity() export class Meta { diff --git a/packages/backend/src/models/entities/moderation-log.ts b/packages/backend/src/models/entities/ModerationLog.ts similarity index 94% rename from packages/backend/src/models/entities/moderation-log.ts rename to packages/backend/src/models/entities/ModerationLog.ts index c99e55078..ab6a226cf 100644 --- a/packages/backend/src/models/entities/moderation-log.ts +++ b/packages/backend/src/models/entities/ModerationLog.ts @@ -1,6 +1,6 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.js'; import { id } from '../id.js'; +import { User } from './User.js'; @Entity() export class ModerationLog { diff --git a/packages/backend/src/models/entities/muted-note.ts b/packages/backend/src/models/entities/MutedNote.ts similarity index 92% rename from packages/backend/src/models/entities/muted-note.ts rename to packages/backend/src/models/entities/MutedNote.ts index 96a4fa8e3..78347d891 100644 --- a/packages/backend/src/models/entities/muted-note.ts +++ b/packages/backend/src/models/entities/MutedNote.ts @@ -1,8 +1,8 @@ import { Entity, Index, JoinColumn, Column, ManyToOne, PrimaryColumn } from 'typeorm'; -import { Note } from './note.js'; -import { User } from './user.js'; import { id } from '../id.js'; import { mutedNoteReasons } from '../../types.js'; +import { Note } from './Note.js'; +import { User } from './User.js'; @Entity() @Index(['noteId', 'userId'], { unique: true }) diff --git a/packages/backend/src/models/entities/muting.ts b/packages/backend/src/models/entities/Muting.ts similarity index 96% rename from packages/backend/src/models/entities/muting.ts rename to packages/backend/src/models/entities/Muting.ts index 8f9e69063..bf5498b96 100644 --- a/packages/backend/src/models/entities/muting.ts +++ b/packages/backend/src/models/entities/Muting.ts @@ -1,6 +1,6 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.js'; import { id } from '../id.js'; +import { User } from './User.js'; @Entity() @Index(['muterId', 'muteeId'], { unique: true }) diff --git a/packages/backend/src/models/entities/note.ts b/packages/backend/src/models/entities/Note.ts similarity index 97% rename from packages/backend/src/models/entities/note.ts rename to packages/backend/src/models/entities/Note.ts index 0ffeb85f6..f1a94bd9c 100644 --- a/packages/backend/src/models/entities/note.ts +++ b/packages/backend/src/models/entities/Note.ts @@ -1,9 +1,9 @@ import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; -import { User } from './user.js'; -import { DriveFile } from './drive-file.js'; import { id } from '../id.js'; import { noteVisibilities } from '../../types.js'; -import { Channel } from './channel.js'; +import { User } from './User.js'; +import { Channel } from './Channel.js'; +import type { DriveFile } from './DriveFile.js'; @Entity() @Index('IDX_NOTE_TAGS', { synchronize: false }) diff --git a/packages/backend/src/models/entities/note-favorite.ts b/packages/backend/src/models/entities/NoteFavorite.ts similarity index 90% rename from packages/backend/src/models/entities/note-favorite.ts rename to packages/backend/src/models/entities/NoteFavorite.ts index fe065b77a..80c97cb53 100644 --- a/packages/backend/src/models/entities/note-favorite.ts +++ b/packages/backend/src/models/entities/NoteFavorite.ts @@ -1,7 +1,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { Note } from './note.js'; -import { User } from './user.js'; import { id } from '../id.js'; +import { Note } from './Note.js'; +import { User } from './User.js'; @Entity() @Index(['userId', 'noteId'], { unique: true }) diff --git a/packages/backend/src/models/entities/note-reaction.ts b/packages/backend/src/models/entities/NoteReaction.ts similarity index 93% rename from packages/backend/src/models/entities/note-reaction.ts rename to packages/backend/src/models/entities/NoteReaction.ts index d7bc60989..c3c381af5 100644 --- a/packages/backend/src/models/entities/note-reaction.ts +++ b/packages/backend/src/models/entities/NoteReaction.ts @@ -1,7 +1,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.js'; -import { Note } from './note.js'; import { id } from '../id.js'; +import { User } from './User.js'; +import { Note } from './Note.js'; @Entity() @Index(['userId', 'noteId'], { unique: true }) diff --git a/packages/backend/src/models/entities/note-thread-muting.ts b/packages/backend/src/models/entities/NoteThreadMuting.ts similarity index 89% rename from packages/backend/src/models/entities/note-thread-muting.ts rename to packages/backend/src/models/entities/NoteThreadMuting.ts index 8c5f7bbab..a23176b99 100644 --- a/packages/backend/src/models/entities/note-thread-muting.ts +++ b/packages/backend/src/models/entities/NoteThreadMuting.ts @@ -1,7 +1,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.js'; -import { Note } from './note.js'; import { id } from '../id.js'; +import { User } from './User.js'; +import { Note } from './Note.js'; @Entity() @Index(['userId', 'threadId'], { unique: true }) diff --git a/packages/backend/src/models/entities/note-unread.ts b/packages/backend/src/models/entities/NoteUnread.ts similarity index 90% rename from packages/backend/src/models/entities/note-unread.ts rename to packages/backend/src/models/entities/NoteUnread.ts index a7acf254d..af91234d0 100644 --- a/packages/backend/src/models/entities/note-unread.ts +++ b/packages/backend/src/models/entities/NoteUnread.ts @@ -1,8 +1,8 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.js'; -import { Note } from './note.js'; import { id } from '../id.js'; -import { Channel } from './channel.js'; +import { User } from './User.js'; +import { Note } from './Note.js'; +import type { Channel } from './Channel.js'; @Entity() @Index(['userId', 'noteId'], { unique: true }) diff --git a/packages/backend/src/models/entities/notification.ts b/packages/backend/src/models/entities/Notification.ts similarity index 94% rename from packages/backend/src/models/entities/notification.ts rename to packages/backend/src/models/entities/Notification.ts index db3dba363..53a7dda43 100644 --- a/packages/backend/src/models/entities/notification.ts +++ b/packages/backend/src/models/entities/Notification.ts @@ -1,11 +1,11 @@ import { Entity, Index, JoinColumn, ManyToOne, Column, PrimaryColumn } from 'typeorm'; -import { User } from './user.js'; -import { id } from '../id.js'; -import { Note } from './note.js'; -import { FollowRequest } from './follow-request.js'; -import { UserGroupInvitation } from './user-group-invitation.js'; -import { AccessToken } from './access-token.js'; import { notificationTypes } from '@/types.js'; +import { id } from '../id.js'; +import { User } from './User.js'; +import { Note } from './Note.js'; +import { FollowRequest } from './FollowRequest.js'; +import { UserGroupInvitation } from './UserGroupInvitation.js'; +import { AccessToken } from './AccessToken.js'; @Entity() export class Notification { diff --git a/packages/backend/src/models/entities/page.ts b/packages/backend/src/models/entities/Page.ts similarity index 96% rename from packages/backend/src/models/entities/page.ts rename to packages/backend/src/models/entities/Page.ts index baad3a36f..6078bc1bc 100644 --- a/packages/backend/src/models/entities/page.ts +++ b/packages/backend/src/models/entities/Page.ts @@ -1,7 +1,7 @@ import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; -import { User } from './user.js'; import { id } from '../id.js'; -import { DriveFile } from './drive-file.js'; +import { User } from './User.js'; +import { DriveFile } from './DriveFile.js'; @Entity() @Index(['userId', 'name'], { unique: true }) diff --git a/packages/backend/src/models/entities/page-like.ts b/packages/backend/src/models/entities/PageLike.ts similarity index 89% rename from packages/backend/src/models/entities/page-like.ts rename to packages/backend/src/models/entities/PageLike.ts index 17f4ebf52..f8c5943a3 100644 --- a/packages/backend/src/models/entities/page-like.ts +++ b/packages/backend/src/models/entities/PageLike.ts @@ -1,7 +1,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.js'; import { id } from '../id.js'; -import { Page } from './page.js'; +import { User } from './User.js'; +import { Page } from './Page.js'; @Entity() @Index(['userId', 'pageId'], { unique: true }) diff --git a/packages/backend/src/models/entities/password-reset-request.ts b/packages/backend/src/models/entities/PasswordResetRequest.ts similarity index 93% rename from packages/backend/src/models/entities/password-reset-request.ts rename to packages/backend/src/models/entities/PasswordResetRequest.ts index 05e62cc5a..939fcc460 100644 --- a/packages/backend/src/models/entities/password-reset-request.ts +++ b/packages/backend/src/models/entities/PasswordResetRequest.ts @@ -1,6 +1,6 @@ import { PrimaryColumn, Entity, Index, Column, ManyToOne, JoinColumn } from 'typeorm'; import { id } from '../id.js'; -import { User } from './user.js'; +import { User } from './User.js'; @Entity() export class PasswordResetRequest { diff --git a/packages/backend/src/models/entities/poll.ts b/packages/backend/src/models/entities/Poll.ts similarity index 94% rename from packages/backend/src/models/entities/poll.ts rename to packages/backend/src/models/entities/Poll.ts index 83d0873cc..6641b435e 100644 --- a/packages/backend/src/models/entities/poll.ts +++ b/packages/backend/src/models/entities/Poll.ts @@ -1,8 +1,8 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, OneToOne } from 'typeorm'; import { id } from '../id.js'; -import { Note } from './note.js'; -import { User } from './user.js'; import { noteVisibilities } from '../../types.js'; +import { Note } from './Note.js'; +import type { User } from './User.js'; @Entity() export class Poll { diff --git a/packages/backend/src/models/entities/poll-vote.ts b/packages/backend/src/models/entities/PollVote.ts similarity index 91% rename from packages/backend/src/models/entities/poll-vote.ts rename to packages/backend/src/models/entities/PollVote.ts index fca1cd009..d447a7be8 100644 --- a/packages/backend/src/models/entities/poll-vote.ts +++ b/packages/backend/src/models/entities/PollVote.ts @@ -1,7 +1,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.js'; -import { Note } from './note.js'; import { id } from '../id.js'; +import { User } from './User.js'; +import { Note } from './Note.js'; @Entity() @Index(['userId', 'noteId', 'choice'], { unique: true }) diff --git a/packages/backend/src/models/entities/promo-note.ts b/packages/backend/src/models/entities/PromoNote.ts similarity index 87% rename from packages/backend/src/models/entities/promo-note.ts rename to packages/backend/src/models/entities/PromoNote.ts index d110b81e9..958008338 100644 --- a/packages/backend/src/models/entities/promo-note.ts +++ b/packages/backend/src/models/entities/PromoNote.ts @@ -1,7 +1,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, OneToOne } from 'typeorm'; -import { Note } from './note.js'; -import { User } from './user.js'; import { id } from '../id.js'; +import { Note } from './Note.js'; +import type { User } from './User.js'; @Entity() export class PromoNote { diff --git a/packages/backend/src/models/entities/promo-read.ts b/packages/backend/src/models/entities/PromoRead.ts similarity index 90% rename from packages/backend/src/models/entities/promo-read.ts rename to packages/backend/src/models/entities/PromoRead.ts index a63b79cd1..27f5d0dc1 100644 --- a/packages/backend/src/models/entities/promo-read.ts +++ b/packages/backend/src/models/entities/PromoRead.ts @@ -1,7 +1,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { Note } from './note.js'; -import { User } from './user.js'; import { id } from '../id.js'; +import { Note } from './Note.js'; +import { User } from './User.js'; @Entity() @Index(['userId', 'noteId'], { unique: true }) diff --git a/packages/backend/src/models/entities/registration-tickets.ts b/packages/backend/src/models/entities/RegistrationTickets.ts similarity index 100% rename from packages/backend/src/models/entities/registration-tickets.ts rename to packages/backend/src/models/entities/RegistrationTickets.ts diff --git a/packages/backend/src/models/entities/registry-item.ts b/packages/backend/src/models/entities/RegistryItem.ts similarity index 97% rename from packages/backend/src/models/entities/registry-item.ts rename to packages/backend/src/models/entities/RegistryItem.ts index 283796df9..670a236ea 100644 --- a/packages/backend/src/models/entities/registry-item.ts +++ b/packages/backend/src/models/entities/RegistryItem.ts @@ -1,6 +1,6 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.js'; import { id } from '../id.js'; +import { User } from './User.js'; // TODO: 同じdomain、同じscope、同じkeyのレコードは二つ以上存在しないように制約付けたい @Entity() diff --git a/packages/backend/src/models/entities/relay.ts b/packages/backend/src/models/entities/Relay.ts similarity index 100% rename from packages/backend/src/models/entities/relay.ts rename to packages/backend/src/models/entities/Relay.ts diff --git a/packages/backend/src/models/entities/signin.ts b/packages/backend/src/models/entities/Signin.ts similarity index 94% rename from packages/backend/src/models/entities/signin.ts rename to packages/backend/src/models/entities/Signin.ts index ba81f45e4..380bf028a 100644 --- a/packages/backend/src/models/entities/signin.ts +++ b/packages/backend/src/models/entities/Signin.ts @@ -1,6 +1,6 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.js'; import { id } from '../id.js'; +import { User } from './User.js'; @Entity() export class Signin { diff --git a/packages/backend/src/models/entities/sw-subscription.ts b/packages/backend/src/models/entities/SwSubscription.ts similarity index 94% rename from packages/backend/src/models/entities/sw-subscription.ts rename to packages/backend/src/models/entities/SwSubscription.ts index 59144d348..51b9786e9 100644 --- a/packages/backend/src/models/entities/sw-subscription.ts +++ b/packages/backend/src/models/entities/SwSubscription.ts @@ -1,6 +1,6 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.js'; import { id } from '../id.js'; +import { User } from './User.js'; @Entity() export class SwSubscription { diff --git a/packages/backend/src/models/entities/used-username.ts b/packages/backend/src/models/entities/UsedUsername.ts similarity index 100% rename from packages/backend/src/models/entities/used-username.ts rename to packages/backend/src/models/entities/UsedUsername.ts diff --git a/packages/backend/src/models/entities/user.ts b/packages/backend/src/models/entities/User.ts similarity index 89% rename from packages/backend/src/models/entities/user.ts rename to packages/backend/src/models/entities/User.ts index bc9446be4..73736f015 100644 --- a/packages/backend/src/models/entities/user.ts +++ b/packages/backend/src/models/entities/User.ts @@ -1,6 +1,6 @@ import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; import { id } from '../id.js'; -import { DriveFile } from './drive-file.js'; +import { DriveFile } from './DriveFile.js'; @Entity() @Index(['usernameLower', 'host'], { unique: true }) @@ -246,3 +246,10 @@ export type CacheableLocalUser = ILocalUser; export type CacheableRemoteUser = IRemoteUser; export type CacheableUser = CacheableLocalUser | CacheableRemoteUser; + +export const localUsernameSchema = { type: 'string', pattern: /^\w{1,20}$/.toString().slice(1, -1) } as const; +export const passwordSchema = { type: 'string', minLength: 1 } as const; +export const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as const; +export const descriptionSchema = { type: 'string', minLength: 1, maxLength: 500 } as const; +export const locationSchema = { type: 'string', minLength: 1, maxLength: 50 } as const; +export const birthdaySchema = { type: 'string', pattern: /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.toString().slice(1, -1) } as const; diff --git a/packages/backend/src/models/entities/user-group.ts b/packages/backend/src/models/entities/UserGroup.ts similarity index 95% rename from packages/backend/src/models/entities/user-group.ts rename to packages/backend/src/models/entities/UserGroup.ts index 8d5de1d92..328a1883c 100644 --- a/packages/backend/src/models/entities/user-group.ts +++ b/packages/backend/src/models/entities/UserGroup.ts @@ -1,6 +1,6 @@ import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; -import { User } from './user.js'; import { id } from '../id.js'; +import { User } from './User.js'; @Entity() export class UserGroup { diff --git a/packages/backend/src/models/entities/user-group-invitation.ts b/packages/backend/src/models/entities/UserGroupInvitation.ts similarity index 90% rename from packages/backend/src/models/entities/user-group-invitation.ts rename to packages/backend/src/models/entities/UserGroupInvitation.ts index 10f357049..e4aa3ccae 100644 --- a/packages/backend/src/models/entities/user-group-invitation.ts +++ b/packages/backend/src/models/entities/UserGroupInvitation.ts @@ -1,7 +1,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.js'; -import { UserGroup } from './user-group.js'; import { id } from '../id.js'; +import { User } from './User.js'; +import { UserGroup } from './UserGroup.js'; @Entity() @Index(['userId', 'userGroupId'], { unique: true }) diff --git a/packages/backend/src/models/entities/user-group-joining.ts b/packages/backend/src/models/entities/UserGroupJoining.ts similarity index 90% rename from packages/backend/src/models/entities/user-group-joining.ts rename to packages/backend/src/models/entities/UserGroupJoining.ts index 62a814218..fae724152 100644 --- a/packages/backend/src/models/entities/user-group-joining.ts +++ b/packages/backend/src/models/entities/UserGroupJoining.ts @@ -1,7 +1,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.js'; -import { UserGroup } from './user-group.js'; import { id } from '../id.js'; +import { User } from './User.js'; +import { UserGroup } from './UserGroup.js'; @Entity() @Index(['userId', 'userGroupId'], { unique: true }) diff --git a/packages/backend/src/models/entities/user-ip.ts b/packages/backend/src/models/entities/UserIp.ts similarity index 86% rename from packages/backend/src/models/entities/user-ip.ts rename to packages/backend/src/models/entities/UserIp.ts index 543e9e728..e9afd40d4 100644 --- a/packages/backend/src/models/entities/user-ip.ts +++ b/packages/backend/src/models/entities/UserIp.ts @@ -1,7 +1,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; import { id } from '../id.js'; -import { Note } from './note.js'; -import { User } from './user.js'; +import { Note } from './Note.js'; +import type { User } from './User.js'; @Entity() @Index(['userId', 'ip'], { unique: true }) diff --git a/packages/backend/src/models/entities/user-keypair.ts b/packages/backend/src/models/entities/UserKeypair.ts similarity index 94% rename from packages/backend/src/models/entities/user-keypair.ts rename to packages/backend/src/models/entities/UserKeypair.ts index 85fa06297..3cd02d3c4 100644 --- a/packages/backend/src/models/entities/user-keypair.ts +++ b/packages/backend/src/models/entities/UserKeypair.ts @@ -1,6 +1,6 @@ import { PrimaryColumn, Entity, JoinColumn, Column, OneToOne } from 'typeorm'; -import { User } from './user.js'; import { id } from '../id.js'; +import { User } from './User.js'; @Entity() export class UserKeypair { diff --git a/packages/backend/src/models/entities/user-list.ts b/packages/backend/src/models/entities/UserList.ts similarity index 94% rename from packages/backend/src/models/entities/user-list.ts rename to packages/backend/src/models/entities/UserList.ts index ca69394e9..b8a4b54d4 100644 --- a/packages/backend/src/models/entities/user-list.ts +++ b/packages/backend/src/models/entities/UserList.ts @@ -1,6 +1,6 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.js'; import { id } from '../id.js'; +import { User } from './User.js'; @Entity() export class UserList { diff --git a/packages/backend/src/models/entities/user-list-joining.ts b/packages/backend/src/models/entities/UserListJoining.ts similarity index 91% rename from packages/backend/src/models/entities/user-list-joining.ts rename to packages/backend/src/models/entities/UserListJoining.ts index 12f28c414..a40793a3e 100644 --- a/packages/backend/src/models/entities/user-list-joining.ts +++ b/packages/backend/src/models/entities/UserListJoining.ts @@ -1,7 +1,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.js'; -import { UserList } from './user-list.js'; import { id } from '../id.js'; +import { User } from './User.js'; +import { UserList } from './UserList.js'; @Entity() @Index(['userId', 'userListId'], { unique: true }) diff --git a/packages/backend/src/models/entities/user-note-pining.ts b/packages/backend/src/models/entities/UserNotePining.ts similarity index 90% rename from packages/backend/src/models/entities/user-note-pining.ts rename to packages/backend/src/models/entities/UserNotePining.ts index c91ab7fdd..fee95d4f7 100644 --- a/packages/backend/src/models/entities/user-note-pining.ts +++ b/packages/backend/src/models/entities/UserNotePining.ts @@ -1,7 +1,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { Note } from './note.js'; -import { User } from './user.js'; import { id } from '../id.js'; +import { Note } from './Note.js'; +import { User } from './User.js'; @Entity() @Index(['userId', 'noteId'], { unique: true }) diff --git a/packages/backend/src/models/entities/user-pending.ts b/packages/backend/src/models/entities/UserPending.ts similarity index 100% rename from packages/backend/src/models/entities/user-pending.ts rename to packages/backend/src/models/entities/UserPending.ts diff --git a/packages/backend/src/models/entities/user-profile.ts b/packages/backend/src/models/entities/UserProfile.ts similarity index 98% rename from packages/backend/src/models/entities/user-profile.ts rename to packages/backend/src/models/entities/UserProfile.ts index 3654b0a99..c561da87c 100644 --- a/packages/backend/src/models/entities/user-profile.ts +++ b/packages/backend/src/models/entities/UserProfile.ts @@ -1,8 +1,8 @@ import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; import { ffVisibility, notificationTypes } from '@/types.js'; import { id } from '../id.js'; -import { User } from './user.js'; -import { Page } from './page.js'; +import { User } from './User.js'; +import { Page } from './Page.js'; // TODO: このテーブルで管理している情報すべてレジストリで管理するようにしても良いかも // ただ、「emailVerified が true なユーザーを find する」のようなクエリは書けなくなるからウーン diff --git a/packages/backend/src/models/entities/user-publickey.ts b/packages/backend/src/models/entities/UserPublickey.ts similarity index 94% rename from packages/backend/src/models/entities/user-publickey.ts rename to packages/backend/src/models/entities/UserPublickey.ts index 31ed60de8..7b505e5b4 100644 --- a/packages/backend/src/models/entities/user-publickey.ts +++ b/packages/backend/src/models/entities/UserPublickey.ts @@ -1,6 +1,6 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, OneToOne } from 'typeorm'; -import { User } from './user.js'; import { id } from '../id.js'; +import { User } from './User.js'; @Entity() export class UserPublickey { diff --git a/packages/backend/src/models/entities/user-security-key.ts b/packages/backend/src/models/entities/UserSecurityKey.ts similarity index 96% rename from packages/backend/src/models/entities/user-security-key.ts rename to packages/backend/src/models/entities/UserSecurityKey.ts index c4f2a852e..947692a32 100644 --- a/packages/backend/src/models/entities/user-security-key.ts +++ b/packages/backend/src/models/entities/UserSecurityKey.ts @@ -1,6 +1,6 @@ import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne, Index } from 'typeorm'; -import { User } from './user.js'; import { id } from '../id.js'; +import { User } from './User.js'; @Entity() export class UserSecurityKey { diff --git a/packages/backend/src/models/entities/webhook.ts b/packages/backend/src/models/entities/Webhook.ts similarity index 97% rename from packages/backend/src/models/entities/webhook.ts rename to packages/backend/src/models/entities/Webhook.ts index 56b411f87..eabb604de 100644 --- a/packages/backend/src/models/entities/webhook.ts +++ b/packages/backend/src/models/entities/Webhook.ts @@ -1,6 +1,6 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.js'; import { id } from '../id.js'; +import { User } from './User.js'; export const webhookEventTypes = ['mention', 'unfollow', 'follow', 'followed', 'note', 'reply', 'renote', 'reaction'] as const; diff --git a/packages/backend/src/models/entities/note-watching.ts b/packages/backend/src/models/entities/note-watching.ts deleted file mode 100644 index ed82e7dfe..000000000 --- a/packages/backend/src/models/entities/note-watching.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.js'; -import { Note } from './note.js'; -import { id } from '../id.js'; - -@Entity() -@Index(['userId', 'noteId'], { unique: true }) -export class NoteWatching { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column('timestamp with time zone', { - comment: 'The created date of the NoteWatching.', - }) - public createdAt: Date; - - @Index() - @Column({ - ...id(), - comment: 'The watcher ID.', - }) - public userId: User['id']; - - @ManyToOne(type => User, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: User | null; - - @Index() - @Column({ - ...id(), - comment: 'The target Note ID.', - }) - public noteId: Note['id']; - - @ManyToOne(type => Note, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public note: Note | null; - - //#region Denormalized fields - @Index() - @Column({ - ...id(), - comment: '[Denormalized]', - }) - public noteUserId: Note['userId']; - //#endregion -} diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index 3f7326931..559ed8c45 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -1,133 +1,194 @@ -import { } from 'typeorm'; -import { db } from '@/db/postgre.js'; +import { AbuseUserReport } from '@/models/entities/AbuseUserReport.js'; +import { AccessToken } from '@/models/entities/AccessToken.js'; +import { Ad } from '@/models/entities/Ad.js'; +import { Announcement } from '@/models/entities/Announcement.js'; +import { AnnouncementRead } from '@/models/entities/AnnouncementRead.js'; +import { Antenna } from '@/models/entities/Antenna.js'; +import { AntennaNote } from '@/models/entities/AntennaNote.js'; +import { App } from '@/models/entities/App.js'; +import { AttestationChallenge } from '@/models/entities/AttestationChallenge.js'; +import { AuthSession } from '@/models/entities/AuthSession.js'; +import { Blocking } from '@/models/entities/Blocking.js'; +import { ChannelFollowing } from '@/models/entities/ChannelFollowing.js'; +import { ChannelNotePining } from '@/models/entities/ChannelNotePining.js'; +import { Clip } from '@/models/entities/Clip.js'; +import { ClipNote } from '@/models/entities/ClipNote.js'; +import { DriveFile } from '@/models/entities/DriveFile.js'; +import { DriveFolder } from '@/models/entities/DriveFolder.js'; +import { Emoji } from '@/models/entities/Emoji.js'; +import { Following } from '@/models/entities/Following.js'; +import { FollowRequest } from '@/models/entities/FollowRequest.js'; +import { GalleryLike } from '@/models/entities/GalleryLike.js'; +import { GalleryPost } from '@/models/entities/GalleryPost.js'; +import { Hashtag } from '@/models/entities/Hashtag.js'; +import { Instance } from '@/models/entities/Instance.js'; +import { MessagingMessage } from '@/models/entities/MessagingMessage.js'; +import { Meta } from '@/models/entities/Meta.js'; +import { ModerationLog } from '@/models/entities/ModerationLog.js'; +import { MutedNote } from '@/models/entities/MutedNote.js'; +import { Muting } from '@/models/entities/Muting.js'; +import { Note } from '@/models/entities/Note.js'; +import { NoteFavorite } from '@/models/entities/NoteFavorite.js'; +import { NoteReaction } from '@/models/entities/NoteReaction.js'; +import { NoteThreadMuting } from '@/models/entities/NoteThreadMuting.js'; +import { NoteUnread } from '@/models/entities/NoteUnread.js'; +import { Notification } from '@/models/entities/Notification.js'; +import { Page } from '@/models/entities/Page.js'; +import { PageLike } from '@/models/entities/PageLike.js'; +import { PasswordResetRequest } from '@/models/entities/PasswordResetRequest.js'; +import { Poll } from '@/models/entities/Poll.js'; +import { PollVote } from '@/models/entities/PollVote.js'; +import { PromoNote } from '@/models/entities/PromoNote.js'; +import { PromoRead } from '@/models/entities/PromoRead.js'; +import { RegistrationTicket } from '@/models/entities/RegistrationTickets.js'; +import { RegistryItem } from '@/models/entities/RegistryItem.js'; +import { Relay } from '@/models/entities/Relay.js'; +import { Signin } from '@/models/entities/Signin.js'; +import { SwSubscription } from '@/models/entities/SwSubscription.js'; +import { UsedUsername } from '@/models/entities/UsedUsername.js'; +import { User } from '@/models/entities/User.js'; +import { UserGroup } from '@/models/entities/UserGroup.js'; +import { UserGroupInvitation } from '@/models/entities/UserGroupInvitation.js'; +import { UserGroupJoining } from '@/models/entities/UserGroupJoining.js'; +import { UserIp } from '@/models/entities/UserIp.js'; +import { UserKeypair } from '@/models/entities/UserKeypair.js'; +import { UserList } from '@/models/entities/UserList.js'; +import { UserListJoining } from '@/models/entities/UserListJoining.js'; +import { UserNotePining } from '@/models/entities/UserNotePining.js'; +import { UserPending } from '@/models/entities/UserPending.js'; +import { UserProfile } from '@/models/entities/UserProfile.js'; +import { UserPublickey } from '@/models/entities/UserPublickey.js'; +import { UserSecurityKey } from '@/models/entities/UserSecurityKey.js'; +import { Webhook } from '@/models/entities/Webhook.js'; +import { Channel } from '@/models/entities/Channel.js'; +import type { Repository } from 'typeorm'; -import { Announcement } from './entities/announcement.js'; -import { AnnouncementRead } from './entities/announcement-read.js'; -import { Instance } from './entities/instance.js'; -import { Poll } from './entities/poll.js'; -import { PollVote } from './entities/poll-vote.js'; -import { Meta } from './entities/meta.js'; -import { SwSubscription } from './entities/sw-subscription.js'; -import { NoteWatching } from './entities/note-watching.js'; -import { NoteThreadMuting } from './entities/note-thread-muting.js'; -import { NoteUnread } from './entities/note-unread.js'; -import { RegistrationTicket } from './entities/registration-tickets.js'; -import { UserRepository } from './repositories/user.js'; -import { NoteRepository } from './repositories/note.js'; -import { DriveFileRepository } from './repositories/drive-file.js'; -import { DriveFolderRepository } from './repositories/drive-folder.js'; -import { AccessToken } from './entities/access-token.js'; -import { UserNotePining } from './entities/user-note-pining.js'; -import { SigninRepository } from './repositories/signin.js'; -import { MessagingMessageRepository } from './repositories/messaging-message.js'; -import { UserListRepository } from './repositories/user-list.js'; -import { UserListJoining } from './entities/user-list-joining.js'; -import { UserGroupRepository } from './repositories/user-group.js'; -import { UserGroupJoining } from './entities/user-group-joining.js'; -import { UserGroupInvitationRepository } from './repositories/user-group-invitation.js'; -import { FollowRequestRepository } from './repositories/follow-request.js'; -import { MutingRepository } from './repositories/muting.js'; -import { BlockingRepository } from './repositories/blocking.js'; -import { NoteReactionRepository } from './repositories/note-reaction.js'; -import { NotificationRepository } from './repositories/notification.js'; -import { NoteFavoriteRepository } from './repositories/note-favorite.js'; -import { UserPublickey } from './entities/user-publickey.js'; -import { UserKeypair } from './entities/user-keypair.js'; -import { AppRepository } from './repositories/app.js'; -import { FollowingRepository } from './repositories/following.js'; -import { AbuseUserReportRepository } from './repositories/abuse-user-report.js'; -import { AuthSessionRepository } from './repositories/auth-session.js'; -import { UserProfile } from './entities/user-profile.js'; -import { AttestationChallenge } from './entities/attestation-challenge.js'; -import { UserSecurityKey } from './entities/user-security-key.js'; -import { HashtagRepository } from './repositories/hashtag.js'; -import { PageRepository } from './repositories/page.js'; -import { PageLikeRepository } from './repositories/page-like.js'; -import { GalleryPostRepository } from './repositories/gallery-post.js'; -import { GalleryLikeRepository } from './repositories/gallery-like.js'; -import { ModerationLogRepository } from './repositories/moderation-logs.js'; -import { UsedUsername } from './entities/used-username.js'; -import { ClipRepository } from './repositories/clip.js'; -import { ClipNote } from './entities/clip-note.js'; -import { AntennaRepository } from './repositories/antenna.js'; -import { AntennaNote } from './entities/antenna-note.js'; -import { PromoNote } from './entities/promo-note.js'; -import { PromoRead } from './entities/promo-read.js'; -import { EmojiRepository } from './repositories/emoji.js'; -import { RelayRepository } from './repositories/relay.js'; -import { ChannelRepository } from './repositories/channel.js'; -import { MutedNote } from './entities/muted-note.js'; -import { ChannelFollowing } from './entities/channel-following.js'; -import { ChannelNotePining } from './entities/channel-note-pining.js'; -import { RegistryItem } from './entities/registry-item.js'; -import { Ad } from './entities/ad.js'; -import { PasswordResetRequest } from './entities/password-reset-request.js'; -import { UserPending } from './entities/user-pending.js'; -import { InstanceRepository } from './repositories/instance.js'; -import { Webhook } from './entities/webhook.js'; -import { UserIp } from './entities/user-ip.js'; +export { + AbuseUserReport, + AccessToken, + Ad, + Announcement, + AnnouncementRead, + Antenna, + AntennaNote, + App, + AttestationChallenge, + AuthSession, + Blocking, + ChannelFollowing, + ChannelNotePining, + Clip, + ClipNote, + DriveFile, + DriveFolder, + Emoji, + Following, + FollowRequest, + GalleryLike, + GalleryPost, + Hashtag, + Instance, + MessagingMessage, + Meta, + ModerationLog, + MutedNote, + Muting, + Note, + NoteFavorite, + NoteReaction, + NoteThreadMuting, + NoteUnread, + Notification, + Page, + PageLike, + PasswordResetRequest, + Poll, + PollVote, + PromoNote, + PromoRead, + RegistrationTicket, + RegistryItem, + Relay, + Signin, + SwSubscription, + UsedUsername, + User, + UserGroup, + UserGroupInvitation, + UserGroupJoining, + UserIp, + UserKeypair, + UserList, + UserListJoining, + UserNotePining, + UserPending, + UserProfile, + UserPublickey, + UserSecurityKey, + Webhook, + Channel, +}; -export const Announcements = db.getRepository(Announcement); -export const AnnouncementReads = db.getRepository(AnnouncementRead); -export const Apps = (AppRepository); -export const Notes = (NoteRepository); -export const NoteFavorites = (NoteFavoriteRepository); -export const NoteWatchings = db.getRepository(NoteWatching); -export const NoteThreadMutings = db.getRepository(NoteThreadMuting); -export const NoteReactions = (NoteReactionRepository); -export const NoteUnreads = db.getRepository(NoteUnread); -export const Polls = db.getRepository(Poll); -export const PollVotes = db.getRepository(PollVote); -export const Users = (UserRepository); -export const UserProfiles = db.getRepository(UserProfile); -export const UserKeypairs = db.getRepository(UserKeypair); -export const UserPendings = db.getRepository(UserPending); -export const AttestationChallenges = db.getRepository(AttestationChallenge); -export const UserSecurityKeys = db.getRepository(UserSecurityKey); -export const UserPublickeys = db.getRepository(UserPublickey); -export const UserLists = (UserListRepository); -export const UserListJoinings = db.getRepository(UserListJoining); -export const UserGroups = (UserGroupRepository); -export const UserGroupJoinings = db.getRepository(UserGroupJoining); -export const UserGroupInvitations = (UserGroupInvitationRepository); -export const UserNotePinings = db.getRepository(UserNotePining); -export const UserIps = db.getRepository(UserIp); -export const UsedUsernames = db.getRepository(UsedUsername); -export const Followings = (FollowingRepository); -export const FollowRequests = (FollowRequestRepository); -export const Instances = (InstanceRepository); -export const Emojis = (EmojiRepository); -export const DriveFiles = (DriveFileRepository); -export const DriveFolders = (DriveFolderRepository); -export const Notifications = (NotificationRepository); -export const Metas = db.getRepository(Meta); -export const Mutings = (MutingRepository); -export const Blockings = (BlockingRepository); -export const SwSubscriptions = db.getRepository(SwSubscription); -export const Hashtags = (HashtagRepository); -export const AbuseUserReports = (AbuseUserReportRepository); -export const RegistrationTickets = db.getRepository(RegistrationTicket); -export const AuthSessions = (AuthSessionRepository); -export const AccessTokens = db.getRepository(AccessToken); -export const Signins = (SigninRepository); -export const MessagingMessages = (MessagingMessageRepository); -export const Pages = (PageRepository); -export const PageLikes = (PageLikeRepository); -export const GalleryPosts = (GalleryPostRepository); -export const GalleryLikes = (GalleryLikeRepository); -export const ModerationLogs = (ModerationLogRepository); -export const Clips = (ClipRepository); -export const ClipNotes = db.getRepository(ClipNote); -export const Antennas = (AntennaRepository); -export const AntennaNotes = db.getRepository(AntennaNote); -export const PromoNotes = db.getRepository(PromoNote); -export const PromoReads = db.getRepository(PromoRead); -export const Relays = (RelayRepository); -export const MutedNotes = db.getRepository(MutedNote); -export const Channels = (ChannelRepository); -export const ChannelFollowings = db.getRepository(ChannelFollowing); -export const ChannelNotePinings = db.getRepository(ChannelNotePining); -export const RegistryItems = db.getRepository(RegistryItem); -export const Webhooks = db.getRepository(Webhook); -export const Ads = db.getRepository(Ad); -export const PasswordResetRequests = db.getRepository(PasswordResetRequest); +export type AbuseUserReportsRepository = Repository; +export type AccessTokensRepository = Repository; +export type AdsRepository = Repository; +export type AnnouncementsRepository = Repository; +export type AnnouncementReadsRepository = Repository; +export type AntennasRepository = Repository; +export type AntennaNotesRepository = Repository; +export type AppsRepository = Repository; +export type AttestationChallengesRepository = Repository; +export type AuthSessionsRepository = Repository; +export type BlockingsRepository = Repository; +export type ChannelFollowingsRepository = Repository; +export type ChannelNotePiningsRepository = Repository; +export type ClipsRepository = Repository; +export type ClipNotesRepository = Repository; +export type DriveFilesRepository = Repository; +export type DriveFoldersRepository = Repository; +export type EmojisRepository = Repository; +export type FollowingsRepository = Repository; +export type FollowRequestsRepository = Repository; +export type GalleryLikesRepository = Repository; +export type GalleryPostsRepository = Repository; +export type HashtagsRepository = Repository; +export type InstancesRepository = Repository; +export type MessagingMessagesRepository = Repository; +export type MetasRepository = Repository; +export type ModerationLogsRepository = Repository; +export type MutedNotesRepository = Repository; +export type MutingsRepository = Repository; +export type NotesRepository = Repository; +export type NoteFavoritesRepository = Repository; +export type NoteReactionsRepository = Repository; +export type NoteThreadMutingsRepository = Repository; +export type NoteUnreadsRepository = Repository; +export type NotificationsRepository = Repository; +export type PagesRepository = Repository; +export type PageLikesRepository = Repository; +export type PasswordResetRequestsRepository = Repository; +export type PollsRepository = Repository; +export type PollVotesRepository = Repository; +export type PromoNotesRepository = Repository; +export type PromoReadsRepository = Repository; +export type RegistrationTicketsRepository = Repository; +export type RegistryItemsRepository = Repository; +export type RelaysRepository = Repository; +export type SigninsRepository = Repository; +export type SwSubscriptionsRepository = Repository; +export type UsedUsernamesRepository = Repository; +export type UsersRepository = Repository; +export type UserGroupsRepository = Repository; +export type UserGroupInvitationsRepository = Repository; +export type UserGroupJoiningsRepository = Repository; +export type UserIpsRepository = Repository; +export type UserKeypairsRepository = Repository; +export type UserListsRepository = Repository; +export type UserListJoiningsRepository = Repository; +export type UserNotePiningsRepository = Repository; +export type UserPendingsRepository = Repository; +export type UserProfilesRepository = Repository; +export type UserPublickeysRepository = Repository; +export type UserSecurityKeysRepository = Repository; +export type WebhooksRepository = Repository; +export type ChannelsRepository = Repository; diff --git a/packages/backend/src/models/repositories/abuse-user-report.ts b/packages/backend/src/models/repositories/abuse-user-report.ts deleted file mode 100644 index 36d7ab90c..000000000 --- a/packages/backend/src/models/repositories/abuse-user-report.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { db } from '@/db/postgre.js'; -import { Users } from '../index.js'; -import { AbuseUserReport } from '@/models/entities/abuse-user-report.js'; -import { awaitAll } from '@/prelude/await-all.js'; - -export const AbuseUserReportRepository = db.getRepository(AbuseUserReport).extend({ - async pack( - src: AbuseUserReport['id'] | AbuseUserReport, - ) { - const report = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); - - return await awaitAll({ - id: report.id, - createdAt: report.createdAt.toISOString(), - comment: report.comment, - resolved: report.resolved, - reporterId: report.reporterId, - targetUserId: report.targetUserId, - assigneeId: report.assigneeId, - reporter: Users.pack(report.reporter || report.reporterId, null, { - detail: true, - }), - targetUser: Users.pack(report.targetUser || report.targetUserId, null, { - detail: true, - }), - assignee: report.assigneeId ? Users.pack(report.assignee || report.assigneeId, null, { - detail: true, - }) : null, - forwarded: report.forwarded, - }); - }, - - packMany( - reports: any[], - ) { - return Promise.all(reports.map(x => this.pack(x))); - }, -}); diff --git a/packages/backend/src/models/repositories/antenna.ts b/packages/backend/src/models/repositories/antenna.ts deleted file mode 100644 index 70180e2de..000000000 --- a/packages/backend/src/models/repositories/antenna.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { db } from '@/db/postgre.js'; -import { Antenna } from '@/models/entities/antenna.js'; -import { Packed } from '@/misc/schema.js'; -import { AntennaNotes, UserGroupJoinings } from '../index.js'; - -export const AntennaRepository = db.getRepository(Antenna).extend({ - async pack( - src: Antenna['id'] | Antenna, - ): Promise> { - const antenna = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); - - const hasUnreadNote = (await AntennaNotes.findOneBy({ antennaId: antenna.id, read: false })) != null; - const userGroupJoining = antenna.userGroupJoiningId ? await UserGroupJoinings.findOneBy({ id: antenna.userGroupJoiningId }) : null; - - return { - id: antenna.id, - createdAt: antenna.createdAt.toISOString(), - name: antenna.name, - keywords: antenna.keywords, - excludeKeywords: antenna.excludeKeywords, - src: antenna.src, - userListId: antenna.userListId, - userGroupId: userGroupJoining ? userGroupJoining.userGroupId : null, - users: antenna.users, - caseSensitive: antenna.caseSensitive, - notify: antenna.notify, - withReplies: antenna.withReplies, - withFile: antenna.withFile, - hasUnreadNote, - }; - }, -}); diff --git a/packages/backend/src/models/repositories/app.ts b/packages/backend/src/models/repositories/app.ts deleted file mode 100644 index e08dd6f0e..000000000 --- a/packages/backend/src/models/repositories/app.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { db } from '@/db/postgre.js'; -import { App } from '@/models/entities/app.js'; -import { AccessTokens } from '../index.js'; -import { Packed } from '@/misc/schema.js'; -import { User } from '../entities/user.js'; - -export const AppRepository = db.getRepository(App).extend({ - async pack( - src: App['id'] | App, - me?: { id: User['id'] } | null | undefined, - options?: { - detail?: boolean, - includeSecret?: boolean, - includeProfileImageIds?: boolean - } - ): Promise> { - const opts = Object.assign({ - detail: false, - includeSecret: false, - includeProfileImageIds: false, - }, options); - - const app = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); - - return { - id: app.id, - name: app.name, - callbackUrl: app.callbackUrl, - permission: app.permission, - ...(opts.includeSecret ? { secret: app.secret } : {}), - ...(me ? { - isAuthorized: await AccessTokens.countBy({ - appId: app.id, - userId: me.id, - }).then(count => count > 0), - } : {}), - }; - }, -}); diff --git a/packages/backend/src/models/repositories/auth-session.ts b/packages/backend/src/models/repositories/auth-session.ts deleted file mode 100644 index 3f1f6f489..000000000 --- a/packages/backend/src/models/repositories/auth-session.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { db } from '@/db/postgre.js'; -import { Apps } from '../index.js'; -import { AuthSession } from '@/models/entities/auth-session.js'; -import { awaitAll } from '@/prelude/await-all.js'; -import { User } from '@/models/entities/user.js'; - -export const AuthSessionRepository = db.getRepository(AuthSession).extend({ - async pack( - src: AuthSession['id'] | AuthSession, - me?: { id: User['id'] } | null | undefined - ) { - const session = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); - - return await awaitAll({ - id: session.id, - app: Apps.pack(session.appId, me), - token: session.token, - }); - }, -}); diff --git a/packages/backend/src/models/repositories/blocking.ts b/packages/backend/src/models/repositories/blocking.ts deleted file mode 100644 index 1d569fb87..000000000 --- a/packages/backend/src/models/repositories/blocking.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { db } from '@/db/postgre.js'; -import { Users } from '../index.js'; -import { Blocking } from '@/models/entities/blocking.js'; -import { awaitAll } from '@/prelude/await-all.js'; -import { Packed } from '@/misc/schema.js'; -import { User } from '@/models/entities/user.js'; - -export const BlockingRepository = db.getRepository(Blocking).extend({ - async pack( - src: Blocking['id'] | Blocking, - me?: { id: User['id'] } | null | undefined - ): Promise> { - const blocking = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); - - return await awaitAll({ - id: blocking.id, - createdAt: blocking.createdAt.toISOString(), - blockeeId: blocking.blockeeId, - blockee: Users.pack(blocking.blockeeId, me, { - detail: true, - }), - }); - }, - - packMany( - blockings: any[], - me: { id: User['id'] } - ) { - return Promise.all(blockings.map(x => this.pack(x, me))); - }, -}); diff --git a/packages/backend/src/models/repositories/channel.ts b/packages/backend/src/models/repositories/channel.ts deleted file mode 100644 index 213ac3671..000000000 --- a/packages/backend/src/models/repositories/channel.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { db } from '@/db/postgre.js'; -import { Channel } from '@/models/entities/channel.js'; -import { Packed } from '@/misc/schema.js'; -import { DriveFiles, ChannelFollowings, NoteUnreads } from '../index.js'; -import { User } from '@/models/entities/user.js'; - -export const ChannelRepository = db.getRepository(Channel).extend({ - async pack( - src: Channel['id'] | Channel, - me?: { id: User['id'] } | null | undefined, - ): Promise> { - const channel = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); - const meId = me ? me.id : null; - - const banner = channel.bannerId ? await DriveFiles.findOneBy({ id: channel.bannerId }) : null; - - const hasUnreadNote = meId ? (await NoteUnreads.findOneBy({ noteChannelId: channel.id, userId: meId })) != null : undefined; - - const following = meId ? await ChannelFollowings.findOneBy({ - followerId: meId, - followeeId: channel.id, - }) : null; - - return { - id: channel.id, - createdAt: channel.createdAt.toISOString(), - lastNotedAt: channel.lastNotedAt ? channel.lastNotedAt.toISOString() : null, - name: channel.name, - description: channel.description, - userId: channel.userId, - bannerUrl: banner ? DriveFiles.getPublicUrl(banner, false) : null, - usersCount: channel.usersCount, - notesCount: channel.notesCount, - - ...(me ? { - isFollowing: following != null, - hasUnreadNote, - } : {}), - }; - }, -}); diff --git a/packages/backend/src/models/repositories/clip.ts b/packages/backend/src/models/repositories/clip.ts deleted file mode 100644 index b4a342905..000000000 --- a/packages/backend/src/models/repositories/clip.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { db } from '@/db/postgre.js'; -import { Clip } from '@/models/entities/clip.js'; -import { Packed } from '@/misc/schema.js'; -import { Users } from '../index.js'; -import { awaitAll } from '@/prelude/await-all.js'; - -export const ClipRepository = db.getRepository(Clip).extend({ - async pack( - src: Clip['id'] | Clip, - ): Promise> { - const clip = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); - - return await awaitAll({ - id: clip.id, - createdAt: clip.createdAt.toISOString(), - userId: clip.userId, - user: Users.pack(clip.user || clip.userId), - name: clip.name, - description: clip.description, - isPublic: clip.isPublic, - }); - }, - - packMany( - clips: Clip[], - ) { - return Promise.all(clips.map(x => this.pack(x))); - }, -}); - diff --git a/packages/backend/src/models/repositories/drive-folder.ts b/packages/backend/src/models/repositories/drive-folder.ts deleted file mode 100644 index ab5f3dab6..000000000 --- a/packages/backend/src/models/repositories/drive-folder.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { db } from '@/db/postgre.js'; -import { DriveFolders, DriveFiles } from '../index.js'; -import { DriveFolder } from '@/models/entities/drive-folder.js'; -import { awaitAll } from '@/prelude/await-all.js'; -import { Packed } from '@/misc/schema.js'; - -export const DriveFolderRepository = db.getRepository(DriveFolder).extend({ - async pack( - src: DriveFolder['id'] | DriveFolder, - options?: { - detail: boolean - } - ): Promise> { - const opts = Object.assign({ - detail: false, - }, options); - - const folder = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); - - return await awaitAll({ - id: folder.id, - createdAt: folder.createdAt.toISOString(), - name: folder.name, - parentId: folder.parentId, - - ...(opts.detail ? { - foldersCount: DriveFolders.countBy({ - parentId: folder.id, - }), - filesCount: DriveFiles.countBy({ - folderId: folder.id, - }), - - ...(folder.parentId ? { - parent: this.pack(folder.parentId, { - detail: true, - }), - } : {}), - } : {}), - }); - }, -}); diff --git a/packages/backend/src/models/repositories/emoji.ts b/packages/backend/src/models/repositories/emoji.ts deleted file mode 100644 index a0d390d79..000000000 --- a/packages/backend/src/models/repositories/emoji.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { db } from '@/db/postgre.js'; -import { Emoji } from '@/models/entities/emoji.js'; -import { Packed } from '@/misc/schema.js'; - -export const EmojiRepository = db.getRepository(Emoji).extend({ - async pack( - src: Emoji['id'] | Emoji, - ): Promise> { - const emoji = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); - - return { - id: emoji.id, - aliases: emoji.aliases, - name: emoji.name, - category: emoji.category, - host: emoji.host, - // || emoji.originalUrl してるのは後方互換性のため - url: emoji.publicUrl || emoji.originalUrl, - }; - }, - - packMany( - emojis: any[], - ) { - return Promise.all(emojis.map(x => this.pack(x))); - }, -}); diff --git a/packages/backend/src/models/repositories/follow-request.ts b/packages/backend/src/models/repositories/follow-request.ts deleted file mode 100644 index c4a7203aa..000000000 --- a/packages/backend/src/models/repositories/follow-request.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { db } from '@/db/postgre.js'; -import { FollowRequest } from '@/models/entities/follow-request.js'; -import { Users } from '../index.js'; -import { User } from '@/models/entities/user.js'; - -export const FollowRequestRepository = db.getRepository(FollowRequest).extend({ - async pack( - src: FollowRequest['id'] | FollowRequest, - me?: { id: User['id'] } | null | undefined - ) { - const request = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); - - return { - id: request.id, - follower: await Users.pack(request.followerId, me), - followee: await Users.pack(request.followeeId, me), - }; - }, -}); diff --git a/packages/backend/src/models/repositories/gallery-like.ts b/packages/backend/src/models/repositories/gallery-like.ts deleted file mode 100644 index 08ca4962b..000000000 --- a/packages/backend/src/models/repositories/gallery-like.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { db } from '@/db/postgre.js'; -import { GalleryLike } from '@/models/entities/gallery-like.js'; -import { GalleryPosts } from '../index.js'; - -export const GalleryLikeRepository = db.getRepository(GalleryLike).extend({ - async pack( - src: GalleryLike['id'] | GalleryLike, - me?: any - ) { - const like = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); - - return { - id: like.id, - post: await GalleryPosts.pack(like.post || like.postId, me), - }; - }, - - packMany( - likes: any[], - me: any - ) { - return Promise.all(likes.map(x => this.pack(x, me))); - }, -}); diff --git a/packages/backend/src/models/repositories/gallery-post.ts b/packages/backend/src/models/repositories/gallery-post.ts deleted file mode 100644 index bb8d40b75..000000000 --- a/packages/backend/src/models/repositories/gallery-post.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { db } from '@/db/postgre.js'; -import { GalleryPost } from '@/models/entities/gallery-post.js'; -import { Packed } from '@/misc/schema.js'; -import { Users, DriveFiles, GalleryLikes } from '../index.js'; -import { awaitAll } from '@/prelude/await-all.js'; -import { User } from '@/models/entities/user.js'; - -export const GalleryPostRepository = db.getRepository(GalleryPost).extend({ - async pack( - src: GalleryPost['id'] | GalleryPost, - me?: { id: User['id'] } | null | undefined, - ): Promise> { - const meId = me ? me.id : null; - const post = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); - - return await awaitAll({ - id: post.id, - createdAt: post.createdAt.toISOString(), - updatedAt: post.updatedAt.toISOString(), - userId: post.userId, - user: Users.pack(post.user || post.userId, me), - title: post.title, - description: post.description, - fileIds: post.fileIds, - files: DriveFiles.packMany(post.fileIds), - tags: post.tags.length > 0 ? post.tags : undefined, - isSensitive: post.isSensitive, - likedCount: post.likedCount, - isLiked: meId ? await GalleryLikes.findOneBy({ postId: post.id, userId: meId }).then(x => x != null) : undefined, - }); - }, - - packMany( - posts: GalleryPost[], - me?: { id: User['id'] } | null | undefined, - ) { - return Promise.all(posts.map(x => this.pack(x, me))); - }, -}); diff --git a/packages/backend/src/models/repositories/hashtag.ts b/packages/backend/src/models/repositories/hashtag.ts deleted file mode 100644 index e6c0e36f0..000000000 --- a/packages/backend/src/models/repositories/hashtag.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { db } from '@/db/postgre.js'; -import { Hashtag } from '@/models/entities/hashtag.js'; -import { Packed } from '@/misc/schema.js'; - -export const HashtagRepository = db.getRepository(Hashtag).extend({ - async pack( - src: Hashtag, - ): Promise> { - return { - tag: src.name, - mentionedUsersCount: src.mentionedUsersCount, - mentionedLocalUsersCount: src.mentionedLocalUsersCount, - mentionedRemoteUsersCount: src.mentionedRemoteUsersCount, - attachedUsersCount: src.attachedUsersCount, - attachedLocalUsersCount: src.attachedLocalUsersCount, - attachedRemoteUsersCount: src.attachedRemoteUsersCount, - }; - }, - - packMany( - hashtags: Hashtag[], - ) { - return Promise.all(hashtags.map(x => this.pack(x))); - }, -}); diff --git a/packages/backend/src/models/repositories/messaging-message.ts b/packages/backend/src/models/repositories/messaging-message.ts deleted file mode 100644 index 6c51c93ff..000000000 --- a/packages/backend/src/models/repositories/messaging-message.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { db } from '@/db/postgre.js'; -import { MessagingMessage } from '@/models/entities/messaging-message.js'; -import { Users, DriveFiles, UserGroups } from '../index.js'; -import { Packed } from '@/misc/schema.js'; -import { User } from '@/models/entities/user.js'; - -export const MessagingMessageRepository = db.getRepository(MessagingMessage).extend({ - async pack( - src: MessagingMessage['id'] | MessagingMessage, - me?: { id: User['id'] } | null | undefined, - options?: { - populateRecipient?: boolean, - populateGroup?: boolean, - } - ): Promise> { - const opts = options || { - populateRecipient: true, - populateGroup: true, - }; - - const message = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); - - return { - id: message.id, - createdAt: message.createdAt.toISOString(), - text: message.text, - userId: message.userId, - user: await Users.pack(message.user || message.userId, me), - recipientId: message.recipientId, - recipient: message.recipientId && opts.populateRecipient ? await Users.pack(message.recipient || message.recipientId, me) : undefined, - groupId: message.groupId, - group: message.groupId && opts.populateGroup ? await UserGroups.pack(message.group || message.groupId) : undefined, - fileId: message.fileId, - file: message.fileId ? await DriveFiles.pack(message.fileId) : null, - isRead: message.isRead, - reads: message.reads, - }; - }, -}); diff --git a/packages/backend/src/models/repositories/moderation-logs.ts b/packages/backend/src/models/repositories/moderation-logs.ts deleted file mode 100644 index 1488b1eab..000000000 --- a/packages/backend/src/models/repositories/moderation-logs.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { db } from '@/db/postgre.js'; -import { Users } from '../index.js'; -import { ModerationLog } from '@/models/entities/moderation-log.js'; -import { awaitAll } from '@/prelude/await-all.js'; - -export const ModerationLogRepository = db.getRepository(ModerationLog).extend({ - async pack( - src: ModerationLog['id'] | ModerationLog, - ) { - const log = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); - - return await awaitAll({ - id: log.id, - createdAt: log.createdAt.toISOString(), - type: log.type, - info: log.info, - userId: log.userId, - user: Users.pack(log.user || log.userId, null, { - detail: true, - }), - }); - }, - - packMany( - reports: any[], - ) { - return Promise.all(reports.map(x => this.pack(x))); - }, -}); diff --git a/packages/backend/src/models/repositories/muting.ts b/packages/backend/src/models/repositories/muting.ts deleted file mode 100644 index 7891b10fb..000000000 --- a/packages/backend/src/models/repositories/muting.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { db } from '@/db/postgre.js'; -import { Users } from '../index.js'; -import { Muting } from '@/models/entities/muting.js'; -import { awaitAll } from '@/prelude/await-all.js'; -import { Packed } from '@/misc/schema.js'; -import { User } from '@/models/entities/user.js'; - -export const MutingRepository = db.getRepository(Muting).extend({ - async pack( - src: Muting['id'] | Muting, - me?: { id: User['id'] } | null | undefined - ): Promise> { - const muting = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); - - return await awaitAll({ - id: muting.id, - createdAt: muting.createdAt.toISOString(), - expiresAt: muting.expiresAt ? muting.expiresAt.toISOString() : null, - muteeId: muting.muteeId, - mutee: Users.pack(muting.muteeId, me, { - detail: true, - }), - }); - }, - - packMany( - mutings: any[], - me: { id: User['id'] } - ) { - return Promise.all(mutings.map(x => this.pack(x, me))); - }, -}); diff --git a/packages/backend/src/models/repositories/note-favorite.ts b/packages/backend/src/models/repositories/note-favorite.ts deleted file mode 100644 index 9bd97f988..000000000 --- a/packages/backend/src/models/repositories/note-favorite.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { db } from '@/db/postgre.js'; -import { NoteFavorite } from '@/models/entities/note-favorite.js'; -import { Notes } from '../index.js'; -import { User } from '@/models/entities/user.js'; - -export const NoteFavoriteRepository = db.getRepository(NoteFavorite).extend({ - async pack( - src: NoteFavorite['id'] | NoteFavorite, - me?: { id: User['id'] } | null | undefined - ) { - const favorite = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); - - return { - id: favorite.id, - createdAt: favorite.createdAt.toISOString(), - noteId: favorite.noteId, - note: await Notes.pack(favorite.note || favorite.noteId, me), - }; - }, - - packMany( - favorites: any[], - me: { id: User['id'] } - ) { - return Promise.all(favorites.map(x => this.pack(x, me))); - }, -}); diff --git a/packages/backend/src/models/repositories/note-reaction.ts b/packages/backend/src/models/repositories/note-reaction.ts deleted file mode 100644 index 4deae51c9..000000000 --- a/packages/backend/src/models/repositories/note-reaction.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { db } from '@/db/postgre.js'; -import { NoteReaction } from '@/models/entities/note-reaction.js'; -import { Notes, Users } from '../index.js'; -import { Packed } from '@/misc/schema.js'; -import { convertLegacyReaction } from '@/misc/reaction-lib.js'; -import { User } from '@/models/entities/user.js'; - -export const NoteReactionRepository = db.getRepository(NoteReaction).extend({ - async pack( - src: NoteReaction['id'] | NoteReaction, - me?: { id: User['id'] } | null | undefined, - options?: { - withNote: boolean; - }, - ): Promise> { - const opts = Object.assign({ - withNote: false, - }, options); - - const reaction = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); - - return { - id: reaction.id, - createdAt: reaction.createdAt.toISOString(), - user: await Users.pack(reaction.user ?? reaction.userId, me), - type: convertLegacyReaction(reaction.reaction), - ...(opts.withNote ? { - note: await Notes.pack(reaction.note ?? reaction.noteId, me), - } : {}), - }; - }, -}); diff --git a/packages/backend/src/models/repositories/note.ts b/packages/backend/src/models/repositories/note.ts deleted file mode 100644 index 3fefab031..000000000 --- a/packages/backend/src/models/repositories/note.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { In } from 'typeorm'; -import * as mfm from 'mfm-js'; -import { Note } from '@/models/entities/note.js'; -import { User } from '@/models/entities/user.js'; -import { Users, PollVotes, DriveFiles, NoteReactions, Followings, Polls, Channels } from '../index.js'; -import { Packed } from '@/misc/schema.js'; -import { nyaize } from '@/misc/nyaize.js'; -import { awaitAll } from '@/prelude/await-all.js'; -import { convertLegacyReaction, convertLegacyReactions, decodeReaction } from '@/misc/reaction-lib.js'; -import { NoteReaction } from '@/models/entities/note-reaction.js'; -import { aggregateNoteEmojis, populateEmojis, prefetchEmojis } from '@/misc/populate-emojis.js'; -import { db } from '@/db/postgre.js'; - -async function hideNote(packedNote: Packed<'Note'>, meId: User['id'] | null) { - // TODO: isVisibleForMe を使うようにしても良さそう(型違うけど) - let hide = false; - - // visibility が specified かつ自分が指定されていなかったら非表示 - if (packedNote.visibility === 'specified') { - if (meId == null) { - hide = true; - } else if (meId === packedNote.userId) { - hide = false; - } else { - // 指定されているかどうか - const specified = packedNote.visibleUserIds!.some((id: any) => meId === id); - - if (specified) { - hide = false; - } else { - hide = true; - } - } - } - - // visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示 - if (packedNote.visibility === 'followers') { - if (meId == null) { - hide = true; - } else if (meId === packedNote.userId) { - hide = false; - } else if (packedNote.reply && (meId === packedNote.reply.userId)) { - // 自分の投稿に対するリプライ - hide = false; - } else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) { - // 自分へのメンション - hide = false; - } else { - // フォロワーかどうか - const following = await Followings.findOneBy({ - followeeId: packedNote.userId, - followerId: meId, - }); - - if (following == null) { - hide = true; - } else { - hide = false; - } - } - } - - if (hide) { - packedNote.visibleUserIds = undefined; - packedNote.fileIds = []; - packedNote.files = []; - packedNote.text = null; - packedNote.poll = undefined; - packedNote.cw = null; - packedNote.isHidden = true; - } -} - -async function populatePoll(note: Note, meId: User['id'] | null) { - const poll = await Polls.findOneByOrFail({ noteId: note.id }); - const choices = poll.choices.map(c => ({ - text: c, - votes: poll.votes[poll.choices.indexOf(c)], - isVoted: false, - })); - - if (meId) { - if (poll.multiple) { - const votes = await PollVotes.findBy({ - userId: meId, - noteId: note.id, - }); - - const myChoices = votes.map(v => v.choice); - for (const myChoice of myChoices) { - choices[myChoice].isVoted = true; - } - } else { - const vote = await PollVotes.findOneBy({ - userId: meId, - noteId: note.id, - }); - - if (vote) { - choices[vote.choice].isVoted = true; - } - } - } - - return { - multiple: poll.multiple, - expiresAt: poll.expiresAt, - choices, - }; -} - -async function populateMyReaction(note: Note, meId: User['id'], _hint_?: { - myReactions: Map; -}) { - if (_hint_?.myReactions) { - const reaction = _hint_.myReactions.get(note.id); - if (reaction) { - return convertLegacyReaction(reaction.reaction); - } else if (reaction === null) { - return undefined; - } - // 実装上抜けがあるだけかもしれないので、「ヒントに含まれてなかったら(=undefinedなら)return」のようにはしない - } - - const reaction = await NoteReactions.findOneBy({ - userId: meId, - noteId: note.id, - }); - - if (reaction) { - return convertLegacyReaction(reaction.reaction); - } - - return undefined; -} - -export const NoteRepository = db.getRepository(Note).extend({ - async isVisibleForMe(note: Note, meId: User['id'] | null): Promise { - // This code must always be synchronized with the checks in generateVisibilityQuery. - // visibility が specified かつ自分が指定されていなかったら非表示 - if (note.visibility === 'specified') { - if (meId == null) { - return false; - } else if (meId === note.userId) { - return true; - } else { - // 指定されているかどうか - return note.visibleUserIds.some((id: any) => meId === id); - } - } - - // visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示 - if (note.visibility === 'followers') { - if (meId == null) { - return false; - } else if (meId === note.userId) { - return true; - } else if (note.reply && (meId === note.reply.userId)) { - // 自分の投稿に対するリプライ - return true; - } else if (note.mentions && note.mentions.some(id => meId === id)) { - // 自分へのメンション - return true; - } else { - // フォロワーかどうか - const [following, user] = await Promise.all([ - Followings.count({ - where: { - followeeId: note.userId, - followerId: meId, - }, - take: 1, - }), - Users.findOneByOrFail({ id: meId }), - ]); - - /* If we know the following, everyhting is fine. - - But if we do not know the following, it might be that both the - author of the note and the author of the like are remote users, - in which case we can never know the following. Instead we have - to assume that the users are following each other. - */ - return following > 0 || (note.userHost != null && user.host != null); - } - } - - return true; - }, - - async pack( - src: Note['id'] | Note, - me?: { id: User['id'] } | null | undefined, - options?: { - detail?: boolean; - skipHide?: boolean; - _hint_?: { - myReactions: Map; - }; - } - ): Promise> { - const opts = Object.assign({ - detail: true, - skipHide: false, - }, options); - - const meId = me ? me.id : null; - const note = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); - const host = note.userHost; - - let text = note.text; - - if (note.name && (note.url ?? note.uri)) { - text = `【${note.name}】\n${(note.text || '').trim()}\n\n${note.url ?? note.uri}`; - } - - const channel = note.channelId - ? note.channel - ? note.channel - : await Channels.findOneBy({ id: note.channelId }) - : null; - - const reactionEmojiNames = Object.keys(note.reactions).filter(x => x?.startsWith(':')).map(x => decodeReaction(x).reaction).map(x => x.replace(/:/g, '')); - - const packed: Packed<'Note'> = await awaitAll({ - id: note.id, - createdAt: note.createdAt.toISOString(), - userId: note.userId, - user: Users.pack(note.user ?? note.userId, me, { - detail: false, - }), - text: text, - cw: note.cw, - visibility: note.visibility, - localOnly: note.localOnly || undefined, - visibleUserIds: note.visibility === 'specified' ? note.visibleUserIds : undefined, - renoteCount: note.renoteCount, - repliesCount: note.repliesCount, - reactions: convertLegacyReactions(note.reactions), - tags: note.tags.length > 0 ? note.tags : undefined, - emojis: populateEmojis(note.emojis.concat(reactionEmojiNames), host), - fileIds: note.fileIds, - files: DriveFiles.packMany(note.fileIds), - replyId: note.replyId, - renoteId: note.renoteId, - channelId: note.channelId || undefined, - channel: channel ? { - id: channel.id, - name: channel.name, - } : undefined, - mentions: note.mentions.length > 0 ? note.mentions : undefined, - uri: note.uri || undefined, - url: note.url || undefined, - - ...(opts.detail ? { - reply: note.replyId ? this.pack(note.reply || note.replyId, me, { - detail: false, - _hint_: options?._hint_, - }) : undefined, - - renote: note.renoteId ? this.pack(note.renote || note.renoteId, me, { - detail: true, - _hint_: options?._hint_, - }) : undefined, - - poll: note.hasPoll ? populatePoll(note, meId) : undefined, - - ...(meId ? { - myReaction: populateMyReaction(note, meId, options?._hint_), - } : {}), - } : {}), - }); - - if (packed.user.isCat && packed.text) { - const tokens = packed.text ? mfm.parse(packed.text) : []; - mfm.inspect(tokens, node => { - if (node.type === 'text') { - // TODO: quoteなtextはskip - node.props.text = nyaize(node.props.text); - } - }); - packed.text = mfm.toString(tokens); - } - - if (!opts.skipHide) { - await hideNote(packed, meId); - } - - return packed; - }, - - async packMany( - notes: Note[], - me?: { id: User['id'] } | null | undefined, - options?: { - detail?: boolean; - skipHide?: boolean; - } - ) { - if (notes.length === 0) return []; - - const meId = me ? me.id : null; - const myReactionsMap = new Map(); - if (meId) { - const renoteIds = notes.filter(n => n.renoteId != null).map(n => n.renoteId!); - const targets = [...notes.map(n => n.id), ...renoteIds]; - const myReactions = await NoteReactions.findBy({ - userId: meId, - noteId: In(targets), - }); - - for (const target of targets) { - myReactionsMap.set(target, myReactions.find(reaction => reaction.noteId === target) || null); - } - } - - await prefetchEmojis(aggregateNoteEmojis(notes)); - - return await Promise.all(notes.map(n => this.pack(n, me, { - ...options, - _hint_: { - myReactions: myReactionsMap, - }, - }))); - }, -}); diff --git a/packages/backend/src/models/repositories/notification.ts b/packages/backend/src/models/repositories/notification.ts deleted file mode 100644 index 42b47ab15..000000000 --- a/packages/backend/src/models/repositories/notification.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { In, Repository } from 'typeorm'; -import { Users, Notes, UserGroupInvitations, AccessTokens, NoteReactions } from '../index.js'; -import { Notification } from '@/models/entities/notification.js'; -import { awaitAll } from '@/prelude/await-all.js'; -import { Packed } from '@/misc/schema.js'; -import { Note } from '@/models/entities/note.js'; -import { NoteReaction } from '@/models/entities/note-reaction.js'; -import { User } from '@/models/entities/user.js'; -import { aggregateNoteEmojis, prefetchEmojis } from '@/misc/populate-emojis.js'; -import { notificationTypes } from '@/types.js'; -import { db } from '@/db/postgre.js'; - -export const NotificationRepository = db.getRepository(Notification).extend({ - async pack( - src: Notification['id'] | Notification, - options: { - _hintForEachNotes_?: { - myReactions: Map; - }; - } - ): Promise> { - const notification = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); - const token = notification.appAccessTokenId ? await AccessTokens.findOneByOrFail({ id: notification.appAccessTokenId }) : null; - - return await awaitAll({ - id: notification.id, - createdAt: notification.createdAt.toISOString(), - type: notification.type, - isRead: notification.isRead, - userId: notification.notifierId, - user: notification.notifierId ? Users.pack(notification.notifier || notification.notifierId) : null, - ...(notification.type === 'mention' ? { - note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), - } : {}), - ...(notification.type === 'reply' ? { - note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), - } : {}), - ...(notification.type === 'renote' ? { - note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), - } : {}), - ...(notification.type === 'quote' ? { - note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), - } : {}), - ...(notification.type === 'reaction' ? { - note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), - reaction: notification.reaction, - } : {}), - ...(notification.type === 'pollVote' ? { - note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), - choice: notification.choice, - } : {}), - ...(notification.type === 'pollEnded' ? { - note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), - } : {}), - ...(notification.type === 'groupInvited' ? { - invitation: UserGroupInvitations.pack(notification.userGroupInvitationId!), - } : {}), - ...(notification.type === 'app' ? { - body: notification.customBody, - header: notification.customHeader || token?.name, - icon: notification.customIcon || token?.iconUrl, - } : {}), - }); - }, - - async packMany( - notifications: Notification[], - meId: User['id'] - ) { - if (notifications.length === 0) return []; - - const notes = notifications.filter(x => x.note != null).map(x => x.note!); - const noteIds = notes.map(n => n.id); - const myReactionsMap = new Map(); - const renoteIds = notes.filter(n => n.renoteId != null).map(n => n.renoteId!); - const targets = [...noteIds, ...renoteIds]; - const myReactions = await NoteReactions.findBy({ - userId: meId, - noteId: In(targets), - }); - - for (const target of targets) { - myReactionsMap.set(target, myReactions.find(reaction => reaction.noteId === target) || null); - } - - await prefetchEmojis(aggregateNoteEmojis(notes)); - - return await Promise.all(notifications.map(x => this.pack(x, { - _hintForEachNotes_: { - myReactions: myReactionsMap, - }, - }))); - }, -}); diff --git a/packages/backend/src/models/repositories/page-like.ts b/packages/backend/src/models/repositories/page-like.ts deleted file mode 100644 index 87d6accc3..000000000 --- a/packages/backend/src/models/repositories/page-like.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { db } from '@/db/postgre.js'; -import { PageLike } from '@/models/entities/page-like.js'; -import { Pages } from '../index.js'; -import { User } from '@/models/entities/user.js'; - -export const PageLikeRepository = db.getRepository(PageLike).extend({ - async pack( - src: PageLike['id'] | PageLike, - me?: { id: User['id'] } | null | undefined - ) { - const like = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); - - return { - id: like.id, - page: await Pages.pack(like.page || like.pageId, me), - }; - }, - - packMany( - likes: any[], - me: { id: User['id'] } - ) { - return Promise.all(likes.map(x => this.pack(x, me))); - }, -}); diff --git a/packages/backend/src/models/repositories/page.ts b/packages/backend/src/models/repositories/page.ts deleted file mode 100644 index 092b26b39..000000000 --- a/packages/backend/src/models/repositories/page.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { db } from '@/db/postgre.js'; -import { Page } from '@/models/entities/page.js'; -import { Packed } from '@/misc/schema.js'; -import { awaitAll } from '@/prelude/await-all.js'; -import { DriveFile } from '@/models/entities/drive-file.js'; -import { User } from '@/models/entities/user.js'; -import { Users, DriveFiles, PageLikes } from '../index.js'; - -export const PageRepository = db.getRepository(Page).extend({ - async pack( - src: Page['id'] | Page, - me?: { id: User['id'] } | null | undefined, - ): Promise> { - const meId = me ? me.id : null; - const page = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); - - const attachedFiles: Promise[] = []; - const collectFile = (xs: any[]) => { - for (const x of xs) { - if (x.type === 'image') { - attachedFiles.push(DriveFiles.findOneBy({ - id: x.fileId, - userId: page.userId, - })); - } - if (x.children) { - collectFile(x.children); - } - } - }; - collectFile(page.content); - - // 後方互換性のため - let migrated = false; - const migrate = (xs: any[]) => { - for (const x of xs) { - if (x.type === 'input') { - if (x.inputType === 'text') { - x.type = 'textInput'; - } - if (x.inputType === 'number') { - x.type = 'numberInput'; - if (x.default) x.default = parseInt(x.default, 10); - } - migrated = true; - } - if (x.children) { - migrate(x.children); - } - } - }; - migrate(page.content); - if (migrated) { - this.update(page.id, { - content: page.content, - }); - } - - return await awaitAll({ - id: page.id, - createdAt: page.createdAt.toISOString(), - updatedAt: page.updatedAt.toISOString(), - userId: page.userId, - user: Users.pack(page.user || page.userId, me), // { detail: true } すると無限ループするので注意 - content: page.content, - variables: page.variables, - title: page.title, - name: page.name, - summary: page.summary, - hideTitleWhenPinned: page.hideTitleWhenPinned, - alignCenter: page.alignCenter, - font: page.font, - script: page.script, - eyeCatchingImageId: page.eyeCatchingImageId, - eyeCatchingImage: page.eyeCatchingImageId ? await DriveFiles.pack(page.eyeCatchingImageId) : null, - attachedFiles: DriveFiles.packMany((await Promise.all(attachedFiles)).filter((x): x is DriveFile => x != null)), - likedCount: page.likedCount, - isLiked: meId ? await PageLikes.findOneBy({ pageId: page.id, userId: meId }).then(x => x != null) : undefined, - }); - }, - - packMany( - pages: Page[], - me?: { id: User['id'] } | null | undefined, - ) { - return Promise.all(pages.map(x => this.pack(x, me))); - }, -}); diff --git a/packages/backend/src/models/repositories/relay.ts b/packages/backend/src/models/repositories/relay.ts deleted file mode 100644 index fa1c8f4d8..000000000 --- a/packages/backend/src/models/repositories/relay.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { db } from '@/db/postgre.js'; -import { Relay } from '@/models/entities/relay.js'; - -export const RelayRepository = db.getRepository(Relay).extend({ -}); diff --git a/packages/backend/src/models/repositories/signin.ts b/packages/backend/src/models/repositories/signin.ts deleted file mode 100644 index 94410ec58..000000000 --- a/packages/backend/src/models/repositories/signin.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { db } from '@/db/postgre.js'; -import { Signin } from '@/models/entities/signin.js'; - -export const SigninRepository = db.getRepository(Signin).extend({ - async pack( - src: Signin, - ) { - return src; - }, -}); diff --git a/packages/backend/src/models/repositories/user-group-invitation.ts b/packages/backend/src/models/repositories/user-group-invitation.ts deleted file mode 100644 index 79ad019c9..000000000 --- a/packages/backend/src/models/repositories/user-group-invitation.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { db } from '@/db/postgre.js'; -import { UserGroupInvitation } from '@/models/entities/user-group-invitation.js'; -import { UserGroups } from '../index.js'; - -export const UserGroupInvitationRepository = db.getRepository(UserGroupInvitation).extend({ - async pack( - src: UserGroupInvitation['id'] | UserGroupInvitation, - ) { - const invitation = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); - - return { - id: invitation.id, - group: await UserGroups.pack(invitation.userGroup || invitation.userGroupId), - }; - }, - - packMany( - invitations: any[], - ) { - return Promise.all(invitations.map(x => this.pack(x))); - }, -}); diff --git a/packages/backend/src/models/repositories/user-group.ts b/packages/backend/src/models/repositories/user-group.ts deleted file mode 100644 index 6eb923424..000000000 --- a/packages/backend/src/models/repositories/user-group.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { db } from '@/db/postgre.js'; -import { UserGroup } from '@/models/entities/user-group.js'; -import { UserGroupJoinings } from '../index.js'; -import { Packed } from '@/misc/schema.js'; - -export const UserGroupRepository = db.getRepository(UserGroup).extend({ - async pack( - src: UserGroup['id'] | UserGroup, - ): Promise> { - const userGroup = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); - - const users = await UserGroupJoinings.findBy({ - userGroupId: userGroup.id, - }); - - return { - id: userGroup.id, - createdAt: userGroup.createdAt.toISOString(), - name: userGroup.name, - ownerId: userGroup.userId, - userIds: users.map(x => x.userId), - }; - }, -}); diff --git a/packages/backend/src/models/repositories/user-list.ts b/packages/backend/src/models/repositories/user-list.ts deleted file mode 100644 index 2b6f411ef..000000000 --- a/packages/backend/src/models/repositories/user-list.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { db } from '@/db/postgre.js'; -import { UserList } from '@/models/entities/user-list.js'; -import { UserListJoinings } from '../index.js'; -import { Packed } from '@/misc/schema.js'; - -export const UserListRepository = db.getRepository(UserList).extend({ - async pack( - src: UserList['id'] | UserList, - ): Promise> { - const userList = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); - - const users = await UserListJoinings.findBy({ - userListId: userList.id, - }); - - return { - id: userList.id, - createdAt: userList.createdAt.toISOString(), - name: userList.name, - userIds: users.map(x => x.userId), - }; - }, -}); diff --git a/packages/backend/src/models/schema/federation-instance.ts b/packages/backend/src/models/schema/federation-instance.ts index 93327304f..c57b3fec1 100644 --- a/packages/backend/src/models/schema/federation-instance.ts +++ b/packages/backend/src/models/schema/federation-instance.ts @@ -1,5 +1,3 @@ -import config from '@/config/index.js'; - export const packedFederationInstanceSchema = { type: 'object', properties: { @@ -64,7 +62,6 @@ export const packedFederationInstanceSchema = { softwareVersion: { type: 'string', optional: false, nullable: true, - example: config.version, }, openRegistrations: { type: 'boolean', diff --git a/packages/backend/src/postgre.ts b/packages/backend/src/postgre.ts new file mode 100644 index 000000000..f7044b374 --- /dev/null +++ b/packages/backend/src/postgre.ts @@ -0,0 +1,215 @@ +// https://github.com/typeorm/typeorm/issues/2400 +import pg from 'pg'; +pg.types.setTypeParser(20, Number); + +import { DataSource } from 'typeorm'; +import * as highlight from 'cli-highlight'; +import { entities as charts } from '@/core/chart/entities.js'; + +import { AbuseUserReport } from '@/models/entities/AbuseUserReport.js'; +import { AccessToken } from '@/models/entities/AccessToken.js'; +import { Ad } from '@/models/entities/Ad.js'; +import { Announcement } from '@/models/entities/Announcement.js'; +import { AnnouncementRead } from '@/models/entities/AnnouncementRead.js'; +import { Antenna } from '@/models/entities/Antenna.js'; +import { AntennaNote } from '@/models/entities/AntennaNote.js'; +import { App } from '@/models/entities/App.js'; +import { AttestationChallenge } from '@/models/entities/AttestationChallenge.js'; +import { AuthSession } from '@/models/entities/AuthSession.js'; +import { Blocking } from '@/models/entities/Blocking.js'; +import { ChannelFollowing } from '@/models/entities/ChannelFollowing.js'; +import { ChannelNotePining } from '@/models/entities/ChannelNotePining.js'; +import { Clip } from '@/models/entities/Clip.js'; +import { ClipNote } from '@/models/entities/ClipNote.js'; +import { DriveFile } from '@/models/entities/DriveFile.js'; +import { DriveFolder } from '@/models/entities/DriveFolder.js'; +import { Emoji } from '@/models/entities/Emoji.js'; +import { Following } from '@/models/entities/Following.js'; +import { FollowRequest } from '@/models/entities/FollowRequest.js'; +import { GalleryLike } from '@/models/entities/GalleryLike.js'; +import { GalleryPost } from '@/models/entities/GalleryPost.js'; +import { Hashtag } from '@/models/entities/Hashtag.js'; +import { Instance } from '@/models/entities/Instance.js'; +import { MessagingMessage } from '@/models/entities/MessagingMessage.js'; +import { Meta } from '@/models/entities/Meta.js'; +import { ModerationLog } from '@/models/entities/ModerationLog.js'; +import { MutedNote } from '@/models/entities/MutedNote.js'; +import { Muting } from '@/models/entities/Muting.js'; +import { Note } from '@/models/entities/Note.js'; +import { NoteFavorite } from '@/models/entities/NoteFavorite.js'; +import { NoteReaction } from '@/models/entities/NoteReaction.js'; +import { NoteThreadMuting } from '@/models/entities/NoteThreadMuting.js'; +import { NoteUnread } from '@/models/entities/NoteUnread.js'; +import { Notification } from '@/models/entities/Notification.js'; +import { Page } from '@/models/entities/Page.js'; +import { PageLike } from '@/models/entities/PageLike.js'; +import { PasswordResetRequest } from '@/models/entities/PasswordResetRequest.js'; +import { Poll } from '@/models/entities/Poll.js'; +import { PollVote } from '@/models/entities/PollVote.js'; +import { PromoNote } from '@/models/entities/PromoNote.js'; +import { PromoRead } from '@/models/entities/PromoRead.js'; +import { RegistrationTicket } from '@/models/entities/RegistrationTickets.js'; +import { RegistryItem } from '@/models/entities/RegistryItem.js'; +import { Relay } from '@/models/entities/Relay.js'; +import { Signin } from '@/models/entities/Signin.js'; +import { SwSubscription } from '@/models/entities/SwSubscription.js'; +import { UsedUsername } from '@/models/entities/UsedUsername.js'; +import { User } from '@/models/entities/User.js'; +import { UserGroup } from '@/models/entities/UserGroup.js'; +import { UserGroupInvitation } from '@/models/entities/UserGroupInvitation.js'; +import { UserGroupJoining } from '@/models/entities/UserGroupJoining.js'; +import { UserIp } from '@/models/entities/UserIp.js'; +import { UserKeypair } from '@/models/entities/UserKeypair.js'; +import { UserList } from '@/models/entities/UserList.js'; +import { UserListJoining } from '@/models/entities/UserListJoining.js'; +import { UserNotePining } from '@/models/entities/UserNotePining.js'; +import { UserPending } from '@/models/entities/UserPending.js'; +import { UserProfile } from '@/models/entities/UserProfile.js'; +import { UserPublickey } from '@/models/entities/UserPublickey.js'; +import { UserSecurityKey } from '@/models/entities/UserSecurityKey.js'; +import { Webhook } from '@/models/entities/Webhook.js'; +import { Channel } from '@/models/entities/Channel.js'; + +import { loadConfig } from '@/config.js'; +import Logger from '@/logger.js'; +import { envOption } from './env.js'; + +export const dbLogger = new Logger('db'); + +const sqlLogger = dbLogger.createSubLogger('sql', 'gray', false); + +class MyCustomLogger implements Logger { + private highlight(sql: string) { + return highlight.highlight(sql, { + language: 'sql', ignoreIllegals: true, + }); + } + + public logQuery(query: string, parameters?: any[]) { + sqlLogger.info(this.highlight(query).substring(0, 100)); + } + + public logQueryError(error: string, query: string, parameters?: any[]) { + sqlLogger.error(this.highlight(query)); + } + + public logQuerySlow(time: number, query: string, parameters?: any[]) { + sqlLogger.warn(this.highlight(query)); + } + + public logSchemaBuild(message: string) { + sqlLogger.info(message); + } + + public log(message: string) { + sqlLogger.info(message); + } + + public logMigration(message: string) { + sqlLogger.info(message); + } +} + +export const entities = [ + Announcement, + AnnouncementRead, + Meta, + Instance, + App, + AuthSession, + AccessToken, + User, + UserProfile, + UserKeypair, + UserPublickey, + UserList, + UserListJoining, + UserGroup, + UserGroupJoining, + UserGroupInvitation, + UserNotePining, + UserSecurityKey, + UsedUsername, + AttestationChallenge, + Following, + FollowRequest, + Muting, + Blocking, + Note, + NoteFavorite, + NoteReaction, + NoteThreadMuting, + NoteUnread, + Page, + PageLike, + GalleryPost, + GalleryLike, + DriveFile, + DriveFolder, + Poll, + PollVote, + Notification, + Emoji, + Hashtag, + SwSubscription, + AbuseUserReport, + RegistrationTicket, + MessagingMessage, + Signin, + ModerationLog, + Clip, + ClipNote, + Antenna, + AntennaNote, + PromoNote, + PromoRead, + Relay, + MutedNote, + Channel, + ChannelFollowing, + ChannelNotePining, + RegistryItem, + Ad, + PasswordResetRequest, + UserPending, + Webhook, + UserIp, + ...charts, +]; + +const log = process.env.NODE_ENV !== 'production'; + +const config = loadConfig(); + +export function createPostgreDataSource() { + return new DataSource({ + type: 'postgres', + host: config.db.host, + port: config.db.port, + username: config.db.user, + password: config.db.pass, + database: config.db.db, + extra: { + statement_timeout: 1000 * 10, + ...config.db.extra, + }, + synchronize: process.env.NODE_ENV === 'test', + dropSchema: process.env.NODE_ENV === 'test', + cache: !config.db.disableCache && process.env.NODE_ENV !== 'test' ? { // dbをcloseしても何故かredisのコネクションが内部的に残り続けるようで、テストの際に支障が出るため無効にする(キャッシュも含めてテストしたいため本当は有効にしたいが...) + type: 'ioredis', + options: { + host: config.redis.host, + port: config.redis.port, + family: config.redis.family == null ? 0 : config.redis.family, + password: config.redis.pass, + keyPrefix: `${config.redis.prefix}:query:`, + db: config.redis.db ?? 0, + }, + } : false, + logging: log, + logger: log ? new MyCustomLogger() : undefined, + maxQueryExecutionTime: 300, + entities: entities, + migrations: ['../../migration/*.js'], + }); +} diff --git a/packages/backend/src/queue/DbQueueProcessorsService.ts b/packages/backend/src/queue/DbQueueProcessorsService.ts new file mode 100644 index 000000000..fcc9873a6 --- /dev/null +++ b/packages/backend/src/queue/DbQueueProcessorsService.ts @@ -0,0 +1,63 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { DbJobData } from '@/queue/types.js'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import { DeleteDriveFilesProcessorService } from './processors/DeleteDriveFilesProcessorService.js'; +import { ExportCustomEmojisProcessorService } from './processors/ExportCustomEmojisProcessorService.js'; +import { ExportNotesProcessorService } from './processors/ExportNotesProcessorService.js'; +import { ExportFollowingProcessorService } from './processors/ExportFollowingProcessorService.js'; +import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js'; +import { ExportBlockingProcessorService } from './processors/ExportBlockingProcessorService.js'; +import { ExportUserListsProcessorService } from './processors/ExportUserListsProcessorService.js'; +import { ImportFollowingProcessorService } from './processors/ImportFollowingProcessorService.js'; +import { ImportMutingProcessorService } from './processors/ImportMutingProcessorService.js'; +import { ImportBlockingProcessorService } from './processors/ImportBlockingProcessorService.js'; +import { ImportUserListsProcessorService } from './processors/ImportUserListsProcessorService.js'; +import { ImportCustomEmojisProcessorService } from './processors/ImportCustomEmojisProcessorService.js'; +import { DeleteAccountProcessorService } from './processors/DeleteAccountProcessorService.js'; +import type Bull from 'bull'; + +@Injectable() +export class DbQueueProcessorsService { + constructor( + @Inject(DI.config) + private config: Config, + + private deleteDriveFilesProcessorService: DeleteDriveFilesProcessorService, + private exportCustomEmojisProcessorService: ExportCustomEmojisProcessorService, + private exportNotesProcessorService: ExportNotesProcessorService, + private exportFollowingProcessorService: ExportFollowingProcessorService, + private exportMutingProcessorService: ExportMutingProcessorService, + private exportBlockingProcessorService: ExportBlockingProcessorService, + private exportUserListsProcessorService: ExportUserListsProcessorService, + private importFollowingProcessorService: ImportFollowingProcessorService, + private importMutingProcessorService: ImportMutingProcessorService, + private importBlockingProcessorService: ImportBlockingProcessorService, + private importUserListsProcessorService: ImportUserListsProcessorService, + private importCustomEmojisProcessorService: ImportCustomEmojisProcessorService, + private deleteAccountProcessorService: DeleteAccountProcessorService, + ) { + } + + public start(dbQueue: Bull.Queue) { + const jobs = { + deleteDriveFiles: (job, done) => this.deleteDriveFilesProcessorService.process(job, done), + exportCustomEmojis: (job, done) => this.exportCustomEmojisProcessorService.process(job, done), + exportNotes: (job, done) => this.exportNotesProcessorService.process(job, done), + exportFollowing: (job, done) => this.exportFollowingProcessorService.process(job, done), + exportMuting: (job, done) => this.exportMutingProcessorService.process(job, done), + exportBlocking: (job, done) => this.exportBlockingProcessorService.process(job, done), + exportUserLists: (job, done) => this.exportUserListsProcessorService.process(job, done), + importFollowing: (job, done) => this.importFollowingProcessorService.process(job, done), + importMuting: (job, done) => this.importMutingProcessorService.process(job, done), + importBlocking: (job, done) => this.importBlockingProcessorService.process(job, done), + importUserLists: (job, done) => this.importUserListsProcessorService.process(job, done), + importCustomEmojis: (job, done) => this.importCustomEmojisProcessorService.process(job, done), + deleteAccount: (job, done) => this.deleteAccountProcessorService.process(job, done), + } as Record>>; + + for (const [k, v] of Object.entries(jobs)) { + dbQueue.process(k, v); + } + } +} diff --git a/packages/backend/src/queue/ObjectStorageQueueProcessorsService.ts b/packages/backend/src/queue/ObjectStorageQueueProcessorsService.ts new file mode 100644 index 000000000..402c038be --- /dev/null +++ b/packages/backend/src/queue/ObjectStorageQueueProcessorsService.ts @@ -0,0 +1,30 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { ObjectStorageJobData } from '@/queue/types.js'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesProcessorService.js'; +import { DeleteFileProcessorService } from './processors/DeleteFileProcessorService.js'; +import type Bull from 'bull'; + +@Injectable() +export class ObjectStorageQueueProcessorsService { + constructor( + @Inject(DI.config) + private config: Config, + + private deleteFileProcessorService: DeleteFileProcessorService, + private cleanRemoteFilesProcessorService: CleanRemoteFilesProcessorService, + ) { + } + + public start(q: Bull.Queue) { + const jobs = { + deleteFile: (job, done) => this.deleteFileProcessorService.process(job, done), + cleanRemoteFiles: (job, done) => this.cleanRemoteFilesProcessorService.process(job, done), + } as Record>>; + + for (const [k, v] of Object.entries(jobs)) { + q.process(k, 16, v); + } + } +} diff --git a/packages/backend/src/queue/QueueLoggerService.ts b/packages/backend/src/queue/QueueLoggerService.ts new file mode 100644 index 000000000..a311470cc --- /dev/null +++ b/packages/backend/src/queue/QueueLoggerService.ts @@ -0,0 +1,14 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type Logger from '@/logger.js'; +import { LoggerService } from '@/core/LoggerService.js'; + +@Injectable() +export class QueueLoggerService { + public logger: Logger; + + constructor( + private loggerService: LoggerService, + ) { + this.logger = this.loggerService.getLogger('queue', 'orange'); + } +} diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts new file mode 100644 index 000000000..f13dd3ef1 --- /dev/null +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -0,0 +1,72 @@ +import { Module } from '@nestjs/common'; +import { CoreModule } from '@/core/CoreModule.js'; +import { QueueLoggerService } from './QueueLoggerService.js'; +import { QueueProcessorService } from './QueueProcessorService.js'; +import { DbQueueProcessorsService } from './DbQueueProcessorsService.js'; +import { ObjectStorageQueueProcessorsService } from './ObjectStorageQueueProcessorsService.js'; +import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; +import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; +import { InboxProcessorService } from './processors/InboxProcessorService.js'; +import { WebhookDeliverProcessorService } from './processors/WebhookDeliverProcessorService.js'; +import { SystemQueueProcessorsService } from './SystemQueueProcessorsService.js'; +import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js'; +import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js'; +import { CleanProcessorService } from './processors/CleanProcessorService.js'; +import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesProcessorService.js'; +import { DeleteAccountProcessorService } from './processors/DeleteAccountProcessorService.js'; +import { DeleteDriveFilesProcessorService } from './processors/DeleteDriveFilesProcessorService.js'; +import { DeleteFileProcessorService } from './processors/DeleteFileProcessorService.js'; +import { ExportBlockingProcessorService } from './processors/ExportBlockingProcessorService.js'; +import { ExportCustomEmojisProcessorService } from './processors/ExportCustomEmojisProcessorService.js'; +import { ExportFollowingProcessorService } from './processors/ExportFollowingProcessorService.js'; +import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js'; +import { ExportNotesProcessorService } from './processors/ExportNotesProcessorService.js'; +import { ExportUserListsProcessorService } from './processors/ExportUserListsProcessorService.js'; +import { ImportBlockingProcessorService } from './processors/ImportBlockingProcessorService.js'; +import { ImportCustomEmojisProcessorService } from './processors/ImportCustomEmojisProcessorService.js'; +import { ImportFollowingProcessorService } from './processors/ImportFollowingProcessorService.js'; +import { ImportMutingProcessorService } from './processors/ImportMutingProcessorService.js'; +import { ImportUserListsProcessorService } from './processors/ImportUserListsProcessorService.js'; +import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js'; +import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js'; + +@Module({ + imports: [ + CoreModule, + ], + providers: [ + QueueLoggerService, + TickChartsProcessorService, + ResyncChartsProcessorService, + CleanChartsProcessorService, + CheckExpiredMutingsProcessorService, + CleanProcessorService, + DeleteDriveFilesProcessorService, + ExportCustomEmojisProcessorService, + ExportNotesProcessorService, + ExportFollowingProcessorService, + ExportMutingProcessorService, + ExportBlockingProcessorService, + ExportUserListsProcessorService, + ImportFollowingProcessorService, + ImportMutingProcessorService, + ImportBlockingProcessorService, + ImportUserListsProcessorService, + ImportCustomEmojisProcessorService, + DeleteAccountProcessorService, + DeleteFileProcessorService, + CleanRemoteFilesProcessorService, + SystemQueueProcessorsService, + ObjectStorageQueueProcessorsService, + DbQueueProcessorsService, + WebhookDeliverProcessorService, + EndedPollNotificationProcessorService, + DeliverProcessorService, + InboxProcessorService, + QueueProcessorService, + ], + exports: [ + QueueProcessorService, + ], +}) +export class QueueProcessorModule {} diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts new file mode 100644 index 000000000..753df8cad --- /dev/null +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -0,0 +1,141 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; +import { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; +import type Logger from '@/logger.js'; +import { QueueService } from '@/core/QueueService.js'; +import { getJobInfo } from './get-job-info.js'; +import { SystemQueueProcessorsService } from './SystemQueueProcessorsService.js'; +import { ObjectStorageQueueProcessorsService } from './ObjectStorageQueueProcessorsService.js'; +import { DbQueueProcessorsService } from './DbQueueProcessorsService.js'; +import { WebhookDeliverProcessorService } from './processors/WebhookDeliverProcessorService.js'; +import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; +import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; +import { InboxProcessorService } from './processors/InboxProcessorService.js'; +import { QueueLoggerService } from './QueueLoggerService.js'; + +@Injectable() +export class QueueProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + private queueLoggerService: QueueLoggerService, + private queueService: QueueService, + private systemQueueProcessorsService: SystemQueueProcessorsService, + private objectStorageQueueProcessorsService: ObjectStorageQueueProcessorsService, + private dbQueueProcessorsService: DbQueueProcessorsService, + private webhookDeliverProcessorService: WebhookDeliverProcessorService, + private endedPollNotificationProcessorService: EndedPollNotificationProcessorService, + private deliverProcessorService: DeliverProcessorService, + private inboxProcessorService: InboxProcessorService, + ) { + this.logger = this.queueLoggerService.logger; + } + + public start() { + function renderError(e: Error): any { + return { + stack: e.stack, + message: e.message, + name: e.name, + }; + } + + const systemLogger = this.logger.createSubLogger('system'); + const deliverLogger = this.logger.createSubLogger('deliver'); + const webhookLogger = this.logger.createSubLogger('webhook'); + const inboxLogger = this.logger.createSubLogger('inbox'); + const dbLogger = this.logger.createSubLogger('db'); + const objectStorageLogger = this.logger.createSubLogger('objectStorage'); + + this.queueService.systemQueue + .on('waiting', (jobId) => systemLogger.debug(`waiting id=${jobId}`)) + .on('active', (job) => systemLogger.debug(`active id=${job.id}`)) + .on('completed', (job, result) => systemLogger.debug(`completed(${result}) id=${job.id}`)) + .on('failed', (job, err) => systemLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) + .on('error', (job: any, err: Error) => systemLogger.error(`error ${err}`, { job, e: renderError(err) })) + .on('stalled', (job) => systemLogger.warn(`stalled id=${job.id}`)); + + this.queueService.deliverQueue + .on('waiting', (jobId) => deliverLogger.debug(`waiting id=${jobId}`)) + .on('active', (job) => deliverLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) + .on('completed', (job, result) => deliverLogger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) + .on('failed', (job, err) => deliverLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job.data.to}`)) + .on('error', (job: any, err: Error) => deliverLogger.error(`error ${err}`, { job, e: renderError(err) })) + .on('stalled', (job) => deliverLogger.warn(`stalled ${getJobInfo(job)} to=${job.data.to}`)); + + this.queueService.inboxQueue + .on('waiting', (jobId) => inboxLogger.debug(`waiting id=${jobId}`)) + .on('active', (job) => inboxLogger.debug(`active ${getJobInfo(job, true)}`)) + .on('completed', (job, result) => inboxLogger.debug(`completed(${result}) ${getJobInfo(job, true)}`)) + .on('failed', (job, err) => inboxLogger.warn(`failed(${err}) ${getJobInfo(job)} activity=${job.data.activity ? job.data.activity.id : 'none'}`, { job, e: renderError(err) })) + .on('error', (job: any, err: Error) => inboxLogger.error(`error ${err}`, { job, e: renderError(err) })) + .on('stalled', (job) => inboxLogger.warn(`stalled ${getJobInfo(job)} activity=${job.data.activity ? job.data.activity.id : 'none'}`)); + + this.queueService.dbQueue + .on('waiting', (jobId) => dbLogger.debug(`waiting id=${jobId}`)) + .on('active', (job) => dbLogger.debug(`active id=${job.id}`)) + .on('completed', (job, result) => dbLogger.debug(`completed(${result}) id=${job.id}`)) + .on('failed', (job, err) => dbLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) + .on('error', (job: any, err: Error) => dbLogger.error(`error ${err}`, { job, e: renderError(err) })) + .on('stalled', (job) => dbLogger.warn(`stalled id=${job.id}`)); + + this.queueService.objectStorageQueue + .on('waiting', (jobId) => objectStorageLogger.debug(`waiting id=${jobId}`)) + .on('active', (job) => objectStorageLogger.debug(`active id=${job.id}`)) + .on('completed', (job, result) => objectStorageLogger.debug(`completed(${result}) id=${job.id}`)) + .on('failed', (job, err) => objectStorageLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) + .on('error', (job: any, err: Error) => objectStorageLogger.error(`error ${err}`, { job, e: renderError(err) })) + .on('stalled', (job) => objectStorageLogger.warn(`stalled id=${job.id}`)); + + this.queueService.webhookDeliverQueue + .on('waiting', (jobId) => webhookLogger.debug(`waiting id=${jobId}`)) + .on('active', (job) => webhookLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) + .on('completed', (job, result) => webhookLogger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) + .on('failed', (job, err) => webhookLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job.data.to}`)) + .on('error', (job: any, err: Error) => webhookLogger.error(`error ${err}`, { job, e: renderError(err) })) + .on('stalled', (job) => webhookLogger.warn(`stalled ${getJobInfo(job)} to=${job.data.to}`)); + + this.queueService.deliverQueue.process(this.config.deliverJobConcurrency ?? 128, (job) => this.deliverProcessorService.process(job)); + this.queueService.inboxQueue.process(this.config.inboxJobConcurrency ?? 16, (job) => this.inboxProcessorService.process(job)); + this.queueService.endedPollNotificationQueue.process((job, done) => this.endedPollNotificationProcessorService.process(job, done)); + this.queueService.webhookDeliverQueue.process(64, (job) => this.webhookDeliverProcessorService.process(job)); + this.dbQueueProcessorsService.start(this.queueService.dbQueue); + this.objectStorageQueueProcessorsService.start(this.queueService.objectStorageQueue); + + this.queueService.systemQueue.add('tickCharts', { + }, { + repeat: { cron: '55 * * * *' }, + removeOnComplete: true, + }); + + this.queueService.systemQueue.add('resyncCharts', { + }, { + repeat: { cron: '0 0 * * *' }, + removeOnComplete: true, + }); + + this.queueService.systemQueue.add('cleanCharts', { + }, { + repeat: { cron: '0 0 * * *' }, + removeOnComplete: true, + }); + + this.queueService.systemQueue.add('clean', { + }, { + repeat: { cron: '0 0 * * *' }, + removeOnComplete: true, + }); + + this.queueService.systemQueue.add('checkExpiredMutings', { + }, { + repeat: { cron: '*/5 * * * *' }, + removeOnComplete: true, + }); + + this.systemQueueProcessorsService.start(this.queueService.systemQueue); + } +} diff --git a/packages/backend/src/queue/SystemQueueProcessorsService.ts b/packages/backend/src/queue/SystemQueueProcessorsService.ts new file mode 100644 index 000000000..7c227296e --- /dev/null +++ b/packages/backend/src/queue/SystemQueueProcessorsService.ts @@ -0,0 +1,38 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js'; +import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js'; +import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js'; +import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js'; +import { CleanProcessorService } from './processors/CleanProcessorService.js'; +import type Bull from 'bull'; + +@Injectable() +export class SystemQueueProcessorsService { + constructor( + @Inject(DI.config) + private config: Config, + + private tickChartsProcessorService: TickChartsProcessorService, + private resyncChartsProcessorService: ResyncChartsProcessorService, + private cleanChartsProcessorService: CleanChartsProcessorService, + private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService, + private cleanProcessorService: CleanProcessorService, + ) { + } + + public start(dbQueue: Bull.Queue>) { + const jobs = { + tickCharts: (job, done) => this.tickChartsProcessorService.process(job, done), + resyncCharts: (job, done) => this.resyncChartsProcessorService.process(job, done), + cleanCharts: (job, done) => this.cleanChartsProcessorService.process(job, done), + checkExpiredMutings: (job, done) => this.checkExpiredMutingsProcessorService.process(job, done), + clean: (job, done) => this.cleanProcessorService.process(job, done), + } as Record> | Bull.ProcessPromiseFunction>>; + + for (const [k, v] of Object.entries(jobs)) { + dbQueue.process(k, v); + } + } +} diff --git a/packages/backend/src/queue/index.ts b/packages/backend/src/queue/index.ts deleted file mode 100644 index ebb3a77ca..000000000 --- a/packages/backend/src/queue/index.ts +++ /dev/null @@ -1,342 +0,0 @@ -import httpSignature from '@peertube/http-signature'; -import { v4 as uuid } from 'uuid'; - -import config from '@/config/index.js'; -import { DriveFile } from '@/models/entities/drive-file.js'; -import { IActivity } from '@/remote/activitypub/type.js'; -import { Webhook, webhookEventTypes } from '@/models/entities/webhook.js'; -import { envOption } from '../env.js'; - -import processDeliver from './processors/deliver.js'; -import processInbox from './processors/inbox.js'; -import processDb from './processors/db/index.js'; -import processObjectStorage from './processors/object-storage/index.js'; -import processSystemQueue from './processors/system/index.js'; -import processWebhookDeliver from './processors/webhook-deliver.js'; -import { endedPollNotification } from './processors/ended-poll-notification.js'; -import { queueLogger } from './logger.js'; -import { getJobInfo } from './get-job-info.js'; -import { systemQueue, dbQueue, deliverQueue, inboxQueue, objectStorageQueue, endedPollNotificationQueue, webhookDeliverQueue } from './queues.js'; -import { ThinUser } from './types.js'; - -function renderError(e: Error): any { - return { - stack: e.stack, - message: e.message, - name: e.name, - }; -} - -const systemLogger = queueLogger.createSubLogger('system'); -const deliverLogger = queueLogger.createSubLogger('deliver'); -const webhookLogger = queueLogger.createSubLogger('webhook'); -const inboxLogger = queueLogger.createSubLogger('inbox'); -const dbLogger = queueLogger.createSubLogger('db'); -const objectStorageLogger = queueLogger.createSubLogger('objectStorage'); - -systemQueue - .on('waiting', (jobId) => systemLogger.debug(`waiting id=${jobId}`)) - .on('active', (job) => systemLogger.debug(`active id=${job.id}`)) - .on('completed', (job, result) => systemLogger.debug(`completed(${result}) id=${job.id}`)) - .on('failed', (job, err) => systemLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) - .on('error', (job: any, err: Error) => systemLogger.error(`error ${err}`, { job, e: renderError(err) })) - .on('stalled', (job) => systemLogger.warn(`stalled id=${job.id}`)); - -deliverQueue - .on('waiting', (jobId) => deliverLogger.debug(`waiting id=${jobId}`)) - .on('active', (job) => deliverLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) - .on('completed', (job, result) => deliverLogger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) - .on('failed', (job, err) => deliverLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job.data.to}`)) - .on('error', (job: any, err: Error) => deliverLogger.error(`error ${err}`, { job, e: renderError(err) })) - .on('stalled', (job) => deliverLogger.warn(`stalled ${getJobInfo(job)} to=${job.data.to}`)); - -inboxQueue - .on('waiting', (jobId) => inboxLogger.debug(`waiting id=${jobId}`)) - .on('active', (job) => inboxLogger.debug(`active ${getJobInfo(job, true)}`)) - .on('completed', (job, result) => inboxLogger.debug(`completed(${result}) ${getJobInfo(job, true)}`)) - .on('failed', (job, err) => inboxLogger.warn(`failed(${err}) ${getJobInfo(job)} activity=${job.data.activity ? job.data.activity.id : 'none'}`, { job, e: renderError(err) })) - .on('error', (job: any, err: Error) => inboxLogger.error(`error ${err}`, { job, e: renderError(err) })) - .on('stalled', (job) => inboxLogger.warn(`stalled ${getJobInfo(job)} activity=${job.data.activity ? job.data.activity.id : 'none'}`)); - -dbQueue - .on('waiting', (jobId) => dbLogger.debug(`waiting id=${jobId}`)) - .on('active', (job) => dbLogger.debug(`active id=${job.id}`)) - .on('completed', (job, result) => dbLogger.debug(`completed(${result}) id=${job.id}`)) - .on('failed', (job, err) => dbLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) - .on('error', (job: any, err: Error) => dbLogger.error(`error ${err}`, { job, e: renderError(err) })) - .on('stalled', (job) => dbLogger.warn(`stalled id=${job.id}`)); - -objectStorageQueue - .on('waiting', (jobId) => objectStorageLogger.debug(`waiting id=${jobId}`)) - .on('active', (job) => objectStorageLogger.debug(`active id=${job.id}`)) - .on('completed', (job, result) => objectStorageLogger.debug(`completed(${result}) id=${job.id}`)) - .on('failed', (job, err) => objectStorageLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) - .on('error', (job: any, err: Error) => objectStorageLogger.error(`error ${err}`, { job, e: renderError(err) })) - .on('stalled', (job) => objectStorageLogger.warn(`stalled id=${job.id}`)); - -webhookDeliverQueue - .on('waiting', (jobId) => webhookLogger.debug(`waiting id=${jobId}`)) - .on('active', (job) => webhookLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) - .on('completed', (job, result) => webhookLogger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) - .on('failed', (job, err) => webhookLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job.data.to}`)) - .on('error', (job: any, err: Error) => webhookLogger.error(`error ${err}`, { job, e: renderError(err) })) - .on('stalled', (job) => webhookLogger.warn(`stalled ${getJobInfo(job)} to=${job.data.to}`)); - -export function deliver(user: ThinUser, content: unknown, to: string | null) { - if (content == null) return null; - if (to == null) return null; - - const data = { - user: { - id: user.id, - }, - content, - to, - }; - - return deliverQueue.add(data, { - attempts: config.deliverJobMaxAttempts || 12, - timeout: 1 * 60 * 1000, // 1min - backoff: { - type: 'apBackoff', - }, - removeOnComplete: true, - removeOnFail: true, - }); -} - -export function inbox(activity: IActivity, signature: httpSignature.IParsedSignature) { - const data = { - activity: activity, - signature, - }; - - return inboxQueue.add(data, { - attempts: config.inboxJobMaxAttempts || 8, - timeout: 5 * 60 * 1000, // 5min - backoff: { - type: 'apBackoff', - }, - removeOnComplete: true, - removeOnFail: true, - }); -} - -export function createDeleteDriveFilesJob(user: ThinUser) { - return dbQueue.add('deleteDriveFiles', { - user: user, - }, { - removeOnComplete: true, - removeOnFail: true, - }); -} - -export function createExportCustomEmojisJob(user: ThinUser) { - return dbQueue.add('exportCustomEmojis', { - user: user, - }, { - removeOnComplete: true, - removeOnFail: true, - }); -} - -export function createExportNotesJob(user: ThinUser) { - return dbQueue.add('exportNotes', { - user: user, - }, { - removeOnComplete: true, - removeOnFail: true, - }); -} - -export function createExportFollowingJob(user: ThinUser, excludeMuting = false, excludeInactive = false) { - return dbQueue.add('exportFollowing', { - user: user, - excludeMuting, - excludeInactive, - }, { - removeOnComplete: true, - removeOnFail: true, - }); -} - -export function createExportMuteJob(user: ThinUser) { - return dbQueue.add('exportMute', { - user: user, - }, { - removeOnComplete: true, - removeOnFail: true, - }); -} - -export function createExportBlockingJob(user: ThinUser) { - return dbQueue.add('exportBlocking', { - user: user, - }, { - removeOnComplete: true, - removeOnFail: true, - }); -} - -export function createExportUserListsJob(user: ThinUser) { - return dbQueue.add('exportUserLists', { - user: user, - }, { - removeOnComplete: true, - removeOnFail: true, - }); -} - -export function createImportFollowingJob(user: ThinUser, fileId: DriveFile['id']) { - return dbQueue.add('importFollowing', { - user: user, - fileId: fileId, - }, { - removeOnComplete: true, - removeOnFail: true, - }); -} - -export function createImportMutingJob(user: ThinUser, fileId: DriveFile['id']) { - return dbQueue.add('importMuting', { - user: user, - fileId: fileId, - }, { - removeOnComplete: true, - removeOnFail: true, - }); -} - -export function createImportBlockingJob(user: ThinUser, fileId: DriveFile['id']) { - return dbQueue.add('importBlocking', { - user: user, - fileId: fileId, - }, { - removeOnComplete: true, - removeOnFail: true, - }); -} - -export function createImportUserListsJob(user: ThinUser, fileId: DriveFile['id']) { - return dbQueue.add('importUserLists', { - user: user, - fileId: fileId, - }, { - removeOnComplete: true, - removeOnFail: true, - }); -} - -export function createImportCustomEmojisJob(user: ThinUser, fileId: DriveFile['id']) { - return dbQueue.add('importCustomEmojis', { - user: user, - fileId: fileId, - }, { - removeOnComplete: true, - removeOnFail: true, - }); -} - -export function createDeleteAccountJob(user: ThinUser, opts: { soft?: boolean; } = {}) { - return dbQueue.add('deleteAccount', { - user: user, - soft: opts.soft, - }, { - removeOnComplete: true, - removeOnFail: true, - }); -} - -export function createDeleteObjectStorageFileJob(key: string) { - return objectStorageQueue.add('deleteFile', { - key: key, - }, { - removeOnComplete: true, - removeOnFail: true, - }); -} - -export function createCleanRemoteFilesJob() { - return objectStorageQueue.add('cleanRemoteFiles', {}, { - removeOnComplete: true, - removeOnFail: true, - }); -} - -export function webhookDeliver(webhook: Webhook, type: typeof webhookEventTypes[number], content: unknown) { - const data = { - type, - content, - webhookId: webhook.id, - userId: webhook.userId, - to: webhook.url, - secret: webhook.secret, - createdAt: Date.now(), - eventId: uuid(), - }; - - return webhookDeliverQueue.add(data, { - attempts: 4, - timeout: 1 * 60 * 1000, // 1min - backoff: { - type: 'apBackoff', - }, - removeOnComplete: true, - removeOnFail: true, - }); -} - -export default function() { - if (envOption.onlyServer) return; - - deliverQueue.process(config.deliverJobConcurrency || 128, processDeliver); - inboxQueue.process(config.inboxJobConcurrency || 16, processInbox); - endedPollNotificationQueue.process(endedPollNotification); - webhookDeliverQueue.process(64, processWebhookDeliver); - processDb(dbQueue); - processObjectStorage(objectStorageQueue); - - systemQueue.add('tickCharts', { - }, { - repeat: { cron: '55 * * * *' }, - removeOnComplete: true, - }); - - systemQueue.add('resyncCharts', { - }, { - repeat: { cron: '0 0 * * *' }, - removeOnComplete: true, - }); - - systemQueue.add('cleanCharts', { - }, { - repeat: { cron: '0 0 * * *' }, - removeOnComplete: true, - }); - - systemQueue.add('clean', { - }, { - repeat: { cron: '0 0 * * *' }, - removeOnComplete: true, - }); - - systemQueue.add('checkExpiredMutings', { - }, { - repeat: { cron: '*/5 * * * *' }, - removeOnComplete: true, - }); - - processSystemQueue(systemQueue); -} - -export function destroy() { - deliverQueue.once('cleaned', (jobs, status) => { - deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); - }); - deliverQueue.clean(0, 'delayed'); - - inboxQueue.once('cleaned', (jobs, status) => { - inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); - }); - inboxQueue.clean(0, 'delayed'); -} diff --git a/packages/backend/src/queue/initialize.ts b/packages/backend/src/queue/initialize.ts deleted file mode 100644 index eef4080af..000000000 --- a/packages/backend/src/queue/initialize.ts +++ /dev/null @@ -1,34 +0,0 @@ -import Bull from 'bull'; -import config from '@/config/index.js'; - -export function initialize(name: string, limitPerSec = -1) { - return new Bull(name, { - redis: { - port: config.redis.port, - host: config.redis.host, - family: config.redis.family == null ? 0 : config.redis.family, - password: config.redis.pass, - db: config.redis.db || 0, - }, - prefix: config.redis.prefix ? `${config.redis.prefix}:queue` : 'queue', - limiter: limitPerSec > 0 ? { - max: limitPerSec, - duration: 1000, - } : undefined, - settings: { - backoffStrategies: { - apBackoff, - }, - }, - }); -} - -// ref. https://github.com/misskey-dev/misskey/pull/7635#issue-971097019 -function apBackoff(attemptsMade: number, err: Error) { - const baseDelay = 60 * 1000; // 1min - const maxBackoff = 8 * 60 * 60 * 1000; // 8hours - let backoff = (Math.pow(2, attemptsMade) - 1) * baseDelay; - backoff = Math.min(backoff, maxBackoff); - backoff += Math.round(backoff * Math.random() * 0.2); - return backoff; -} diff --git a/packages/backend/src/queue/logger.ts b/packages/backend/src/queue/logger.ts deleted file mode 100644 index 2843a3c26..000000000 --- a/packages/backend/src/queue/logger.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Logger from '@/services/logger.js'; - -export const queueLogger = new Logger('queue', 'orange'); diff --git a/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts b/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts new file mode 100644 index 000000000..17337837a --- /dev/null +++ b/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts @@ -0,0 +1,50 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { In, MoreThan } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { MutingsRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type Bull from 'bull'; + +@Injectable() +export class CheckExpiredMutingsProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + private globalEventService: GlobalEventService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('check-expired-mutings'); + } + + public async process(job: Bull.Job>, done: () => void): Promise { + this.logger.info('Checking expired mutings...'); + + const expired = await this.mutingsRepository.createQueryBuilder('muting') + .where('muting.expiresAt IS NOT NULL') + .andWhere('muting.expiresAt < :now', { now: new Date() }) + .innerJoinAndSelect('muting.mutee', 'mutee') + .getMany(); + + if (expired.length > 0) { + await this.mutingsRepository.delete({ + id: In(expired.map(m => m.id)), + }); + + for (const m of expired) { + this.globalEventService.publishUserEvent(m.muterId, 'unmute', m.mutee!); + } + } + + this.logger.succ('All expired mutings checked.'); + done(); + } +} diff --git a/packages/backend/src/queue/processors/CleanChartsProcessorService.ts b/packages/backend/src/queue/processors/CleanChartsProcessorService.ts new file mode 100644 index 000000000..6f2fb8dea --- /dev/null +++ b/packages/backend/src/queue/processors/CleanChartsProcessorService.ts @@ -0,0 +1,68 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { In, MoreThan } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import FederationChart from '@/core/chart/charts/federation.js'; +import NotesChart from '@/core/chart/charts/notes.js'; +import UsersChart from '@/core/chart/charts/users.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; +import InstanceChart from '@/core/chart/charts/instance.js'; +import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js'; +import DriveChart from '@/core/chart/charts/drive.js'; +import PerUserReactionsChart from '@/core/chart/charts/per-user-reactions.js'; +import HashtagChart from '@/core/chart/charts/hashtag.js'; +import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; +import PerUserDriveChart from '@/core/chart/charts/per-user-drive.js'; +import ApRequestChart from '@/core/chart/charts/ap-request.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type Bull from 'bull'; + +@Injectable() +export class CleanChartsProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + private federationChart: FederationChart, + private notesChart: NotesChart, + private usersChart: UsersChart, + private activeUsersChart: ActiveUsersChart, + private instanceChart: InstanceChart, + private perUserNotesChart: PerUserNotesChart, + private driveChart: DriveChart, + private perUserReactionsChart: PerUserReactionsChart, + private hashtagChart: HashtagChart, + private perUserFollowingChart: PerUserFollowingChart, + private perUserDriveChart: PerUserDriveChart, + private apRequestChart: ApRequestChart, + + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('clean-charts'); + } + + public async process(job: Bull.Job>, done: () => void): Promise { + this.logger.info('Clean charts...'); + + await Promise.all([ + this.federationChart.clean(), + this.notesChart.clean(), + this.usersChart.clean(), + this.activeUsersChart.clean(), + this.instanceChart.clean(), + this.perUserNotesChart.clean(), + this.driveChart.clean(), + this.perUserReactionsChart.clean(), + this.hashtagChart.clean(), + this.perUserFollowingChart.clean(), + this.perUserDriveChart.clean(), + this.apRequestChart.clean(), + ]); + + this.logger.succ('All charts successfully cleaned.'); + done(); + } +} diff --git a/packages/backend/src/queue/processors/CleanProcessorService.ts b/packages/backend/src/queue/processors/CleanProcessorService.ts new file mode 100644 index 000000000..830f0c56b --- /dev/null +++ b/packages/backend/src/queue/processors/CleanProcessorService.ts @@ -0,0 +1,36 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { In, LessThan, MoreThan } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { UserIpsRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type Bull from 'bull'; + +@Injectable() +export class CleanProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.userIpsRepository) + private userIpsRepository: UserIpsRepository, + + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('clean'); + } + + public async process(job: Bull.Job>, done: () => void): Promise { + this.logger.info('Cleaning...'); + + this.userIpsRepository.delete({ + createdAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90))), + }); + + this.logger.succ('Cleaned.'); + done(); + } +} diff --git a/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts b/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts new file mode 100644 index 000000000..c3c68be1b --- /dev/null +++ b/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts @@ -0,0 +1,69 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull, MoreThan, Not } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { DriveFilesRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import { DriveService } from '@/core/DriveService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type Bull from 'bull'; + +@Injectable() +export class CleanRemoteFilesProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private driveService: DriveService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('clean-remote-files'); + } + + public async process(job: Bull.Job>, done: () => void): Promise { + this.logger.info('Deleting cached remote files...'); + + let deletedCount = 0; + let cursor: any = null; + + while (true) { + const files = await this.driveFilesRepository.find({ + where: { + userHost: Not(IsNull()), + isLink: false, + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 8, + order: { + id: 1, + }, + }); + + if (files.length === 0) { + job.progress(100); + break; + } + + cursor = files[files.length - 1].id; + + await Promise.all(files.map(file => this.driveService.deleteFileSync(file, true))); + + deletedCount += 8; + + const total = await this.driveFilesRepository.countBy({ + userHost: Not(IsNull()), + isLink: false, + }); + + job.progress(deletedCount / total); + } + + this.logger.succ('All cahced remote files has been deleted.'); + done(); + } +} diff --git a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts new file mode 100644 index 000000000..ab82f87d5 --- /dev/null +++ b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts @@ -0,0 +1,124 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { MoreThan } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { DriveFilesRepository, UserProfilesRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import { DriveService } from '@/core/DriveService.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import type { Note } from '@/models/entities/Note.js'; +import { EmailService } from '@/core/EmailService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type Bull from 'bull'; +import type { DbUserDeleteJobData } from '../types.js'; + +@Injectable() +export class DeleteAccountProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private driveService: DriveService, + private emailService: EmailService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('delete-account'); + } + + public async process(job: Bull.Job): Promise { + this.logger.info(`Deleting account of ${job.data.user.id} ...`); + + const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); + if (user == null) { + return; + } + + { // Delete notes + let cursor: Note['id'] | null = null; + + while (true) { + const notes = await this.notesRepository.find({ + where: { + userId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 100, + order: { + id: 1, + }, + }) as Note[]; + + if (notes.length === 0) { + break; + } + + cursor = notes[notes.length - 1].id; + + await this.notesRepository.delete(notes.map(note => note.id)); + } + + this.logger.succ('All of notes deleted'); + } + + { // Delete files + let cursor: DriveFile['id'] | null = null; + + while (true) { + const files = await this.driveFilesRepository.find({ + where: { + userId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 10, + order: { + id: 1, + }, + }) as DriveFile[]; + + if (files.length === 0) { + break; + } + + cursor = files[files.length - 1].id; + + for (const file of files) { + await this.driveService.deleteFileSync(file); + } + } + + this.logger.succ('All of files deleted'); + } + + { // Send email notification + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + if (profile.email && profile.emailVerified) { + this.emailService.sendEmail(profile.email, 'Account deleted', + 'Your account has been deleted.', + 'Your account has been deleted.'); + } + } + + // soft指定されている場合は物理削除しない + if (job.data.soft) { + // nop + } else { + await this.usersRepository.delete(job.data.user.id); + } + + return 'Account deleted'; + } +} diff --git a/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts b/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts new file mode 100644 index 000000000..430fbf19e --- /dev/null +++ b/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts @@ -0,0 +1,78 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { MoreThan } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { UsersRepository, DriveFilesRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import { DriveService } from '@/core/DriveService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type Bull from 'bull'; +import type { DbUserJobData } from '../types.js'; + +@Injectable() +export class DeleteDriveFilesProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private driveService: DriveService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('delete-drive-files'); + } + + public async process(job: Bull.Job, done: () => void): Promise { + this.logger.info(`Deleting drive files of ${job.data.user.id} ...`); + + const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); + if (user == null) { + done(); + return; + } + + let deletedCount = 0; + let cursor: any = null; + + while (true) { + const files = await this.driveFilesRepository.find({ + where: { + userId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 100, + order: { + id: 1, + }, + }); + + if (files.length === 0) { + job.progress(100); + break; + } + + cursor = files[files.length - 1].id; + + for (const file of files) { + await this.driveService.deleteFileSync(file); + deletedCount++; + } + + const total = await this.driveFilesRepository.countBy({ + userId: user.id, + }); + + job.progress(deletedCount / total); + } + + this.logger.succ(`All drive files (${deletedCount}) of ${user.id} has been deleted.`); + done(); + } +} diff --git a/packages/backend/src/queue/processors/DeleteFileProcessorService.ts b/packages/backend/src/queue/processors/DeleteFileProcessorService.ts new file mode 100644 index 000000000..72923b80a --- /dev/null +++ b/packages/backend/src/queue/processors/DeleteFileProcessorService.ts @@ -0,0 +1,31 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import { DriveService } from '@/core/DriveService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type Bull from 'bull'; +import type { ObjectStorageFileJobData } from '../types.js'; + +@Injectable() +export class DeleteFileProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + private driveService: DriveService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('delete-file'); + } + + public async process(job: Bull.Job): Promise { + const key: string = job.data.key; + + await this.driveService.deleteObjectStorageFile(key); + + return 'Success'; + } +} diff --git a/packages/backend/src/queue/processors/DeliverProcessorService.ts b/packages/backend/src/queue/processors/DeliverProcessorService.ts new file mode 100644 index 000000000..1bf51c1bc --- /dev/null +++ b/packages/backend/src/queue/processors/DeliverProcessorService.ts @@ -0,0 +1,130 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { MoreThan } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { DriveFilesRepository, InstancesRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import { MetaService } from '@/core/MetaService.js'; +import { ApRequestService } from '@/core/remote/activitypub/ApRequestService.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; +import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; +import { Cache } from '@/misc/cache.js'; +import type { Instance } from '@/models/entities/Instance.js'; +import InstanceChart from '@/core/chart/charts/instance.js'; +import ApRequestChart from '@/core/chart/charts/ap-request.js'; +import FederationChart from '@/core/chart/charts/federation.js'; +import { StatusError } from '@/misc/status-error.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type Bull from 'bull'; +import type { DeliverJobData } from '../types.js'; + +@Injectable() +export class DeliverProcessorService { + private logger: Logger; + private suspendedHostsCache: Cache; + private latest: string | null; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private metaService: MetaService, + private utilityService: UtilityService, + private federatedInstanceService: FederatedInstanceService, + private fetchInstanceMetadataService: FetchInstanceMetadataService, + private apRequestService: ApRequestService, + private instanceChart: InstanceChart, + private apRequestChart: ApRequestChart, + private federationChart: FederationChart, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('deliver'); + this.suspendedHostsCache = new Cache(1000 * 60 * 60); + this.latest = null; + } + + public async process(job: Bull.Job): Promise { + const { host } = new URL(job.data.to); + + // ブロックしてたら中断 + const meta = await this.metaService.fetch(); + if (meta.blockedHosts.includes(this.utilityService.toPuny(host))) { + return 'skip (blocked)'; + } + + // isSuspendedなら中断 + let suspendedHosts = this.suspendedHostsCache.get(null); + if (suspendedHosts == null) { + suspendedHosts = await this.instancesRepository.find({ + where: { + isSuspended: true, + }, + }); + this.suspendedHostsCache.set(null, suspendedHosts); + } + if (suspendedHosts.map(x => x.host).includes(this.utilityService.toPuny(host))) { + return 'skip (suspended)'; + } + + try { + if (this.latest !== (this.latest = JSON.stringify(job.data.content, null, 2))) { + this.logger.debug(`delivering ${this.latest}`); + } + + await this.apRequestService.signedPost(job.data.user, job.data.to, job.data.content); + + // Update stats + this.federatedInstanceService.registerOrFetchInstanceDoc(host).then(i => { + this.instancesRepository.update(i.id, { + latestRequestSentAt: new Date(), + latestStatus: 200, + lastCommunicatedAt: new Date(), + isNotResponding: false, + }); + + this.fetchInstanceMetadataService.fetchInstanceMetadata(i); + + this.instanceChart.requestSent(i.host, true); + this.apRequestChart.deliverSucc(); + this.federationChart.deliverd(i.host, true); + }); + + return 'Success'; + } catch (res) { + // Update stats + this.federatedInstanceService.registerOrFetchInstanceDoc(host).then(i => { + this.instancesRepository.update(i.id, { + latestRequestSentAt: new Date(), + latestStatus: res instanceof StatusError ? res.statusCode : null, + isNotResponding: true, + }); + + this.instanceChart.requestSent(i.host, false); + this.apRequestChart.deliverFail(); + this.federationChart.deliverd(i.host, false); + }); + + if (res instanceof StatusError) { + // 4xx + if (res.isClientError) { + // HTTPステータスコード4xxはクライアントエラーであり、それはつまり + // 何回再送しても成功することはないということなのでエラーにはしないでおく + return `${res.statusCode} ${res.statusMessage}`; + } + + // 5xx etc. + throw `${res.statusCode} ${res.statusMessage}`; + } else { + // DNS error, socket error, timeout ... + throw res; + } + } + } +} diff --git a/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts b/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts new file mode 100644 index 000000000..3e55a351a --- /dev/null +++ b/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts @@ -0,0 +1,56 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { MoreThan } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { PollVotesRepository, NotesRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type Bull from 'bull'; +import type { EndedPollNotificationJobData } from '../types.js'; + +@Injectable() +export class EndedPollNotificationProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.pollVotesRepository) + private pollVotesRepository: PollVotesRepository, + + private createNotificationService: CreateNotificationService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('ended-poll-notification'); + } + + public async process(job: Bull.Job, done: () => void): Promise { + const note = await this.notesRepository.findOneBy({ id: job.data.noteId }); + if (note == null || !note.hasPoll) { + done(); + return; + } + + const votes = await this.pollVotesRepository.createQueryBuilder('vote') + .select('vote.userId') + .where('vote.noteId = :noteId', { noteId: note.id }) + .innerJoinAndSelect('vote.user', 'user') + .andWhere('user.host IS NULL') + .getMany(); + + const userIds = [...new Set([note.userId, ...votes.map(v => v.userId)])]; + + for (const userId of userIds) { + this.createNotificationService.createNotification(userId, 'pollEnded', { + noteId: note.id, + }); + } + + done(); + } +} diff --git a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts new file mode 100644 index 000000000..cbc483698 --- /dev/null +++ b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts @@ -0,0 +1,117 @@ +import * as fs from 'node:fs'; +import { Inject, Injectable } from '@nestjs/common'; +import { MoreThan } from 'typeorm'; +import { format as dateFormat } from 'date-fns'; +import { DI } from '@/di-symbols.js'; +import { UsersRepository, BlockingsRepository } from '@/models/index.js'; +import type { DriveFilesRepository, UserProfilesRepository, NotesRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import { DriveService } from '@/core/DriveService.js'; +import { createTemp } from '@/misc/create-temp.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type Bull from 'bull'; +import type { DbUserJobData } from '../types.js'; + +@Injectable() +export class ExportBlockingProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + private utilityService: UtilityService, + private driveService: DriveService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('export-blocking'); + } + + public async process(job: Bull.Job, done: () => void): Promise { + this.logger.info(`Exporting blocking of ${job.data.user.id} ...`); + + const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); + if (user == null) { + done(); + return; + } + + // Create temp file + const [path, cleanup] = await createTemp(); + + this.logger.info(`Temp file is ${path}`); + + try { + const stream = fs.createWriteStream(path, { flags: 'a' }); + + let exportedCount = 0; + let cursor: any = null; + + while (true) { + const blockings = await this.blockingsRepository.find({ + where: { + blockerId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 100, + order: { + id: 1, + }, + }); + + if (blockings.length === 0) { + job.progress(100); + break; + } + + cursor = blockings[blockings.length - 1].id; + + for (const block of blockings) { + const u = await this.usersRepository.findOneBy({ id: block.blockeeId }); + if (u == null) { + exportedCount++; continue; + } + + const content = this.utilityService.getFullApAccount(u.username, u.host); + await new Promise((res, rej) => { + stream.write(content + '\n', err => { + if (err) { + this.logger.error(err); + rej(err); + } else { + res(); + } + }); + }); + exportedCount++; + } + + const total = await this.blockingsRepository.countBy({ + blockerId: user.id, + }); + + job.progress(exportedCount / total); + } + + stream.end(); + this.logger.succ(`Exported to: ${path}`); + + const fileName = 'blocking-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; + const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); + + this.logger.succ(`Exported to: ${driveFile.id}`); + } finally { + cleanup(); + } + + done(); + } +} diff --git a/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts new file mode 100644 index 000000000..c49a47561 --- /dev/null +++ b/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts @@ -0,0 +1,135 @@ +import * as fs from 'node:fs'; +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull, MoreThan } from 'typeorm'; +import { format as dateFormat } from 'date-fns'; +import { ulid } from 'ulid'; +import mime from 'mime-types'; +import archiver from 'archiver'; +import { DI } from '@/di-symbols.js'; +import { EmojisRepository, UsersRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import { DriveService } from '@/core/DriveService.js'; +import { createTemp, createTempDir } from '@/misc/create-temp.js'; +import { DownloadService } from '@/core/DownloadService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type Bull from 'bull'; + +@Injectable() +export class ExportCustomEmojisProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, + + private driveService: DriveService, + private downloadService: DownloadService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('export-custom-emojis'); + } + + public async process(job: Bull.Job, done: () => void): Promise { + this.logger.info('Exporting custom emojis ...'); + + const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); + if (user == null) { + done(); + return; + } + + const [path, cleanup] = await createTempDir(); + + this.logger.info(`Temp dir is ${path}`); + + const metaPath = path + '/meta.json'; + + fs.writeFileSync(metaPath, '', 'utf-8'); + + const metaStream = fs.createWriteStream(metaPath, { flags: 'a' }); + + const writeMeta = (text: string): Promise => { + return new Promise((res, rej) => { + metaStream.write(text, err => { + if (err) { + this.logger.error(err); + rej(err); + } else { + res(); + } + }); + }); + }; + + await writeMeta(`{"metaVersion":2,"host":"${this.config.host}","exportedAt":"${new Date().toString()}","emojis":[`); + + const customEmojis = await this.emojisRepository.find({ + where: { + host: IsNull(), + }, + order: { + id: 'ASC', + }, + }); + + for (const emoji of customEmojis) { + const ext = mime.extension(emoji.type); + const fileName = emoji.name + (ext ? '.' + ext : ''); + const emojiPath = path + '/' + fileName; + fs.writeFileSync(emojiPath, '', 'binary'); + let downloaded = false; + + try { + await this.downloadService.downloadUrl(emoji.originalUrl, emojiPath); + downloaded = true; + } catch (e) { // TODO: 何度か再試行 + this.logger.error(e instanceof Error ? e : new Error(e as string)); + } + + if (!downloaded) { + fs.unlinkSync(emojiPath); + } + + const content = JSON.stringify({ + fileName: fileName, + downloaded: downloaded, + emoji: emoji, + }); + const isFirst = customEmojis.indexOf(emoji) === 0; + + await writeMeta(isFirst ? content : ',\n' + content); + } + + await writeMeta(']}'); + + metaStream.end(); + + // Create archive + const [archivePath, archiveCleanup] = await createTemp(); + const archiveStream = fs.createWriteStream(archivePath); + const archive = archiver('zip', { + zlib: { level: 0 }, + }); + archiveStream.on('close', async () => { + this.logger.succ(`Exported to: ${archivePath}`); + + const fileName = 'custom-emojis-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.zip'; + const driveFile = await this.driveService.addFile({ user, path: archivePath, name: fileName, force: true }); + + this.logger.succ(`Exported to: ${driveFile.id}`); + cleanup(); + archiveCleanup(); + done(); + }); + archive.pipe(archiveStream); + archive.directory(path, false); + archive.finalize(); + } +} diff --git a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts new file mode 100644 index 000000000..4c6162432 --- /dev/null +++ b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts @@ -0,0 +1,120 @@ +import * as fs from 'node:fs'; +import { Inject, Injectable } from '@nestjs/common'; +import { In, MoreThan, Not } from 'typeorm'; +import { format as dateFormat } from 'date-fns'; +import { DI } from '@/di-symbols.js'; +import { FollowingsRepository, MutingsRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import { DriveService } from '@/core/DriveService.js'; +import { createTemp } from '@/misc/create-temp.js'; +import type { Following } from '@/models/entities/Following.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type Bull from 'bull'; +import type { DbUserJobData } from '../types.js'; + +@Injectable() +export class ExportFollowingProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + private utilityService: UtilityService, + private driveService: DriveService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('export-following'); + } + + public async process(job: Bull.Job, done: () => void): Promise { + this.logger.info(`Exporting following of ${job.data.user.id} ...`); + + const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); + if (user == null) { + done(); + return; + } + + // Create temp file + const [path, cleanup] = await createTemp(); + + this.logger.info(`Temp file is ${path}`); + + try { + const stream = fs.createWriteStream(path, { flags: 'a' }); + + let cursor: Following['id'] | null = null; + + const mutings = job.data.excludeMuting ? await this.mutingsRepository.findBy({ + muterId: user.id, + }) : []; + + while (true) { + const followings = await this.followingsRepository.find({ + where: { + followerId: user.id, + ...(mutings.length > 0 ? { followeeId: Not(In(mutings.map(x => x.muteeId))) } : {}), + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 100, + order: { + id: 1, + }, + }) as Following[]; + + if (followings.length === 0) { + break; + } + + cursor = followings[followings.length - 1].id; + + for (const following of followings) { + const u = await this.usersRepository.findOneBy({ id: following.followeeId }); + if (u == null) { + continue; + } + + if (job.data.excludeInactive && u.updatedAt && (Date.now() - u.updatedAt.getTime() > 1000 * 60 * 60 * 24 * 90)) { + continue; + } + + const content = this.utilityService.getFullApAccount(u.username, u.host); + await new Promise((res, rej) => { + stream.write(content + '\n', err => { + if (err) { + this.logger.error(err); + rej(err); + } else { + res(); + } + }); + }); + } + } + + stream.end(); + this.logger.succ(`Exported to: ${path}`); + + const fileName = 'following-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; + const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); + + this.logger.succ(`Exported to: ${driveFile.id}`); + } finally { + cleanup(); + } + + done(); + } +} diff --git a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts new file mode 100644 index 000000000..7781d2787 --- /dev/null +++ b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts @@ -0,0 +1,120 @@ +import * as fs from 'node:fs'; +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull, MoreThan } from 'typeorm'; +import { format as dateFormat } from 'date-fns'; +import { DI } from '@/di-symbols.js'; +import { MutingsRepository, UsersRepository, BlockingsRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import { DriveService } from '@/core/DriveService.js'; +import { createTemp } from '@/misc/create-temp.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type Bull from 'bull'; +import type { DbUserJobData } from '../types.js'; + +@Injectable() +export class ExportMutingProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + private utilityService: UtilityService, + private driveService: DriveService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('export-muting'); + } + + public async process(job: Bull.Job, done: () => void): Promise { + this.logger.info(`Exporting muting of ${job.data.user.id} ...`); + + const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); + if (user == null) { + done(); + return; + } + + // Create temp file + const [path, cleanup] = await createTemp(); + + this.logger.info(`Temp file is ${path}`); + + try { + const stream = fs.createWriteStream(path, { flags: 'a' }); + + let exportedCount = 0; + let cursor: any = null; + + while (true) { + const mutes = await this.mutingsRepository.find({ + where: { + muterId: user.id, + expiresAt: IsNull(), + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 100, + order: { + id: 1, + }, + }); + + if (mutes.length === 0) { + job.progress(100); + break; + } + + cursor = mutes[mutes.length - 1].id; + + for (const mute of mutes) { + const u = await this.usersRepository.findOneBy({ id: mute.muteeId }); + if (u == null) { + exportedCount++; continue; + } + + const content = this.utilityService.getFullApAccount(u.username, u.host); + await new Promise((res, rej) => { + stream.write(content + '\n', err => { + if (err) { + this.logger.error(err); + rej(err); + } else { + res(); + } + }); + }); + exportedCount++; + } + + const total = await this.mutingsRepository.countBy({ + muterId: user.id, + }); + + job.progress(exportedCount / total); + } + + stream.end(); + this.logger.succ(`Exported to: ${path}`); + + const fileName = 'mute-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; + const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); + + this.logger.succ(`Exported to: ${driveFile.id}`); + } finally { + cleanup(); + } + + done(); + } +} diff --git a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts new file mode 100644 index 000000000..62b3a53c4 --- /dev/null +++ b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts @@ -0,0 +1,143 @@ +import * as fs from 'node:fs'; +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull, MoreThan } from 'typeorm'; +import { format as dateFormat } from 'date-fns'; +import { DI } from '@/di-symbols.js'; +import { NotesRepository, PollsRepository, UsersRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import { DriveService } from '@/core/DriveService.js'; +import { createTemp } from '@/misc/create-temp.js'; +import type { Poll } from '@/models/entities/Poll.js'; +import type { Note } from '@/models/entities/Note.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type Bull from 'bull'; +import type { DbUserJobData } from '../types.js'; + +@Injectable() +export class ExportNotesProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.pollsRepository) + private pollsRepository: PollsRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private driveService: DriveService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('export-notes'); + } + + public async process(job: Bull.Job, done: () => void): Promise { + this.logger.info(`Exporting notes of ${job.data.user.id} ...`); + + const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); + if (user == null) { + done(); + return; + } + + // Create temp file + const [path, cleanup] = await createTemp(); + + this.logger.info(`Temp file is ${path}`); + + try { + const stream = fs.createWriteStream(path, { flags: 'a' }); + + const write = (text: string): Promise => { + return new Promise((res, rej) => { + stream.write(text, err => { + if (err) { + this.logger.error(err); + rej(err); + } else { + res(); + } + }); + }); + }; + + await write('['); + + let exportedNotesCount = 0; + let cursor: Note['id'] | null = null; + + while (true) { + const notes = await this.notesRepository.find({ + where: { + userId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 100, + order: { + id: 1, + }, + }) as Note[]; + + if (notes.length === 0) { + job.progress(100); + break; + } + + cursor = notes[notes.length - 1].id; + + for (const note of notes) { + let poll: Poll | undefined; + if (note.hasPoll) { + poll = await this.pollsRepository.findOneByOrFail({ noteId: note.id }); + } + const content = JSON.stringify(serialize(note, poll)); + const isFirst = exportedNotesCount === 0; + await write(isFirst ? content : ',\n' + content); + exportedNotesCount++; + } + + const total = await this.notesRepository.countBy({ + userId: user.id, + }); + + job.progress(exportedNotesCount / total); + } + + await write(']'); + + stream.end(); + this.logger.succ(`Exported to: ${path}`); + + const fileName = 'notes-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json'; + const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); + + this.logger.succ(`Exported to: ${driveFile.id}`); + } finally { + cleanup(); + } + + done(); + } +} + +function serialize(note: Note, poll: Poll | null = null): Record { + return { + id: note.id, + text: note.text, + createdAt: note.createdAt, + fileIds: note.fileIds, + replyId: note.replyId, + renoteId: note.renoteId, + poll: poll, + cw: note.cw, + visibility: note.visibility, + visibleUserIds: note.visibleUserIds, + localOnly: note.localOnly, + }; +} diff --git a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts new file mode 100644 index 000000000..097835ac8 --- /dev/null +++ b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts @@ -0,0 +1,96 @@ +import * as fs from 'node:fs'; +import { Inject, Injectable } from '@nestjs/common'; +import { In, IsNull, MoreThan } from 'typeorm'; +import { format as dateFormat } from 'date-fns'; +import { DI } from '@/di-symbols.js'; +import { UserListJoiningsRepository, UserListsRepository, UsersRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import { DriveService } from '@/core/DriveService.js'; +import { createTemp } from '@/misc/create-temp.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type Bull from 'bull'; +import type { DbUserJobData } from '../types.js'; + +@Injectable() +export class ExportUserListsProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userListsRepository) + private userListsRepository: UserListsRepository, + + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, + + private utilityService: UtilityService, + private driveService: DriveService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('export-user-lists'); + } + + public async process(job: Bull.Job, done: () => void): Promise { + this.logger.info(`Exporting user lists of ${job.data.user.id} ...`); + + const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); + if (user == null) { + done(); + return; + } + + const lists = await this.userListsRepository.findBy({ + userId: user.id, + }); + + // Create temp file + const [path, cleanup] = await createTemp(); + + this.logger.info(`Temp file is ${path}`); + + try { + const stream = fs.createWriteStream(path, { flags: 'a' }); + + for (const list of lists) { + const joinings = await this.userListJoiningsRepository.findBy({ userListId: list.id }); + const users = await this.usersRepository.findBy({ + id: In(joinings.map(j => j.userId)), + }); + + for (const u of users) { + const acct = this.utilityService.getFullApAccount(u.username, u.host); + const content = `${list.name},${acct}`; + await new Promise((res, rej) => { + stream.write(content + '\n', err => { + if (err) { + this.logger.error(err); + rej(err); + } else { + res(); + } + }); + }); + } + } + + stream.end(); + this.logger.succ(`Exported to: ${path}`); + + const fileName = 'user-lists-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; + const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); + + this.logger.succ(`Exported to: ${driveFile.id}`); + } finally { + cleanup(); + } + + done(); + } +} diff --git a/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts b/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts new file mode 100644 index 000000000..44c8800a6 --- /dev/null +++ b/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts @@ -0,0 +1,102 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull, MoreThan } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { BlockingsRepository, DriveFilesRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import * as Acct from '@/misc/acct.js'; +import { ResolveUserService } from '@/core/remote/ResolveUserService.js'; +import { UserBlockingService } from '@/core/UserBlockingService.js'; +import { DownloadService } from '@/core/DownloadService.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type Bull from 'bull'; +import type { DbUserImportJobData } from '../types.js'; + +@Injectable() +export class ImportBlockingProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private utilityService: UtilityService, + private userBlockingService: UserBlockingService, + private resolveUserService: ResolveUserService, + private downloadService: DownloadService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('import-blocking'); + } + + public async process(job: Bull.Job, done: () => void): Promise { + this.logger.info(`Importing blocking of ${job.data.user.id} ...`); + + const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); + if (user == null) { + done(); + return; + } + + const file = await this.driveFilesRepository.findOneBy({ + id: job.data.fileId, + }); + if (file == null) { + done(); + return; + } + + const csv = await this.downloadService.downloadTextFile(file.url); + + let linenum = 0; + + for (const line of csv.trim().split('\n')) { + linenum++; + + try { + const acct = line.split(',')[0].trim(); + const { username, host } = Acct.parse(acct); + + let target = this.utilityService.isSelfHost(host!) ? await this.usersRepository.findOneBy({ + host: IsNull(), + usernameLower: username.toLowerCase(), + }) : await this.usersRepository.findOneBy({ + host: this.utilityService.toPuny(host!), + usernameLower: username.toLowerCase(), + }); + + if (host == null && target == null) continue; + + if (target == null) { + target = await this.resolveUserService.resolveUser(username, host); + } + + if (target == null) { + throw `cannot resolve user: @${username}@${host}`; + } + + // skip myself + if (target.id === job.data.user.id) continue; + + this.logger.info(`Block[${linenum}] ${target.id} ...`); + + await this.userBlockingService.block(user, target); + } catch (e) { + this.logger.warn(`Error in line:${linenum} ${e}`); + } + } + + this.logger.succ('Imported'); + done(); + } +} diff --git a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts new file mode 100644 index 000000000..4919fb2f7 --- /dev/null +++ b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts @@ -0,0 +1,110 @@ +import * as fs from 'node:fs'; +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull, MoreThan, DataSource } from 'typeorm'; +import unzipper from 'unzipper'; +import { DI } from '@/di-symbols.js'; +import { EmojisRepository, DriveFilesRepository, UsersRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { createTempDir } from '@/misc/create-temp.js'; +import { DriveService } from '@/core/DriveService.js'; +import { DownloadService } from '@/core/DownloadService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type Bull from 'bull'; +import type { DbUserImportJobData } from '../types.js'; + +// TODO: 名前衝突時の動作を選べるようにする +@Injectable() +export class ImportCustomEmojisProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, + + private customEmojiService: CustomEmojiService, + private driveService: DriveService, + private downloadService: DownloadService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('import-custom-emojis'); + } + + public async process(job: Bull.Job, done: () => void): Promise { + this.logger.info('Importing custom emojis ...'); + + const file = await this.driveFilesRepository.findOneBy({ + id: job.data.fileId, + }); + if (file == null) { + done(); + return; + } + + const [path, cleanup] = await createTempDir(); + + this.logger.info(`Temp dir is ${path}`); + + const destPath = path + '/emojis.zip'; + + try { + fs.writeFileSync(destPath, '', 'binary'); + await this.downloadService.downloadUrl(file.url, destPath); + } catch (e) { // TODO: 何度か再試行 + if (e instanceof Error || typeof e === 'string') { + this.logger.error(e); + } + throw e; + } + + const outputPath = path + '/emojis'; + const unzipStream = fs.createReadStream(destPath); + const extractor = unzipper.Extract({ path: outputPath }); + extractor.on('close', async () => { + const metaRaw = fs.readFileSync(outputPath + '/meta.json', 'utf-8'); + const meta = JSON.parse(metaRaw); + + for (const record of meta.emojis) { + if (!record.downloaded) continue; + const emojiInfo = record.emoji; + const emojiPath = outputPath + '/' + record.fileName; + await this.emojisRepository.delete({ + name: emojiInfo.name, + }); + const driveFile = await this.driveService.addFile({ + user: null, + path: emojiPath, + name: record.fileName, + force: true, + }); + await this.customEmojiService.add({ + name: emojiInfo.name, + category: emojiInfo.category, + host: null, + aliases: emojiInfo.aliases, + driveFile, + }); + } + + cleanup(); + + this.logger.succ('Imported'); + done(); + }); + unzipStream.pipe(extractor); + this.logger.succ(`Unzipping to ${outputPath}`); + } +} diff --git a/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts b/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts new file mode 100644 index 000000000..5e49678d0 --- /dev/null +++ b/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts @@ -0,0 +1,99 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull, MoreThan } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { DriveFilesRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import * as Acct from '@/misc/acct.js'; +import { ResolveUserService } from '@/core/remote/ResolveUserService.js'; +import { DownloadService } from '@/core/DownloadService.js'; +import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type Bull from 'bull'; +import type { DbUserImportJobData } from '../types.js'; + +@Injectable() +export class ImportFollowingProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private utilityService: UtilityService, + private userFollowingService: UserFollowingService, + private resolveUserService: ResolveUserService, + private downloadService: DownloadService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('import-following'); + } + + public async process(job: Bull.Job, done: () => void): Promise { + this.logger.info(`Importing following of ${job.data.user.id} ...`); + + const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); + if (user == null) { + done(); + return; + } + + const file = await this.driveFilesRepository.findOneBy({ + id: job.data.fileId, + }); + if (file == null) { + done(); + return; + } + + const csv = await this.downloadService.downloadTextFile(file.url); + + let linenum = 0; + + for (const line of csv.trim().split('\n')) { + linenum++; + + try { + const acct = line.split(',')[0].trim(); + const { username, host } = Acct.parse(acct); + + let target = this.utilityService.isSelfHost(host!) ? await this.usersRepository.findOneBy({ + host: IsNull(), + usernameLower: username.toLowerCase(), + }) : await this.usersRepository.findOneBy({ + host: this.utilityService.toPuny(host!), + usernameLower: username.toLowerCase(), + }); + + if (host == null && target == null) continue; + + if (target == null) { + target = await this.resolveUserService.resolveUser(username, host); + } + + if (target == null) { + throw `cannot resolve user: @${username}@${host}`; + } + + // skip myself + if (target.id === job.data.user.id) continue; + + this.logger.info(`Follow[${linenum}] ${target.id} ...`); + + this.userFollowingService.follow(user, target); + } catch (e) { + this.logger.warn(`Error in line:${linenum} ${e}`); + } + } + + this.logger.succ('Imported'); + done(); + } +} diff --git a/packages/backend/src/queue/processors/ImportMutingProcessorService.ts b/packages/backend/src/queue/processors/ImportMutingProcessorService.ts new file mode 100644 index 000000000..c613c7e74 --- /dev/null +++ b/packages/backend/src/queue/processors/ImportMutingProcessorService.ts @@ -0,0 +1,100 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull, MoreThan } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { DriveFilesRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import * as Acct from '@/misc/acct.js'; +import { ResolveUserService } from '@/core/remote/ResolveUserService.js'; +import { DownloadService } from '@/core/DownloadService.js'; +import type { UserFollowingService } from '@/core/UserFollowingService.js'; +import { UserMutingService } from '@/core/UserMutingService.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type Bull from 'bull'; +import type { DbUserImportJobData } from '../types.js'; + +@Injectable() +export class ImportMutingProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private utilityService: UtilityService, + private userMutingService: UserMutingService, + private resolveUserService: ResolveUserService, + private downloadService: DownloadService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('import-muting'); + } + + public async process(job: Bull.Job, done: () => void): Promise { + this.logger.info(`Importing muting of ${job.data.user.id} ...`); + + const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); + if (user == null) { + done(); + return; + } + + const file = await this.driveFilesRepository.findOneBy({ + id: job.data.fileId, + }); + if (file == null) { + done(); + return; + } + + const csv = await this.downloadService.downloadTextFile(file.url); + + let linenum = 0; + + for (const line of csv.trim().split('\n')) { + linenum++; + + try { + const acct = line.split(',')[0].trim(); + const { username, host } = Acct.parse(acct); + + let target = this.utilityService.isSelfHost(host!) ? await this.usersRepository.findOneBy({ + host: IsNull(), + usernameLower: username.toLowerCase(), + }) : await this.usersRepository.findOneBy({ + host: this.utilityService.toPuny(host!), + usernameLower: username.toLowerCase(), + }); + + if (host == null && target == null) continue; + + if (target == null) { + target = await this.resolveUserService.resolveUser(username, host); + } + + if (target == null) { + throw `cannot resolve user: @${username}@${host}`; + } + + // skip myself + if (target.id === job.data.user.id) continue; + + this.logger.info(`Mute[${linenum}] ${target.id} ...`); + + await this.userMutingService.mute(user, target); + } catch (e) { + this.logger.warn(`Error in line:${linenum} ${e}`); + } + } + + this.logger.succ('Imported'); + done(); + } +} diff --git a/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts b/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts new file mode 100644 index 000000000..96c862e5c --- /dev/null +++ b/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts @@ -0,0 +1,112 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull, MoreThan } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { DriveFilesRepository, UserListJoiningsRepository, UserListsRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import * as Acct from '@/misc/acct.js'; +import { ResolveUserService } from '@/core/remote/ResolveUserService.js'; +import { DownloadService } from '@/core/DownloadService.js'; +import { UserListService } from '@/core/UserListService.js'; +import { IdService } from '@/core/IdService.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type Bull from 'bull'; +import type { DbUserImportJobData } from '../types.js'; + +@Injectable() +export class ImportUserListsProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + @Inject(DI.userListsRepository) + private userListsRepository: UserListsRepository, + + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, + + private utilityService: UtilityService, + private idService: IdService, + private userListService: UserListService, + private resolveUserService: ResolveUserService, + private downloadService: DownloadService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('import-user-lists'); + } + + public async process(job: Bull.Job, done: () => void): Promise { + this.logger.info(`Importing user lists of ${job.data.user.id} ...`); + + const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); + if (user == null) { + done(); + return; + } + + const file = await this.driveFilesRepository.findOneBy({ + id: job.data.fileId, + }); + if (file == null) { + done(); + return; + } + + const csv = await this.downloadService.downloadTextFile(file.url); + + let linenum = 0; + + for (const line of csv.trim().split('\n')) { + linenum++; + + try { + const listName = line.split(',')[0].trim(); + const { username, host } = Acct.parse(line.split(',')[1].trim()); + + let list = await this.userListsRepository.findOneBy({ + userId: user.id, + name: listName, + }); + + if (list == null) { + list = await this.userListsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: user.id, + name: listName, + }).then(x => this.userListsRepository.findOneByOrFail(x.identifiers[0])); + } + + let target = this.utilityService.isSelfHost(host!) ? await this.usersRepository.findOneBy({ + host: IsNull(), + usernameLower: username.toLowerCase(), + }) : await this.usersRepository.findOneBy({ + host: this.utilityService.toPuny(host!), + usernameLower: username.toLowerCase(), + }); + + if (target == null) { + target = await this.resolveUserService.resolveUser(username, host); + } + + if (await this.userListJoiningsRepository.findOneBy({ userListId: list!.id, userId: target.id }) != null) continue; + + this.userListService.push(target, list!); + } catch (e) { + this.logger.warn(`Error in line:${linenum} ${e}`); + } + } + + this.logger.succ('Imported'); + done(); + } +} diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts new file mode 100644 index 000000000..4593b4fb6 --- /dev/null +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -0,0 +1,195 @@ +import { URL } from 'node:url'; +import { Inject, Injectable } from '@nestjs/common'; +import { MoreThan } from 'typeorm'; +import httpSignature from '@peertube/http-signature'; +import { DI } from '@/di-symbols.js'; +import { DriveFilesRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import { MetaService } from '@/core/MetaService.js'; +import { ApRequestService } from '@/core/remote/activitypub/ApRequestService.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; +import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; +import { Cache } from '@/misc/cache.js'; +import type { Instance } from '@/models/entities/Instance.js'; +import InstanceChart from '@/core/chart/charts/instance.js'; +import ApRequestChart from '@/core/chart/charts/ap-request.js'; +import FederationChart from '@/core/chart/charts/federation.js'; +import { getApId } from '@/core/remote/activitypub/type.js'; +import type { CacheableRemoteUser } from '@/models/entities/User.js'; +import type { UserPublickey } from '@/models/entities/UserPublickey.js'; +import { ApDbResolverService } from '@/core/remote/activitypub/ApDbResolverService.js'; +import { StatusError } from '@/misc/status-error.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { ApPersonService } from '@/core/remote/activitypub/models/ApPersonService.js'; +import { LdSignatureService } from '@/core/remote/activitypub/LdSignatureService.js'; +import { ApInboxService } from '@/core/remote/activitypub/ApInboxService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type Bull from 'bull'; +import type { DeliverJobData, InboxJobData } from '../types.js'; + +// ユーザーのinboxにアクティビティが届いた時の処理 +@Injectable() +export class InboxProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private utilityService: UtilityService, + private metaService: MetaService, + private apInboxService: ApInboxService, + private federatedInstanceService: FederatedInstanceService, + private fetchInstanceMetadataService: FetchInstanceMetadataService, + private ldSignatureService: LdSignatureService, + private apRequestService: ApRequestService, + private apPersonService: ApPersonService, + private apDbResolverService: ApDbResolverService, + private instanceChart: InstanceChart, + private apRequestChart: ApRequestChart, + private federationChart: FederationChart, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('inbox'); + } + + public async process(job: Bull.Job): Promise { + const signature = job.data.signature; // HTTP-signature + const activity = job.data.activity; + + //#region Log + const info = Object.assign({}, activity) as any; + delete info['@context']; + this.logger.debug(JSON.stringify(info, null, 2)); + //#endregion + + const host = this.utilityService.toPuny(new URL(signature.keyId).hostname); + + // ブロックしてたら中断 + const meta = await this.metaService.fetch(); + if (meta.blockedHosts.includes(host)) { + return `Blocked request: ${host}`; + } + + const keyIdLower = signature.keyId.toLowerCase(); + if (keyIdLower.startsWith('acct:')) { + return `Old keyId is no longer supported. ${keyIdLower}`; + } + + // HTTP-Signature keyIdを元にDBから取得 + let authUser: { + user: CacheableRemoteUser; + key: UserPublickey | null; + } | null = await this.apDbResolverService.getAuthUserFromKeyId(signature.keyId); + + // keyIdでわからなければ、activity.actorを元にDBから取得 || activity.actorを元にリモートから取得 + if (authUser == null) { + try { + authUser = await this.apDbResolverService.getAuthUserFromApId(getApId(activity.actor)); + } catch (err) { + // 対象が4xxならスキップ + if (err instanceof StatusError) { + if (err.isClientError) { + return `skip: Ignored deleted actors on both ends ${activity.actor} - ${err.statusCode}`; + } + throw `Error in actor ${activity.actor} - ${err.statusCode ?? err}`; + } + } + } + + // それでもわからなければ終了 + if (authUser == null) { + return 'skip: failed to resolve user'; + } + + // publicKey がなくても終了 + if (authUser.key == null) { + return 'skip: failed to resolve user publicKey'; + } + + // HTTP-Signatureの検証 + const httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem); + + // また、signatureのsignerは、activity.actorと一致する必要がある + if (!httpSignatureValidated || authUser.user.uri !== activity.actor) { + // 一致しなくても、でもLD-Signatureがありそうならそっちも見る + if (activity.signature) { + if (activity.signature.type !== 'RsaSignature2017') { + return `skip: unsupported LD-signature type ${activity.signature.type}`; + } + + // activity.signature.creator: https://example.oom/users/user#main-key + // みたいになっててUserを引っ張れば公開キーも入ることを期待する + if (activity.signature.creator) { + const candicate = activity.signature.creator.replace(/#.*/, ''); + await this.apPersonService.resolvePerson(candicate).catch(() => null); + } + + // keyIdからLD-Signatureのユーザーを取得 + authUser = await this.apDbResolverService.getAuthUserFromKeyId(activity.signature.creator); + if (authUser == null) { + return 'skip: LD-Signatureのユーザーが取得できませんでした'; + } + + if (authUser.key == null) { + return 'skip: LD-SignatureのユーザーはpublicKeyを持っていませんでした'; + } + + // LD-Signature検証 + const ldSignature = this.ldSignatureService.use(); + const verified = await ldSignature.verifyRsaSignature2017(activity, authUser.key.keyPem).catch(() => false); + if (!verified) { + return 'skip: LD-Signatureの検証に失敗しました'; + } + + // もう一度actorチェック + if (authUser.user.uri !== activity.actor) { + return `skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`; + } + + // ブロックしてたら中断 + const ldHost = this.utilityService.extractDbHost(authUser.user.uri); + if (meta.blockedHosts.includes(ldHost)) { + return `Blocked request: ${ldHost}`; + } + } else { + return `skip: http-signature verification failed and no LD-Signature. keyId=${signature.keyId}`; + } + } + + // activity.idがあればホストが署名者のホストであることを確認する + if (typeof activity.id === 'string') { + const signerHost = this.utilityService.extractDbHost(authUser.user.uri!); + const activityIdHost = this.utilityService.extractDbHost(activity.id); + if (signerHost !== activityIdHost) { + return `skip: signerHost(${signerHost}) !== activity.id host(${activityIdHost}`; + } + } + + // Update stats + this.federatedInstanceService.registerOrFetchInstanceDoc(authUser.user.host).then(i => { + this.instancesRepository.update(i.id, { + latestRequestReceivedAt: new Date(), + lastCommunicatedAt: new Date(), + isNotResponding: false, + }); + + this.fetchInstanceMetadataService.fetchInstanceMetadata(i); + + this.instanceChart.requestReceived(i.host); + this.apRequestChart.inbox(); + this.federationChart.inbox(i.host); + }); + + // アクティビティを処理 + await this.apInboxService.performActivity(authUser.user, activity); + return 'ok'; + } +} diff --git a/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts b/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts new file mode 100644 index 000000000..75d02d527 --- /dev/null +++ b/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts @@ -0,0 +1,61 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { In, MoreThan } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import FederationChart from '@/core/chart/charts/federation.js'; +import NotesChart from '@/core/chart/charts/notes.js'; +import UsersChart from '@/core/chart/charts/users.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; +import InstanceChart from '@/core/chart/charts/instance.js'; +import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js'; +import DriveChart from '@/core/chart/charts/drive.js'; +import PerUserReactionsChart from '@/core/chart/charts/per-user-reactions.js'; +import HashtagChart from '@/core/chart/charts/hashtag.js'; +import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; +import PerUserDriveChart from '@/core/chart/charts/per-user-drive.js'; +import ApRequestChart from '@/core/chart/charts/ap-request.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type Bull from 'bull'; + +@Injectable() +export class ResyncChartsProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + private federationChart: FederationChart, + private notesChart: NotesChart, + private usersChart: UsersChart, + private activeUsersChart: ActiveUsersChart, + private instanceChart: InstanceChart, + private perUserNotesChart: PerUserNotesChart, + private driveChart: DriveChart, + private perUserReactionsChart: PerUserReactionsChart, + private hashtagChart: HashtagChart, + private perUserFollowingChart: PerUserFollowingChart, + private perUserDriveChart: PerUserDriveChart, + private apRequestChart: ApRequestChart, + + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('resync-charts'); + } + + public async process(job: Bull.Job>, done: () => void): Promise { + this.logger.info('Resync charts...'); + + // TODO: ユーザーごとのチャートも更新する + // TODO: インスタンスごとのチャートも更新する + await Promise.all([ + this.driveChart.resync(), + this.notesChart.resync(), + this.usersChart.resync(), + ]); + + this.logger.succ('All charts successfully resynced.'); + done(); + } +} diff --git a/packages/backend/src/queue/processors/TickChartsProcessorService.ts b/packages/backend/src/queue/processors/TickChartsProcessorService.ts new file mode 100644 index 000000000..e16956df0 --- /dev/null +++ b/packages/backend/src/queue/processors/TickChartsProcessorService.ts @@ -0,0 +1,68 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { In, MoreThan } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import FederationChart from '@/core/chart/charts/federation.js'; +import NotesChart from '@/core/chart/charts/notes.js'; +import UsersChart from '@/core/chart/charts/users.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; +import InstanceChart from '@/core/chart/charts/instance.js'; +import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js'; +import DriveChart from '@/core/chart/charts/drive.js'; +import PerUserReactionsChart from '@/core/chart/charts/per-user-reactions.js'; +import HashtagChart from '@/core/chart/charts/hashtag.js'; +import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; +import PerUserDriveChart from '@/core/chart/charts/per-user-drive.js'; +import ApRequestChart from '@/core/chart/charts/ap-request.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type Bull from 'bull'; + +@Injectable() +export class TickChartsProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + private federationChart: FederationChart, + private notesChart: NotesChart, + private usersChart: UsersChart, + private activeUsersChart: ActiveUsersChart, + private instanceChart: InstanceChart, + private perUserNotesChart: PerUserNotesChart, + private driveChart: DriveChart, + private perUserReactionsChart: PerUserReactionsChart, + private hashtagChart: HashtagChart, + private perUserFollowingChart: PerUserFollowingChart, + private perUserDriveChart: PerUserDriveChart, + private apRequestChart: ApRequestChart, + + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('tick-charts'); + } + + public async process(job: Bull.Job>, done: () => void): Promise { + this.logger.info('Tick charts...'); + + await Promise.all([ + this.federationChart.tick(false), + this.notesChart.tick(false), + this.usersChart.tick(false), + this.activeUsersChart.tick(false), + this.instanceChart.tick(false), + this.perUserNotesChart.tick(false), + this.driveChart.tick(false), + this.perUserReactionsChart.tick(false), + this.hashtagChart.tick(false), + this.perUserFollowingChart.tick(false), + this.perUserDriveChart.tick(false), + this.apRequestChart.tick(false), + ]); + + this.logger.succ('All charts successfully ticked.'); + done(); + } +} diff --git a/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts b/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts new file mode 100644 index 000000000..27243be51 --- /dev/null +++ b/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts @@ -0,0 +1,79 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull, MoreThan } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { WebhooksRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { StatusError } from '@/misc/status-error.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type Bull from 'bull'; +import type { WebhookDeliverJobData } from '../types.js'; + +@Injectable() +export class WebhookDeliverProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.webhooksRepository) + private webhooksRepository: WebhooksRepository, + + private httpRequestService: HttpRequestService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('webhook'); + } + + public async process(job: Bull.Job): Promise { + try { + this.logger.debug(`delivering ${job.data.webhookId}`); + + const res = await this.httpRequestService.getResponse({ + url: job.data.to, + method: 'POST', + headers: { + 'User-Agent': 'Misskey-Hooks', + 'X-Misskey-Host': this.config.host, + 'X-Misskey-Hook-Id': job.data.webhookId, + 'X-Misskey-Hook-Secret': job.data.secret, + }, + body: JSON.stringify({ + hookId: job.data.webhookId, + userId: job.data.userId, + eventId: job.data.eventId, + createdAt: job.data.createdAt, + type: job.data.type, + body: job.data.content, + }), + }); + + this.webhooksRepository.update({ id: job.data.webhookId }, { + latestSentAt: new Date(), + latestStatus: res.status, + }); + + return 'Success'; + } catch (res) { + this.webhooksRepository.update({ id: job.data.webhookId }, { + latestSentAt: new Date(), + latestStatus: res instanceof StatusError ? res.statusCode : 1, + }); + + if (res instanceof StatusError) { + // 4xx + if (res.isClientError) { + return `${res.statusCode} ${res.statusMessage}`; + } + + // 5xx etc. + throw `${res.statusCode} ${res.statusMessage}`; + } else { + // DNS error, socket error, timeout ... + throw res; + } + } + } +} diff --git a/packages/backend/src/queue/processors/db/delete-account.ts b/packages/backend/src/queue/processors/db/delete-account.ts deleted file mode 100644 index c1657b4be..000000000 --- a/packages/backend/src/queue/processors/db/delete-account.ts +++ /dev/null @@ -1,94 +0,0 @@ -import Bull from 'bull'; -import { queueLogger } from '../../logger.js'; -import { DriveFiles, Notes, UserProfiles, Users } from '@/models/index.js'; -import { DbUserDeleteJobData } from '@/queue/types.js'; -import { Note } from '@/models/entities/note.js'; -import { DriveFile } from '@/models/entities/drive-file.js'; -import { MoreThan } from 'typeorm'; -import { deleteFileSync } from '@/services/drive/delete-file.js'; -import { sendEmail } from '@/services/send-email.js'; - -const logger = queueLogger.createSubLogger('delete-account'); - -export async function deleteAccount(job: Bull.Job): Promise { - logger.info(`Deleting account of ${job.data.user.id} ...`); - - const user = await Users.findOneBy({ id: job.data.user.id }); - if (user == null) { - return; - } - - { // Delete notes - let cursor: Note['id'] | null = null; - - while (true) { - const notes = await Notes.find({ - where: { - userId: user.id, - ...(cursor ? { id: MoreThan(cursor) } : {}), - }, - take: 100, - order: { - id: 1, - }, - }) as Note[]; - - if (notes.length === 0) { - break; - } - - cursor = notes[notes.length - 1].id; - - await Notes.delete(notes.map(note => note.id)); - } - - logger.succ(`All of notes deleted`); - } - - { // Delete files - let cursor: DriveFile['id'] | null = null; - - while (true) { - const files = await DriveFiles.find({ - where: { - userId: user.id, - ...(cursor ? { id: MoreThan(cursor) } : {}), - }, - take: 10, - order: { - id: 1, - }, - }) as DriveFile[]; - - if (files.length === 0) { - break; - } - - cursor = files[files.length - 1].id; - - for (const file of files) { - await deleteFileSync(file); - } - } - - logger.succ(`All of files deleted`); - } - - { // Send email notification - const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - if (profile.email && profile.emailVerified) { - sendEmail(profile.email, 'Account deleted', - `Your account has been deleted.`, - `Your account has been deleted.`); - } - } - - // soft指定されている場合は物理削除しない - if (job.data.soft) { - // nop - } else { - await Users.delete(job.data.user.id); - } - - return 'Account deleted'; -} diff --git a/packages/backend/src/queue/processors/db/delete-drive-files.ts b/packages/backend/src/queue/processors/db/delete-drive-files.ts deleted file mode 100644 index b3832d9f0..000000000 --- a/packages/backend/src/queue/processors/db/delete-drive-files.ts +++ /dev/null @@ -1,56 +0,0 @@ -import Bull from 'bull'; - -import { queueLogger } from '../../logger.js'; -import { deleteFileSync } from '@/services/drive/delete-file.js'; -import { Users, DriveFiles } from '@/models/index.js'; -import { MoreThan } from 'typeorm'; -import { DbUserJobData } from '@/queue/types.js'; - -const logger = queueLogger.createSubLogger('delete-drive-files'); - -export async function deleteDriveFiles(job: Bull.Job, done: any): Promise { - logger.info(`Deleting drive files of ${job.data.user.id} ...`); - - const user = await Users.findOneBy({ id: job.data.user.id }); - if (user == null) { - done(); - return; - } - - let deletedCount = 0; - let cursor: any = null; - - while (true) { - const files = await DriveFiles.find({ - where: { - userId: user.id, - ...(cursor ? { id: MoreThan(cursor) } : {}), - }, - take: 100, - order: { - id: 1, - }, - }); - - if (files.length === 0) { - job.progress(100); - break; - } - - cursor = files[files.length - 1].id; - - for (const file of files) { - await deleteFileSync(file); - deletedCount++; - } - - const total = await DriveFiles.countBy({ - userId: user.id, - }); - - job.progress(deletedCount / total); - } - - logger.succ(`All drive files (${deletedCount}) of ${user.id} has been deleted.`); - done(); -} diff --git a/packages/backend/src/queue/processors/db/export-blocking.ts b/packages/backend/src/queue/processors/db/export-blocking.ts deleted file mode 100644 index f5e0424a7..000000000 --- a/packages/backend/src/queue/processors/db/export-blocking.ts +++ /dev/null @@ -1,93 +0,0 @@ -import Bull from 'bull'; -import * as fs from 'node:fs'; - -import { queueLogger } from '../../logger.js'; -import { addFile } from '@/services/drive/add-file.js'; -import { format as dateFormat } from 'date-fns'; -import { getFullApAccount } from '@/misc/convert-host.js'; -import { createTemp } from '@/misc/create-temp.js'; -import { Users, Blockings } from '@/models/index.js'; -import { MoreThan } from 'typeorm'; -import { DbUserJobData } from '@/queue/types.js'; - -const logger = queueLogger.createSubLogger('export-blocking'); - -export async function exportBlocking(job: Bull.Job, done: any): Promise { - logger.info(`Exporting blocking of ${job.data.user.id} ...`); - - const user = await Users.findOneBy({ id: job.data.user.id }); - if (user == null) { - done(); - return; - } - - // Create temp file - const [path, cleanup] = await createTemp(); - - logger.info(`Temp file is ${path}`); - - try { - const stream = fs.createWriteStream(path, { flags: 'a' }); - - let exportedCount = 0; - let cursor: any = null; - - while (true) { - const blockings = await Blockings.find({ - where: { - blockerId: user.id, - ...(cursor ? { id: MoreThan(cursor) } : {}), - }, - take: 100, - order: { - id: 1, - }, - }); - - if (blockings.length === 0) { - job.progress(100); - break; - } - - cursor = blockings[blockings.length - 1].id; - - for (const block of blockings) { - const u = await Users.findOneBy({ id: block.blockeeId }); - if (u == null) { - exportedCount++; continue; - } - - const content = getFullApAccount(u.username, u.host); - await new Promise((res, rej) => { - stream.write(content + '\n', err => { - if (err) { - logger.error(err); - rej(err); - } else { - res(); - } - }); - }); - exportedCount++; - } - - const total = await Blockings.countBy({ - blockerId: user.id, - }); - - job.progress(exportedCount / total); - } - - stream.end(); - logger.succ(`Exported to: ${path}`); - - const fileName = 'blocking-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; - const driveFile = await addFile({ user, path, name: fileName, force: true }); - - logger.succ(`Exported to: ${driveFile.id}`); - } finally { - cleanup(); - } - - done(); -} diff --git a/packages/backend/src/queue/processors/db/export-custom-emojis.ts b/packages/backend/src/queue/processors/db/export-custom-emojis.ts deleted file mode 100644 index 3da887cda..000000000 --- a/packages/backend/src/queue/processors/db/export-custom-emojis.ts +++ /dev/null @@ -1,114 +0,0 @@ -import Bull from 'bull'; -import * as fs from 'node:fs'; - -import { ulid } from 'ulid'; -import mime from 'mime-types'; -import archiver from 'archiver'; -import { queueLogger } from '../../logger.js'; -import { addFile } from '@/services/drive/add-file.js'; -import { format as dateFormat } from 'date-fns'; -import { Users, Emojis } from '@/models/index.js'; -import { } from '@/queue/types.js'; -import { createTemp, createTempDir } from '@/misc/create-temp.js'; -import { downloadUrl } from '@/misc/download-url.js'; -import config from '@/config/index.js'; -import { IsNull } from 'typeorm'; - -const logger = queueLogger.createSubLogger('export-custom-emojis'); - -export async function exportCustomEmojis(job: Bull.Job, done: () => void): Promise { - logger.info(`Exporting custom emojis ...`); - - const user = await Users.findOneBy({ id: job.data.user.id }); - if (user == null) { - done(); - return; - } - - const [path, cleanup] = await createTempDir(); - - logger.info(`Temp dir is ${path}`); - - const metaPath = path + '/meta.json'; - - fs.writeFileSync(metaPath, '', 'utf-8'); - - const metaStream = fs.createWriteStream(metaPath, { flags: 'a' }); - - const writeMeta = (text: string): Promise => { - return new Promise((res, rej) => { - metaStream.write(text, err => { - if (err) { - logger.error(err); - rej(err); - } else { - res(); - } - }); - }); - }; - - await writeMeta(`{"metaVersion":2,"host":"${config.host}","exportedAt":"${new Date().toString()}","emojis":[`); - - const customEmojis = await Emojis.find({ - where: { - host: IsNull(), - }, - order: { - id: 'ASC', - }, - }); - - for (const emoji of customEmojis) { - const ext = mime.extension(emoji.type); - const fileName = emoji.name + (ext ? '.' + ext : ''); - const emojiPath = path + '/' + fileName; - fs.writeFileSync(emojiPath, '', 'binary'); - let downloaded = false; - - try { - await downloadUrl(emoji.originalUrl, emojiPath); - downloaded = true; - } catch (e) { // TODO: 何度か再試行 - logger.error(e instanceof Error ? e : new Error(e as string)); - } - - if (!downloaded) { - fs.unlinkSync(emojiPath); - } - - const content = JSON.stringify({ - fileName: fileName, - downloaded: downloaded, - emoji: emoji, - }); - const isFirst = customEmojis.indexOf(emoji) === 0; - - await writeMeta(isFirst ? content : ',\n' + content); - } - - await writeMeta(']}'); - - metaStream.end(); - - // Create archive - const [archivePath, archiveCleanup] = await createTemp(); - const archiveStream = fs.createWriteStream(archivePath); - const archive = archiver('zip', { - zlib: { level: 0 }, - }); - archiveStream.on('close', async () => { - logger.succ(`Exported to: ${archivePath}`); - - const fileName = 'custom-emojis-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.zip'; - const driveFile = await addFile({ user, path: archivePath, name: fileName, force: true }); - - logger.succ(`Exported to: ${driveFile.id}`); - cleanup(); - archiveCleanup(); - done(); - }); - archive.pipe(archiveStream); - archive.directory(path, false); - archive.finalize(); -} diff --git a/packages/backend/src/queue/processors/db/export-following.ts b/packages/backend/src/queue/processors/db/export-following.ts deleted file mode 100644 index 4ac165567..000000000 --- a/packages/backend/src/queue/processors/db/export-following.ts +++ /dev/null @@ -1,94 +0,0 @@ -import Bull from 'bull'; -import * as fs from 'node:fs'; - -import { queueLogger } from '../../logger.js'; -import { addFile } from '@/services/drive/add-file.js'; -import { format as dateFormat } from 'date-fns'; -import { getFullApAccount } from '@/misc/convert-host.js'; -import { createTemp } from '@/misc/create-temp.js'; -import { Users, Followings, Mutings } from '@/models/index.js'; -import { In, MoreThan, Not } from 'typeorm'; -import { DbUserJobData } from '@/queue/types.js'; -import { Following } from '@/models/entities/following.js'; - -const logger = queueLogger.createSubLogger('export-following'); - -export async function exportFollowing(job: Bull.Job, done: () => void): Promise { - logger.info(`Exporting following of ${job.data.user.id} ...`); - - const user = await Users.findOneBy({ id: job.data.user.id }); - if (user == null) { - done(); - return; - } - - // Create temp file - const [path, cleanup] = await createTemp(); - - logger.info(`Temp file is ${path}`); - - try { - const stream = fs.createWriteStream(path, { flags: 'a' }); - - let cursor: Following['id'] | null = null; - - const mutings = job.data.excludeMuting ? await Mutings.findBy({ - muterId: user.id, - }) : []; - - while (true) { - const followings = await Followings.find({ - where: { - followerId: user.id, - ...(mutings.length > 0 ? { followeeId: Not(In(mutings.map(x => x.muteeId))) } : {}), - ...(cursor ? { id: MoreThan(cursor) } : {}), - }, - take: 100, - order: { - id: 1, - }, - }) as Following[]; - - if (followings.length === 0) { - break; - } - - cursor = followings[followings.length - 1].id; - - for (const following of followings) { - const u = await Users.findOneBy({ id: following.followeeId }); - if (u == null) { - continue; - } - - if (job.data.excludeInactive && u.updatedAt && (Date.now() - u.updatedAt.getTime() > 1000 * 60 * 60 * 24 * 90)) { - continue; - } - - const content = getFullApAccount(u.username, u.host); - await new Promise((res, rej) => { - stream.write(content + '\n', err => { - if (err) { - logger.error(err); - rej(err); - } else { - res(); - } - }); - }); - } - } - - stream.end(); - logger.succ(`Exported to: ${path}`); - - const fileName = 'following-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; - const driveFile = await addFile({ user, path, name: fileName, force: true }); - - logger.succ(`Exported to: ${driveFile.id}`); - } finally { - cleanup(); - } - - done(); -} diff --git a/packages/backend/src/queue/processors/db/export-mute.ts b/packages/backend/src/queue/processors/db/export-mute.ts deleted file mode 100644 index 6a36cfa07..000000000 --- a/packages/backend/src/queue/processors/db/export-mute.ts +++ /dev/null @@ -1,94 +0,0 @@ -import Bull from 'bull'; -import * as fs from 'node:fs'; - -import { queueLogger } from '../../logger.js'; -import { addFile } from '@/services/drive/add-file.js'; -import { format as dateFormat } from 'date-fns'; -import { getFullApAccount } from '@/misc/convert-host.js'; -import { createTemp } from '@/misc/create-temp.js'; -import { Users, Mutings } from '@/models/index.js'; -import { IsNull, MoreThan } from 'typeorm'; -import { DbUserJobData } from '@/queue/types.js'; - -const logger = queueLogger.createSubLogger('export-mute'); - -export async function exportMute(job: Bull.Job, done: any): Promise { - logger.info(`Exporting mute of ${job.data.user.id} ...`); - - const user = await Users.findOneBy({ id: job.data.user.id }); - if (user == null) { - done(); - return; - } - - // Create temp file - const [path, cleanup] = await createTemp(); - - logger.info(`Temp file is ${path}`); - - try { - const stream = fs.createWriteStream(path, { flags: 'a' }); - - let exportedCount = 0; - let cursor: any = null; - - while (true) { - const mutes = await Mutings.find({ - where: { - muterId: user.id, - expiresAt: IsNull(), - ...(cursor ? { id: MoreThan(cursor) } : {}), - }, - take: 100, - order: { - id: 1, - }, - }); - - if (mutes.length === 0) { - job.progress(100); - break; - } - - cursor = mutes[mutes.length - 1].id; - - for (const mute of mutes) { - const u = await Users.findOneBy({ id: mute.muteeId }); - if (u == null) { - exportedCount++; continue; - } - - const content = getFullApAccount(u.username, u.host); - await new Promise((res, rej) => { - stream.write(content + '\n', err => { - if (err) { - logger.error(err); - rej(err); - } else { - res(); - } - }); - }); - exportedCount++; - } - - const total = await Mutings.countBy({ - muterId: user.id, - }); - - job.progress(exportedCount / total); - } - - stream.end(); - logger.succ(`Exported to: ${path}`); - - const fileName = 'mute-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; - const driveFile = await addFile({ user, path, name: fileName, force: true }); - - logger.succ(`Exported to: ${driveFile.id}`); - } finally { - cleanup(); - } - - done(); -} diff --git a/packages/backend/src/queue/processors/db/export-notes.ts b/packages/backend/src/queue/processors/db/export-notes.ts deleted file mode 100644 index 051fcdf38..000000000 --- a/packages/backend/src/queue/processors/db/export-notes.ts +++ /dev/null @@ -1,118 +0,0 @@ -import Bull from 'bull'; -import * as fs from 'node:fs'; - -import { queueLogger } from '../../logger.js'; -import { addFile } from '@/services/drive/add-file.js'; -import { format as dateFormat } from 'date-fns'; -import { Users, Notes, Polls } from '@/models/index.js'; -import { MoreThan } from 'typeorm'; -import { Note } from '@/models/entities/note.js'; -import { Poll } from '@/models/entities/poll.js'; -import { DbUserJobData } from '@/queue/types.js'; -import { createTemp } from '@/misc/create-temp.js'; - -const logger = queueLogger.createSubLogger('export-notes'); - -export async function exportNotes(job: Bull.Job, done: any): Promise { - logger.info(`Exporting notes of ${job.data.user.id} ...`); - - const user = await Users.findOneBy({ id: job.data.user.id }); - if (user == null) { - done(); - return; - } - - // Create temp file - const [path, cleanup] = await createTemp(); - - logger.info(`Temp file is ${path}`); - - try { - const stream = fs.createWriteStream(path, { flags: 'a' }); - - const write = (text: string): Promise => { - return new Promise((res, rej) => { - stream.write(text, err => { - if (err) { - logger.error(err); - rej(err); - } else { - res(); - } - }); - }); - }; - - await write('['); - - let exportedNotesCount = 0; - let cursor: Note['id'] | null = null; - - while (true) { - const notes = await Notes.find({ - where: { - userId: user.id, - ...(cursor ? { id: MoreThan(cursor) } : {}), - }, - take: 100, - order: { - id: 1, - }, - }) as Note[]; - - if (notes.length === 0) { - job.progress(100); - break; - } - - cursor = notes[notes.length - 1].id; - - for (const note of notes) { - let poll: Poll | undefined; - if (note.hasPoll) { - poll = await Polls.findOneByOrFail({ noteId: note.id }); - } - const content = JSON.stringify(serialize(note, poll)); - const isFirst = exportedNotesCount === 0; - await write(isFirst ? content : ',\n' + content); - exportedNotesCount++; - } - - const total = await Notes.countBy({ - userId: user.id, - }); - - job.progress(exportedNotesCount / total); - } - - await write(']'); - - stream.end(); - logger.succ(`Exported to: ${path}`); - - const fileName = 'notes-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json'; - const driveFile = await addFile({ user, path, name: fileName, force: true }); - - logger.succ(`Exported to: ${driveFile.id}`); - } finally { - cleanup(); - } - - done(); -} - -function serialize(note: Note, poll: Poll | null = null): Record { - return { - id: note.id, - text: note.text, - createdAt: note.createdAt, - fileIds: note.fileIds, - replyId: note.replyId, - renoteId: note.renoteId, - poll: poll, - cw: note.cw, - visibility: note.visibility, - visibleUserIds: note.visibleUserIds, - localOnly: note.localOnly, - }; -} diff --git a/packages/backend/src/queue/processors/db/export-user-lists.ts b/packages/backend/src/queue/processors/db/export-user-lists.ts deleted file mode 100644 index 71dd72df2..000000000 --- a/packages/backend/src/queue/processors/db/export-user-lists.ts +++ /dev/null @@ -1,70 +0,0 @@ -import Bull from 'bull'; -import * as fs from 'node:fs'; - -import { queueLogger } from '../../logger.js'; -import { addFile } from '@/services/drive/add-file.js'; -import { format as dateFormat } from 'date-fns'; -import { getFullApAccount } from '@/misc/convert-host.js'; -import { createTemp } from '@/misc/create-temp.js'; -import { Users, UserLists, UserListJoinings } from '@/models/index.js'; -import { In } from 'typeorm'; -import { DbUserJobData } from '@/queue/types.js'; - -const logger = queueLogger.createSubLogger('export-user-lists'); - -export async function exportUserLists(job: Bull.Job, done: any): Promise { - logger.info(`Exporting user lists of ${job.data.user.id} ...`); - - const user = await Users.findOneBy({ id: job.data.user.id }); - if (user == null) { - done(); - return; - } - - const lists = await UserLists.findBy({ - userId: user.id, - }); - - // Create temp file - const [path, cleanup] = await createTemp(); - - logger.info(`Temp file is ${path}`); - - try { - const stream = fs.createWriteStream(path, { flags: 'a' }); - - for (const list of lists) { - const joinings = await UserListJoinings.findBy({ userListId: list.id }); - const users = await Users.findBy({ - id: In(joinings.map(j => j.userId)), - }); - - for (const u of users) { - const acct = getFullApAccount(u.username, u.host); - const content = `${list.name},${acct}`; - await new Promise((res, rej) => { - stream.write(content + '\n', err => { - if (err) { - logger.error(err); - rej(err); - } else { - res(); - } - }); - }); - } - } - - stream.end(); - logger.succ(`Exported to: ${path}`); - - const fileName = 'user-lists-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; - const driveFile = await addFile({ user, path, name: fileName, force: true }); - - logger.succ(`Exported to: ${driveFile.id}`); - } finally { - cleanup(); - } - - done(); -} diff --git a/packages/backend/src/queue/processors/db/import-blocking.ts b/packages/backend/src/queue/processors/db/import-blocking.ts deleted file mode 100644 index 8bddf34bc..000000000 --- a/packages/backend/src/queue/processors/db/import-blocking.ts +++ /dev/null @@ -1,75 +0,0 @@ -import Bull from 'bull'; - -import { queueLogger } from '../../logger.js'; -import * as Acct from '@/misc/acct.js'; -import { resolveUser } from '@/remote/resolve-user.js'; -import { downloadTextFile } from '@/misc/download-text-file.js'; -import { isSelfHost, toPuny } from '@/misc/convert-host.js'; -import { Users, DriveFiles, Blockings } from '@/models/index.js'; -import { DbUserImportJobData } from '@/queue/types.js'; -import block from '@/services/blocking/create.js'; -import { IsNull } from 'typeorm'; - -const logger = queueLogger.createSubLogger('import-blocking'); - -export async function importBlocking(job: Bull.Job, done: any): Promise { - logger.info(`Importing blocking of ${job.data.user.id} ...`); - - const user = await Users.findOneBy({ id: job.data.user.id }); - if (user == null) { - done(); - return; - } - - const file = await DriveFiles.findOneBy({ - id: job.data.fileId, - }); - if (file == null) { - done(); - return; - } - - const csv = await downloadTextFile(file.url); - - let linenum = 0; - - for (const line of csv.trim().split('\n')) { - linenum++; - - try { - const acct = line.split(',')[0].trim(); - const { username, host } = Acct.parse(acct); - - let target = isSelfHost(host!) ? await Users.findOneBy({ - host: IsNull(), - usernameLower: username.toLowerCase(), - }) : await Users.findOneBy({ - host: toPuny(host!), - usernameLower: username.toLowerCase(), - }); - - if (host == null && target == null) continue; - - if (target == null) { - target = await resolveUser(username, host); - } - - if (target == null) { - throw `cannot resolve user: @${username}@${host}`; - } - - // skip myself - if (target.id === job.data.user.id) continue; - - logger.info(`Block[${linenum}] ${target.id} ...`); - - await block(user, target); - } catch (e) { - logger.warn(`Error in line:${linenum} ${e}`); - } - } - - logger.succ('Imported'); - done(); -} - diff --git a/packages/backend/src/queue/processors/db/import-custom-emojis.ts b/packages/backend/src/queue/processors/db/import-custom-emojis.ts deleted file mode 100644 index 64dfe8537..000000000 --- a/packages/backend/src/queue/processors/db/import-custom-emojis.ts +++ /dev/null @@ -1,81 +0,0 @@ -import Bull from 'bull'; -import * as fs from 'node:fs'; -import unzipper from 'unzipper'; - -import { queueLogger } from '../../logger.js'; -import { createTempDir } from '@/misc/create-temp.js'; -import { downloadUrl } from '@/misc/download-url.js'; -import { DriveFiles, Emojis } from '@/models/index.js'; -import { DbUserImportJobData } from '@/queue/types.js'; -import { addFile } from '@/services/drive/add-file.js'; -import { genId } from '@/misc/gen-id.js'; -import { db } from '@/db/postgre.js'; - -const logger = queueLogger.createSubLogger('import-custom-emojis'); - -// TODO: 名前衝突時の動作を選べるようにする -export async function importCustomEmojis(job: Bull.Job, done: any): Promise { - logger.info(`Importing custom emojis ...`); - - const file = await DriveFiles.findOneBy({ - id: job.data.fileId, - }); - if (file == null) { - done(); - return; - } - - const [path, cleanup] = await createTempDir(); - - logger.info(`Temp dir is ${path}`); - - const destPath = path + '/emojis.zip'; - - try { - fs.writeFileSync(destPath, '', 'binary'); - await downloadUrl(file.url, destPath); - } catch (e) { // TODO: 何度か再試行 - if (e instanceof Error || typeof e === 'string') { - logger.error(e); - } - throw e; - } - - const outputPath = path + '/emojis'; - const unzipStream = fs.createReadStream(destPath); - const extractor = unzipper.Extract({ path: outputPath }); - extractor.on('close', async () => { - const metaRaw = fs.readFileSync(outputPath + '/meta.json', 'utf-8'); - const meta = JSON.parse(metaRaw); - - for (const record of meta.emojis) { - if (!record.downloaded) continue; - const emojiInfo = record.emoji; - const emojiPath = outputPath + '/' + record.fileName; - await Emojis.delete({ - name: emojiInfo.name, - }); - const driveFile = await addFile({ user: null, path: emojiPath, name: record.fileName, force: true }); - const emoji = await Emojis.insert({ - id: genId(), - updatedAt: new Date(), - name: emojiInfo.name, - category: emojiInfo.category, - host: null, - aliases: emojiInfo.aliases, - originalUrl: driveFile.url, - publicUrl: driveFile.webpublicUrl ?? driveFile.url, - type: driveFile.webpublicType ?? driveFile.type, - }).then(x => Emojis.findOneByOrFail(x.identifiers[0])); - } - - await db.queryResultCache!.remove(['meta_emojis']); - - cleanup(); - - logger.succ('Imported'); - done(); - }); - unzipStream.pipe(extractor); - logger.succ(`Unzipping to ${outputPath}`); -} diff --git a/packages/backend/src/queue/processors/db/import-following.ts b/packages/backend/src/queue/processors/db/import-following.ts deleted file mode 100644 index 8ce2c367d..000000000 --- a/packages/backend/src/queue/processors/db/import-following.ts +++ /dev/null @@ -1,74 +0,0 @@ -import Bull from 'bull'; - -import { queueLogger } from '../../logger.js'; -import follow from '@/services/following/create.js'; -import * as Acct from '@/misc/acct.js'; -import { resolveUser } from '@/remote/resolve-user.js'; -import { downloadTextFile } from '@/misc/download-text-file.js'; -import { isSelfHost, toPuny } from '@/misc/convert-host.js'; -import { Users, DriveFiles } from '@/models/index.js'; -import { DbUserImportJobData } from '@/queue/types.js'; -import { IsNull } from 'typeorm'; - -const logger = queueLogger.createSubLogger('import-following'); - -export async function importFollowing(job: Bull.Job, done: any): Promise { - logger.info(`Importing following of ${job.data.user.id} ...`); - - const user = await Users.findOneBy({ id: job.data.user.id }); - if (user == null) { - done(); - return; - } - - const file = await DriveFiles.findOneBy({ - id: job.data.fileId, - }); - if (file == null) { - done(); - return; - } - - const csv = await downloadTextFile(file.url); - - let linenum = 0; - - for (const line of csv.trim().split('\n')) { - linenum++; - - try { - const acct = line.split(',')[0].trim(); - const { username, host } = Acct.parse(acct); - - let target = isSelfHost(host!) ? await Users.findOneBy({ - host: IsNull(), - usernameLower: username.toLowerCase(), - }) : await Users.findOneBy({ - host: toPuny(host!), - usernameLower: username.toLowerCase(), - }); - - if (host == null && target == null) continue; - - if (target == null) { - target = await resolveUser(username, host); - } - - if (target == null) { - throw `cannot resolve user: @${username}@${host}`; - } - - // skip myself - if (target.id === job.data.user.id) continue; - - logger.info(`Follow[${linenum}] ${target.id} ...`); - - follow(user, target); - } catch (e) { - logger.warn(`Error in line:${linenum} ${e}`); - } - } - - logger.succ('Imported'); - done(); -} diff --git a/packages/backend/src/queue/processors/db/import-muting.ts b/packages/backend/src/queue/processors/db/import-muting.ts deleted file mode 100644 index 8552b797b..000000000 --- a/packages/backend/src/queue/processors/db/import-muting.ts +++ /dev/null @@ -1,84 +0,0 @@ -import Bull from 'bull'; - -import { queueLogger } from '../../logger.js'; -import * as Acct from '@/misc/acct.js'; -import { resolveUser } from '@/remote/resolve-user.js'; -import { downloadTextFile } from '@/misc/download-text-file.js'; -import { isSelfHost, toPuny } from '@/misc/convert-host.js'; -import { Users, DriveFiles, Mutings } from '@/models/index.js'; -import { DbUserImportJobData } from '@/queue/types.js'; -import { User } from '@/models/entities/user.js'; -import { genId } from '@/misc/gen-id.js'; -import { IsNull } from 'typeorm'; - -const logger = queueLogger.createSubLogger('import-muting'); - -export async function importMuting(job: Bull.Job, done: any): Promise { - logger.info(`Importing muting of ${job.data.user.id} ...`); - - const user = await Users.findOneBy({ id: job.data.user.id }); - if (user == null) { - done(); - return; - } - - const file = await DriveFiles.findOneBy({ - id: job.data.fileId, - }); - if (file == null) { - done(); - return; - } - - const csv = await downloadTextFile(file.url); - - let linenum = 0; - - for (const line of csv.trim().split('\n')) { - linenum++; - - try { - const acct = line.split(',')[0].trim(); - const { username, host } = Acct.parse(acct); - - let target = isSelfHost(host!) ? await Users.findOneBy({ - host: IsNull(), - usernameLower: username.toLowerCase(), - }) : await Users.findOneBy({ - host: toPuny(host!), - usernameLower: username.toLowerCase(), - }); - - if (host == null && target == null) continue; - - if (target == null) { - target = await resolveUser(username, host); - } - - if (target == null) { - throw `cannot resolve user: @${username}@${host}`; - } - - // skip myself - if (target.id === job.data.user.id) continue; - - logger.info(`Mute[${linenum}] ${target.id} ...`); - - await mute(user, target); - } catch (e) { - logger.warn(`Error in line:${linenum} ${e}`); - } - } - - logger.succ('Imported'); - done(); -} - -async function mute(user: User, target: User) { - await Mutings.insert({ - id: genId(), - createdAt: new Date(), - muterId: user.id, - muteeId: target.id, - }); -} diff --git a/packages/backend/src/queue/processors/db/import-user-lists.ts b/packages/backend/src/queue/processors/db/import-user-lists.ts deleted file mode 100644 index 9919b7c53..000000000 --- a/packages/backend/src/queue/processors/db/import-user-lists.ts +++ /dev/null @@ -1,80 +0,0 @@ -import Bull from 'bull'; - -import { queueLogger } from '../../logger.js'; -import * as Acct from '@/misc/acct.js'; -import { resolveUser } from '@/remote/resolve-user.js'; -import { pushUserToUserList } from '@/services/user-list/push.js'; -import { downloadTextFile } from '@/misc/download-text-file.js'; -import { isSelfHost, toPuny } from '@/misc/convert-host.js'; -import { DriveFiles, Users, UserLists, UserListJoinings } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; -import { DbUserImportJobData } from '@/queue/types.js'; -import { IsNull } from 'typeorm'; - -const logger = queueLogger.createSubLogger('import-user-lists'); - -export async function importUserLists(job: Bull.Job, done: any): Promise { - logger.info(`Importing user lists of ${job.data.user.id} ...`); - - const user = await Users.findOneBy({ id: job.data.user.id }); - if (user == null) { - done(); - return; - } - - const file = await DriveFiles.findOneBy({ - id: job.data.fileId, - }); - if (file == null) { - done(); - return; - } - - const csv = await downloadTextFile(file.url); - - let linenum = 0; - - for (const line of csv.trim().split('\n')) { - linenum++; - - try { - const listName = line.split(',')[0].trim(); - const { username, host } = Acct.parse(line.split(',')[1].trim()); - - let list = await UserLists.findOneBy({ - userId: user.id, - name: listName, - }); - - if (list == null) { - list = await UserLists.insert({ - id: genId(), - createdAt: new Date(), - userId: user.id, - name: listName, - }).then(x => UserLists.findOneByOrFail(x.identifiers[0])); - } - - let target = isSelfHost(host!) ? await Users.findOneBy({ - host: IsNull(), - usernameLower: username.toLowerCase(), - }) : await Users.findOneBy({ - host: toPuny(host!), - usernameLower: username.toLowerCase(), - }); - - if (target == null) { - target = await resolveUser(username, host); - } - - if (await UserListJoinings.findOneBy({ userListId: list!.id, userId: target.id }) != null) continue; - - pushUserToUserList(target, list!); - } catch (e) { - logger.warn(`Error in line:${linenum} ${e}`); - } - } - - logger.succ('Imported'); - done(); -} diff --git a/packages/backend/src/queue/processors/db/index.ts b/packages/backend/src/queue/processors/db/index.ts deleted file mode 100644 index e91d56977..000000000 --- a/packages/backend/src/queue/processors/db/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -import Bull from 'bull'; -import { DbJobData } from '@/queue/types.js'; -import { deleteDriveFiles } from './delete-drive-files.js'; -import { exportCustomEmojis } from './export-custom-emojis.js'; -import { exportNotes } from './export-notes.js'; -import { exportFollowing } from './export-following.js'; -import { exportMute } from './export-mute.js'; -import { exportBlocking } from './export-blocking.js'; -import { exportUserLists } from './export-user-lists.js'; -import { importFollowing } from './import-following.js'; -import { importUserLists } from './import-user-lists.js'; -import { deleteAccount } from './delete-account.js'; -import { importMuting } from './import-muting.js'; -import { importBlocking } from './import-blocking.js'; -import { importCustomEmojis } from './import-custom-emojis.js'; - -const jobs = { - deleteDriveFiles, - exportCustomEmojis, - exportNotes, - exportFollowing, - exportMute, - exportBlocking, - exportUserLists, - importFollowing, - importMuting, - importBlocking, - importUserLists, - importCustomEmojis, - deleteAccount, -} as Record | Bull.ProcessPromiseFunction>; - -export default function(dbQueue: Bull.Queue) { - for (const [k, v] of Object.entries(jobs)) { - dbQueue.process(k, v); - } -} diff --git a/packages/backend/src/queue/processors/deliver.ts b/packages/backend/src/queue/processors/deliver.ts deleted file mode 100644 index 291c05766..000000000 --- a/packages/backend/src/queue/processors/deliver.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { URL } from 'node:url'; -import Bull from 'bull'; -import request from '@/remote/activitypub/request.js'; -import { registerOrFetchInstanceDoc } from '@/services/register-or-fetch-instance-doc.js'; -import Logger from '@/services/logger.js'; -import { Instances } from '@/models/index.js'; -import { apRequestChart, federationChart, instanceChart } from '@/services/chart/index.js'; -import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { toPuny } from '@/misc/convert-host.js'; -import { Cache } from '@/misc/cache.js'; -import { Instance } from '@/models/entities/instance.js'; -import { DeliverJobData } from '../types.js'; -import { StatusError } from '@/misc/fetch.js'; - -const logger = new Logger('deliver'); - -let latest: string | null = null; - -const suspendedHostsCache = new Cache(1000 * 60 * 60); - -export default async (job: Bull.Job) => { - const { host } = new URL(job.data.to); - - // ブロックしてたら中断 - const meta = await fetchMeta(); - if (meta.blockedHosts.includes(toPuny(host))) { - return 'skip (blocked)'; - } - - // isSuspendedなら中断 - let suspendedHosts = suspendedHostsCache.get(null); - if (suspendedHosts == null) { - suspendedHosts = await Instances.find({ - where: { - isSuspended: true, - }, - }); - suspendedHostsCache.set(null, suspendedHosts); - } - if (suspendedHosts.map(x => x.host).includes(toPuny(host))) { - return 'skip (suspended)'; - } - - try { - if (latest !== (latest = JSON.stringify(job.data.content, null, 2))) { - logger.debug(`delivering ${latest}`); - } - - await request(job.data.user, job.data.to, job.data.content); - - // Update stats - registerOrFetchInstanceDoc(host).then(i => { - Instances.update(i.id, { - latestRequestSentAt: new Date(), - latestStatus: 200, - lastCommunicatedAt: new Date(), - isNotResponding: false, - }); - - fetchInstanceMetadata(i); - - instanceChart.requestSent(i.host, true); - apRequestChart.deliverSucc(); - federationChart.deliverd(i.host, true); - }); - - return 'Success'; - } catch (res) { - // Update stats - registerOrFetchInstanceDoc(host).then(i => { - Instances.update(i.id, { - latestRequestSentAt: new Date(), - latestStatus: res instanceof StatusError ? res.statusCode : null, - isNotResponding: true, - }); - - instanceChart.requestSent(i.host, false); - apRequestChart.deliverFail(); - federationChart.deliverd(i.host, false); - }); - - if (res instanceof StatusError) { - // 4xx - if (res.isClientError) { - // HTTPステータスコード4xxはクライアントエラーであり、それはつまり - // 何回再送しても成功することはないということなのでエラーにはしないでおく - return `${res.statusCode} ${res.statusMessage}`; - } - - // 5xx etc. - throw `${res.statusCode} ${res.statusMessage}`; - } else { - // DNS error, socket error, timeout ... - throw res; - } - } -}; diff --git a/packages/backend/src/queue/processors/ended-poll-notification.ts b/packages/backend/src/queue/processors/ended-poll-notification.ts deleted file mode 100644 index 6151c96ad..000000000 --- a/packages/backend/src/queue/processors/ended-poll-notification.ts +++ /dev/null @@ -1,33 +0,0 @@ -import Bull from 'bull'; -import { In } from 'typeorm'; -import { Notes, Polls, PollVotes } from '@/models/index.js'; -import { queueLogger } from '../logger.js'; -import { EndedPollNotificationJobData } from '@/queue/types.js'; -import { createNotification } from '@/services/create-notification.js'; - -const logger = queueLogger.createSubLogger('ended-poll-notification'); - -export async function endedPollNotification(job: Bull.Job, done: any): Promise { - const note = await Notes.findOneBy({ id: job.data.noteId }); - if (note == null || !note.hasPoll) { - done(); - return; - } - - const votes = await PollVotes.createQueryBuilder('vote') - .select('vote.userId') - .where('vote.noteId = :noteId', { noteId: note.id }) - .innerJoinAndSelect('vote.user', 'user') - .andWhere('user.host IS NULL') - .getMany(); - - const userIds = [...new Set([note.userId, ...votes.map(v => v.userId)])]; - - for (const userId of userIds) { - createNotification(userId, 'pollEnded', { - noteId: note.id, - }); - } - - done(); -} diff --git a/packages/backend/src/queue/processors/inbox.ts b/packages/backend/src/queue/processors/inbox.ts deleted file mode 100644 index 198dde605..000000000 --- a/packages/backend/src/queue/processors/inbox.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { URL } from 'node:url'; -import Bull from 'bull'; -import httpSignature from '@peertube/http-signature'; -import perform from '@/remote/activitypub/perform.js'; -import Logger from '@/services/logger.js'; -import { registerOrFetchInstanceDoc } from '@/services/register-or-fetch-instance-doc.js'; -import { Instances } from '@/models/index.js'; -import { apRequestChart, federationChart, instanceChart } from '@/services/chart/index.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { toPuny, extractDbHost } from '@/misc/convert-host.js'; -import { getApId } from '@/remote/activitypub/type.js'; -import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata.js'; -import { InboxJobData } from '../types.js'; -import DbResolver from '@/remote/activitypub/db-resolver.js'; -import { resolvePerson } from '@/remote/activitypub/models/person.js'; -import { LdSignature } from '@/remote/activitypub/misc/ld-signature.js'; -import { StatusError } from '@/misc/fetch.js'; -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import { UserPublickey } from '@/models/entities/user-publickey.js'; - -const logger = new Logger('inbox'); - -// ユーザーのinboxにアクティビティが届いた時の処理 -export default async (job: Bull.Job): Promise => { - const signature = job.data.signature; // HTTP-signature - const activity = job.data.activity; - - //#region Log - const info = Object.assign({}, activity) as any; - delete info['@context']; - logger.debug(JSON.stringify(info, null, 2)); - //#endregion - - const host = toPuny(new URL(signature.keyId).hostname); - - // ブロックしてたら中断 - const meta = await fetchMeta(); - if (meta.blockedHosts.includes(host)) { - return `Blocked request: ${host}`; - } - - const keyIdLower = signature.keyId.toLowerCase(); - if (keyIdLower.startsWith('acct:')) { - return `Old keyId is no longer supported. ${keyIdLower}`; - } - - const dbResolver = new DbResolver(); - - // HTTP-Signature keyIdを元にDBから取得 - let authUser: { - user: CacheableRemoteUser; - key: UserPublickey | null; - } | null = await dbResolver.getAuthUserFromKeyId(signature.keyId); - - // keyIdでわからなければ、activity.actorを元にDBから取得 || activity.actorを元にリモートから取得 - if (authUser == null) { - try { - authUser = await dbResolver.getAuthUserFromApId(getApId(activity.actor)); - } catch (e) { - // 対象が4xxならスキップ - if (e instanceof StatusError) { - if (e.isClientError) { - return `skip: Ignored deleted actors on both ends ${activity.actor} - ${e.statusCode}`; - } - throw `Error in actor ${activity.actor} - ${e.statusCode || e}`; - } - } - } - - // それでもわからなければ終了 - if (authUser == null) { - return `skip: failed to resolve user`; - } - - // publicKey がなくても終了 - if (authUser.key == null) { - return `skip: failed to resolve user publicKey`; - } - - // HTTP-Signatureの検証 - const httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem); - - // また、signatureのsignerは、activity.actorと一致する必要がある - if (!httpSignatureValidated || authUser.user.uri !== activity.actor) { - // 一致しなくても、でもLD-Signatureがありそうならそっちも見る - if (activity.signature) { - if (activity.signature.type !== 'RsaSignature2017') { - return `skip: unsupported LD-signature type ${activity.signature.type}`; - } - - // activity.signature.creator: https://example.oom/users/user#main-key - // みたいになっててUserを引っ張れば公開キーも入ることを期待する - if (activity.signature.creator) { - const candicate = activity.signature.creator.replace(/#.*/, ''); - await resolvePerson(candicate).catch(() => null); - } - - // keyIdからLD-Signatureのユーザーを取得 - authUser = await dbResolver.getAuthUserFromKeyId(activity.signature.creator); - if (authUser == null) { - return `skip: LD-Signatureのユーザーが取得できませんでした`; - } - - if (authUser.key == null) { - return `skip: LD-SignatureのユーザーはpublicKeyを持っていませんでした`; - } - - // LD-Signature検証 - const ldSignature = new LdSignature(); - const verified = await ldSignature.verifyRsaSignature2017(activity, authUser.key.keyPem).catch(() => false); - if (!verified) { - return `skip: LD-Signatureの検証に失敗しました`; - } - - // もう一度actorチェック - if (authUser.user.uri !== activity.actor) { - return `skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`; - } - - // ブロックしてたら中断 - const ldHost = extractDbHost(authUser.user.uri); - if (meta.blockedHosts.includes(ldHost)) { - return `Blocked request: ${ldHost}`; - } - } else { - return `skip: http-signature verification failed and no LD-Signature. keyId=${signature.keyId}`; - } - } - - // activity.idがあればホストが署名者のホストであることを確認する - if (typeof activity.id === 'string') { - const signerHost = extractDbHost(authUser.user.uri!); - const activityIdHost = extractDbHost(activity.id); - if (signerHost !== activityIdHost) { - return `skip: signerHost(${signerHost}) !== activity.id host(${activityIdHost}`; - } - } - - // Update stats - registerOrFetchInstanceDoc(authUser.user.host).then(i => { - Instances.update(i.id, { - latestRequestReceivedAt: new Date(), - lastCommunicatedAt: new Date(), - isNotResponding: false, - }); - - fetchInstanceMetadata(i); - - instanceChart.requestReceived(i.host); - apRequestChart.inbox(); - federationChart.inbox(i.host); - }); - - // アクティビティを処理 - await perform(authUser.user, activity); - return `ok`; -}; diff --git a/packages/backend/src/queue/processors/object-storage/clean-remote-files.ts b/packages/backend/src/queue/processors/object-storage/clean-remote-files.ts deleted file mode 100644 index 77da162f6..000000000 --- a/packages/backend/src/queue/processors/object-storage/clean-remote-files.ts +++ /dev/null @@ -1,50 +0,0 @@ -import Bull from 'bull'; - -import { queueLogger } from '../../logger.js'; -import { deleteFileSync } from '@/services/drive/delete-file.js'; -import { DriveFiles } from '@/models/index.js'; -import { MoreThan, Not, IsNull } from 'typeorm'; - -const logger = queueLogger.createSubLogger('clean-remote-files'); - -export default async function cleanRemoteFiles(job: Bull.Job>, done: any): Promise { - logger.info(`Deleting cached remote files...`); - - let deletedCount = 0; - let cursor: any = null; - - while (true) { - const files = await DriveFiles.find({ - where: { - userHost: Not(IsNull()), - isLink: false, - ...(cursor ? { id: MoreThan(cursor) } : {}), - }, - take: 8, - order: { - id: 1, - }, - }); - - if (files.length === 0) { - job.progress(100); - break; - } - - cursor = files[files.length - 1].id; - - await Promise.all(files.map(file => deleteFileSync(file, true))); - - deletedCount += 8; - - const total = await DriveFiles.countBy({ - userHost: Not(IsNull()), - isLink: false, - }); - - job.progress(deletedCount / total); - } - - logger.succ(`All cahced remote files has been deleted.`); - done(); -} diff --git a/packages/backend/src/queue/processors/object-storage/delete-file.ts b/packages/backend/src/queue/processors/object-storage/delete-file.ts deleted file mode 100644 index c271e3ddd..000000000 --- a/packages/backend/src/queue/processors/object-storage/delete-file.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ObjectStorageFileJobData } from '@/queue/types.js'; -import Bull from 'bull'; -import { deleteObjectStorageFile } from '@/services/drive/delete-file.js'; - -export default async (job: Bull.Job) => { - const key: string = job.data.key; - - await deleteObjectStorageFile(key); - - return 'Success'; -}; diff --git a/packages/backend/src/queue/processors/object-storage/index.ts b/packages/backend/src/queue/processors/object-storage/index.ts deleted file mode 100644 index ae6c481fe..000000000 --- a/packages/backend/src/queue/processors/object-storage/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import Bull from 'bull'; -import { ObjectStorageJobData } from '@/queue/types.js'; -import deleteFile from './delete-file.js'; -import cleanRemoteFiles from './clean-remote-files.js'; - -const jobs = { - deleteFile, - cleanRemoteFiles, -} as Record | Bull.ProcessPromiseFunction>; - -export default function(q: Bull.Queue) { - for (const [k, v] of Object.entries(jobs)) { - q.process(k, 16, v); - } -} diff --git a/packages/backend/src/queue/processors/system/check-expired-mutings.ts b/packages/backend/src/queue/processors/system/check-expired-mutings.ts deleted file mode 100644 index 621269e7e..000000000 --- a/packages/backend/src/queue/processors/system/check-expired-mutings.ts +++ /dev/null @@ -1,30 +0,0 @@ -import Bull from 'bull'; -import { In } from 'typeorm'; -import { Mutings } from '@/models/index.js'; -import { queueLogger } from '../../logger.js'; -import { publishUserEvent } from '@/services/stream.js'; - -const logger = queueLogger.createSubLogger('check-expired-mutings'); - -export async function checkExpiredMutings(job: Bull.Job>, done: any): Promise { - logger.info(`Checking expired mutings...`); - - const expired = await Mutings.createQueryBuilder('muting') - .where('muting.expiresAt IS NOT NULL') - .andWhere('muting.expiresAt < :now', { now: new Date() }) - .innerJoinAndSelect('muting.mutee', 'mutee') - .getMany(); - - if (expired.length > 0) { - await Mutings.delete({ - id: In(expired.map(m => m.id)), - }); - - for (const m of expired) { - publishUserEvent(m.muterId, 'unmute', m.mutee!); - } - } - - logger.succ(`All expired mutings checked.`); - done(); -} diff --git a/packages/backend/src/queue/processors/system/clean-charts.ts b/packages/backend/src/queue/processors/system/clean-charts.ts deleted file mode 100644 index c9169d5ac..000000000 --- a/packages/backend/src/queue/processors/system/clean-charts.ts +++ /dev/null @@ -1,28 +0,0 @@ -import Bull from 'bull'; - -import { queueLogger } from '../../logger.js'; -import { activeUsersChart, driveChart, federationChart, hashtagChart, instanceChart, notesChart, perUserDriveChart, perUserFollowingChart, perUserNotesChart, perUserReactionsChart, usersChart, apRequestChart } from '@/services/chart/index.js'; - -const logger = queueLogger.createSubLogger('clean-charts'); - -export async function cleanCharts(job: Bull.Job>, done: any): Promise { - logger.info(`Clean charts...`); - - await Promise.all([ - federationChart.clean(), - notesChart.clean(), - usersChart.clean(), - activeUsersChart.clean(), - instanceChart.clean(), - perUserNotesChart.clean(), - driveChart.clean(), - perUserReactionsChart.clean(), - hashtagChart.clean(), - perUserFollowingChart.clean(), - perUserDriveChart.clean(), - apRequestChart.clean(), - ]); - - logger.succ(`All charts successfully cleaned.`); - done(); -} diff --git a/packages/backend/src/queue/processors/system/clean.ts b/packages/backend/src/queue/processors/system/clean.ts deleted file mode 100644 index c4f978d7c..000000000 --- a/packages/backend/src/queue/processors/system/clean.ts +++ /dev/null @@ -1,18 +0,0 @@ -import Bull from 'bull'; -import { LessThan } from 'typeorm'; -import { UserIps } from '@/models/index.js'; - -import { queueLogger } from '../../logger.js'; - -const logger = queueLogger.createSubLogger('clean'); - -export async function clean(job: Bull.Job>, done: any): Promise { - logger.info('Cleaning...'); - - UserIps.delete({ - createdAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90))), - }); - - logger.succ('Cleaned.'); - done(); -} diff --git a/packages/backend/src/queue/processors/system/index.ts b/packages/backend/src/queue/processors/system/index.ts deleted file mode 100644 index 9527d40b0..000000000 --- a/packages/backend/src/queue/processors/system/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -import Bull from 'bull'; -import { tickCharts } from './tick-charts.js'; -import { resyncCharts } from './resync-charts.js'; -import { cleanCharts } from './clean-charts.js'; -import { checkExpiredMutings } from './check-expired-mutings.js'; -import { clean } from './clean.js'; - -const jobs = { - tickCharts, - resyncCharts, - cleanCharts, - checkExpiredMutings, - clean, -} as Record> | Bull.ProcessPromiseFunction>>; - -export default function(dbQueue: Bull.Queue>) { - for (const [k, v] of Object.entries(jobs)) { - dbQueue.process(k, v); - } -} diff --git a/packages/backend/src/queue/processors/system/resync-charts.ts b/packages/backend/src/queue/processors/system/resync-charts.ts deleted file mode 100644 index 20012513a..000000000 --- a/packages/backend/src/queue/processors/system/resync-charts.ts +++ /dev/null @@ -1,21 +0,0 @@ -import Bull from 'bull'; - -import { queueLogger } from '../../logger.js'; -import { driveChart, notesChart, usersChart } from '@/services/chart/index.js'; - -const logger = queueLogger.createSubLogger('resync-charts'); - -export async function resyncCharts(job: Bull.Job>, done: any): Promise { - logger.info(`Resync charts...`); - - // TODO: ユーザーごとのチャートも更新する - // TODO: インスタンスごとのチャートも更新する - await Promise.all([ - driveChart.resync(), - notesChart.resync(), - usersChart.resync(), - ]); - - logger.succ(`All charts successfully resynced.`); - done(); -} diff --git a/packages/backend/src/queue/processors/system/tick-charts.ts b/packages/backend/src/queue/processors/system/tick-charts.ts deleted file mode 100644 index 13403f8f7..000000000 --- a/packages/backend/src/queue/processors/system/tick-charts.ts +++ /dev/null @@ -1,28 +0,0 @@ -import Bull from 'bull'; - -import { queueLogger } from '../../logger.js'; -import { activeUsersChart, driveChart, federationChart, hashtagChart, instanceChart, notesChart, perUserDriveChart, perUserFollowingChart, perUserNotesChart, perUserReactionsChart, usersChart, apRequestChart } from '@/services/chart/index.js'; - -const logger = queueLogger.createSubLogger('tick-charts'); - -export async function tickCharts(job: Bull.Job>, done: any): Promise { - logger.info(`Tick charts...`); - - await Promise.all([ - federationChart.tick(false), - notesChart.tick(false), - usersChart.tick(false), - activeUsersChart.tick(false), - instanceChart.tick(false), - perUserNotesChart.tick(false), - driveChart.tick(false), - perUserReactionsChart.tick(false), - hashtagChart.tick(false), - perUserFollowingChart.tick(false), - perUserDriveChart.tick(false), - apRequestChart.tick(false), - ]); - - logger.succ(`All charts successfully ticked.`); - done(); -} diff --git a/packages/backend/src/queue/processors/webhook-deliver.ts b/packages/backend/src/queue/processors/webhook-deliver.ts deleted file mode 100644 index d49206f68..000000000 --- a/packages/backend/src/queue/processors/webhook-deliver.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { URL } from 'node:url'; -import Bull from 'bull'; -import Logger from '@/services/logger.js'; -import { WebhookDeliverJobData } from '../types.js'; -import { getResponse, StatusError } from '@/misc/fetch.js'; -import { Webhooks } from '@/models/index.js'; -import config from '@/config/index.js'; - -const logger = new Logger('webhook'); - -export default async (job: Bull.Job) => { - try { - logger.debug(`delivering ${job.data.webhookId}`); - - const res = await getResponse({ - url: job.data.to, - method: 'POST', - headers: { - 'User-Agent': 'Misskey-Hooks', - 'X-Misskey-Host': config.host, - 'X-Misskey-Hook-Id': job.data.webhookId, - 'X-Misskey-Hook-Secret': job.data.secret, - }, - body: JSON.stringify({ - hookId: job.data.webhookId, - userId: job.data.userId, - eventId: job.data.eventId, - createdAt: job.data.createdAt, - type: job.data.type, - body: job.data.content, - }), - }); - - Webhooks.update({ id: job.data.webhookId }, { - latestSentAt: new Date(), - latestStatus: res.status, - }); - - return 'Success'; - } catch (res) { - Webhooks.update({ id: job.data.webhookId }, { - latestSentAt: new Date(), - latestStatus: res instanceof StatusError ? res.statusCode : 1, - }); - - if (res instanceof StatusError) { - // 4xx - if (res.isClientError) { - return `${res.statusCode} ${res.statusMessage}`; - } - - // 5xx etc. - throw `${res.statusCode} ${res.statusMessage}`; - } else { - // DNS error, socket error, timeout ... - throw res; - } - } -}; diff --git a/packages/backend/src/queue/queues.ts b/packages/backend/src/queue/queues.ts deleted file mode 100644 index f3a267790..000000000 --- a/packages/backend/src/queue/queues.ts +++ /dev/null @@ -1,21 +0,0 @@ -import config from '@/config/index.js'; -import { initialize as initializeQueue } from './initialize.js'; -import { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, EndedPollNotificationJobData, WebhookDeliverJobData } from './types.js'; - -export const systemQueue = initializeQueue>('system'); -export const endedPollNotificationQueue = initializeQueue('endedPollNotification'); -export const deliverQueue = initializeQueue('deliver', config.deliverJobPerSec || 128); -export const inboxQueue = initializeQueue('inbox', config.inboxJobPerSec || 16); -export const dbQueue = initializeQueue('db'); -export const objectStorageQueue = initializeQueue('objectStorage'); -export const webhookDeliverQueue = initializeQueue('webhookDeliver', 64); - -export const queues = [ - systemQueue, - endedPollNotificationQueue, - deliverQueue, - inboxQueue, - dbQueue, - objectStorageQueue, - webhookDeliverQueue, -]; diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index 5ea472556..18ec997a1 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -1,9 +1,9 @@ -import { DriveFile } from '@/models/entities/drive-file.js'; -import { Note } from '@/models/entities/note'; -import { User } from '@/models/entities/user.js'; -import { Webhook } from '@/models/entities/webhook'; -import { IActivity } from '@/remote/activitypub/type.js'; -import httpSignature from '@peertube/http-signature'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import type { Note } from '@/models/entities/Note.js'; +import type { User } from '@/models/entities/User.js'; +import type { Webhook } from '@/models/entities/Webhook.js'; +import type { IActivity } from '@/core/remote/activitypub/type.js'; +import type httpSignature from '@peertube/http-signature'; export type DeliverJobData = { /** Actor */ diff --git a/packages/backend/src/db/redis.ts b/packages/backend/src/redis.ts similarity index 51% rename from packages/backend/src/db/redis.ts rename to packages/backend/src/redis.ts index 49f5bb2ba..d1678ae65 100644 --- a/packages/backend/src/db/redis.ts +++ b/packages/backend/src/redis.ts @@ -1,18 +1,15 @@ import Redis from 'ioredis'; -import config from '@/config/index.js'; +import { loadConfig } from '@/config.js'; + +export function createRedisConnection(): Redis.Redis { + const config = loadConfig(); -export function createConnection() { return new Redis({ port: config.redis.port, host: config.redis.host, family: config.redis.family == null ? 0 : config.redis.family, password: config.redis.pass, keyPrefix: `${config.redis.prefix}:`, - db: config.redis.db || 0, + db: config.redis.db ?? 0, }); } - -export const subsdcriber = createConnection(); -subsdcriber.subscribe(config.host); - -export const redisClient = createConnection(); diff --git a/packages/backend/src/remote/activitypub/ap-request.ts b/packages/backend/src/remote/activitypub/ap-request.ts deleted file mode 100644 index 8b55f2247..000000000 --- a/packages/backend/src/remote/activitypub/ap-request.ts +++ /dev/null @@ -1,104 +0,0 @@ -import * as crypto from 'node:crypto'; -import { URL } from 'node:url'; - -type Request = { - url: string; - method: string; - headers: Record; -}; - -type PrivateKey = { - privateKeyPem: string; - keyId: string; -}; - -export function createSignedPost(args: { key: PrivateKey, url: string, body: string, additionalHeaders: Record }) { - const u = new URL(args.url); - const digestHeader = `SHA-256=${crypto.createHash('sha256').update(args.body).digest('base64')}`; - - const request: Request = { - url: u.href, - method: 'POST', - headers: objectAssignWithLcKey({ - 'Date': new Date().toUTCString(), - 'Host': u.hostname, - 'Content-Type': 'application/activity+json', - 'Digest': digestHeader, - }, args.additionalHeaders), - }; - - const result = signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'digest']); - - return { - request, - signingString: result.signingString, - signature: result.signature, - signatureHeader: result.signatureHeader, - }; -} - -export function createSignedGet(args: { key: PrivateKey, url: string, additionalHeaders: Record }) { - const u = new URL(args.url); - - const request: Request = { - url: u.href, - method: 'GET', - headers: objectAssignWithLcKey({ - 'Accept': 'application/activity+json, application/ld+json', - 'Date': new Date().toUTCString(), - 'Host': new URL(args.url).hostname, - }, args.additionalHeaders), - }; - - const result = signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'accept']); - - return { - request, - signingString: result.signingString, - signature: result.signature, - signatureHeader: result.signatureHeader, - }; -} - -function signToRequest(request: Request, key: PrivateKey, includeHeaders: string[]) { - const signingString = genSigningString(request, includeHeaders); - const signature = crypto.sign('sha256', Buffer.from(signingString), key.privateKeyPem).toString('base64'); - const signatureHeader = `keyId="${key.keyId}",algorithm="rsa-sha256",headers="${includeHeaders.join(' ')}",signature="${signature}"`; - - request.headers = objectAssignWithLcKey(request.headers, { - Signature: signatureHeader, - }); - - return { - request, - signingString, - signature, - signatureHeader, - }; -} - -function genSigningString(request: Request, includeHeaders: string[]) { - request.headers = lcObjectKey(request.headers); - - const results: string[] = []; - - for (const key of includeHeaders.map(x => x.toLowerCase())) { - if (key === '(request-target)') { - results.push(`(request-target): ${request.method.toLowerCase()} ${new URL(request.url).pathname}`); - } else { - results.push(`${key}: ${request.headers[key]}`); - } - } - - return results.join('\n'); -} - -function lcObjectKey(src: Record) { - const dst: Record = {}; - for (const key of Object.keys(src).filter(x => x !== '__proto__' && typeof src[x] === 'string')) dst[key.toLowerCase()] = src[key]; - return dst; -} - -function objectAssignWithLcKey(a: Record, b: Record) { - return Object.assign(lcObjectKey(a), lcObjectKey(b)); -} diff --git a/packages/backend/src/remote/activitypub/audience.ts b/packages/backend/src/remote/activitypub/audience.ts deleted file mode 100644 index 846ccf9c0..000000000 --- a/packages/backend/src/remote/activitypub/audience.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { ApObject, getApIds } from './type.js'; -import Resolver from './resolver.js'; -import { resolvePerson } from './models/person.js'; -import { unique, concat } from '@/prelude/array.js'; -import promiseLimit from 'promise-limit'; -import { User, CacheableRemoteUser, CacheableUser } from '@/models/entities/user.js'; - -type Visibility = 'public' | 'home' | 'followers' | 'specified'; - -type AudienceInfo = { - visibility: Visibility, - mentionedUsers: CacheableUser[], - visibleUsers: CacheableUser[], -}; - -export async function parseAudience(actor: CacheableRemoteUser, to?: ApObject, cc?: ApObject, resolver?: Resolver): Promise { - const toGroups = groupingAudience(getApIds(to), actor); - const ccGroups = groupingAudience(getApIds(cc), actor); - - const others = unique(concat([toGroups.other, ccGroups.other])); - - const limit = promiseLimit(2); - const mentionedUsers = (await Promise.all( - others.map(id => limit(() => resolvePerson(id, resolver).catch(() => null))) - )).filter((x): x is CacheableUser => x != null); - - if (toGroups.public.length > 0) { - return { - visibility: 'public', - mentionedUsers, - visibleUsers: [], - }; - } - - if (ccGroups.public.length > 0) { - return { - visibility: 'home', - mentionedUsers, - visibleUsers: [], - }; - } - - if (toGroups.followers.length > 0) { - return { - visibility: 'followers', - mentionedUsers, - visibleUsers: [], - }; - } - - return { - visibility: 'specified', - mentionedUsers, - visibleUsers: mentionedUsers, - }; -} - -function groupingAudience(ids: string[], actor: CacheableRemoteUser) { - const groups = { - public: [] as string[], - followers: [] as string[], - other: [] as string[], - }; - - for (const id of ids) { - if (isPublic(id)) { - groups.public.push(id); - } else if (isFollowers(id, actor)) { - groups.followers.push(id); - } else { - groups.other.push(id); - } - } - - groups.other = unique(groups.other); - - return groups; -} - -function isPublic(id: string) { - return [ - 'https://www.w3.org/ns/activitystreams#Public', - 'as#Public', - 'Public', - ].includes(id); -} - -function isFollowers(id: string, actor: CacheableRemoteUser) { - return ( - id === (actor.followersUri || `${actor.uri}/followers`) - ); -} diff --git a/packages/backend/src/remote/activitypub/db-resolver.ts b/packages/backend/src/remote/activitypub/db-resolver.ts deleted file mode 100644 index 1a02f675c..000000000 --- a/packages/backend/src/remote/activitypub/db-resolver.ts +++ /dev/null @@ -1,155 +0,0 @@ -import escapeRegexp from 'escape-regexp'; -import config from '@/config/index.js'; -import { Note } from '@/models/entities/note.js'; -import { User, IRemoteUser, CacheableRemoteUser, CacheableUser } from '@/models/entities/user.js'; -import { UserPublickey } from '@/models/entities/user-publickey.js'; -import { MessagingMessage } from '@/models/entities/messaging-message.js'; -import { Notes, Users, UserPublickeys, MessagingMessages } from '@/models/index.js'; -import { Cache } from '@/misc/cache.js'; -import { uriPersonCache, userByIdCache } from '@/services/user-cache.js'; -import { IObject, getApId } from './type.js'; -import { resolvePerson } from './models/person.js'; - -const publicKeyCache = new Cache(Infinity); -const publicKeyByUserIdCache = new Cache(Infinity); - -export type UriParseResult = { - /** wether the URI was generated by us */ - local: true; - /** id in DB */ - id: string; - /** hint of type, e.g. "notes", "users" */ - type: string; - /** any remaining text after type and id, not including the slash after id. undefined if empty */ - rest?: string; -} | { - /** wether the URI was generated by us */ - local: false; - /** uri in DB */ - uri: string; -}; - -export function parseUri(value: string | IObject): UriParseResult { - const uri = getApId(value); - - // the host part of a URL is case insensitive, so use the 'i' flag. - const localRegex = new RegExp('^' + escapeRegexp(config.url) + '/(\\w+)/(\\w+)(?:\/(.+))?', 'i'); - const matchLocal = uri.match(localRegex); - - if (matchLocal) { - return { - local: true, - type: matchLocal[1], - id: matchLocal[2], - rest: matchLocal[3], - }; - } else { - return { - local: false, - uri, - }; - } -} - -export default class DbResolver { - constructor() { - } - - /** - * AP Note => Misskey Note in DB - */ - public async getNoteFromApId(value: string | IObject): Promise { - const parsed = parseUri(value); - - if (parsed.local) { - if (parsed.type !== 'notes') return null; - - return await Notes.findOneBy({ - id: parsed.id, - }); - } else { - return await Notes.findOneBy({ - uri: parsed.uri, - }); - } - } - - public async getMessageFromApId(value: string | IObject): Promise { - const parsed = parseUri(value); - - if (parsed.local) { - if (parsed.type !== 'notes') return null; - - return await MessagingMessages.findOneBy({ - id: parsed.id, - }); - } else { - return await MessagingMessages.findOneBy({ - uri: parsed.uri, - }); - } - } - - /** - * AP Person => Misskey User in DB - */ - public async getUserFromApId(value: string | IObject): Promise { - const parsed = parseUri(value); - - if (parsed.local) { - if (parsed.type !== 'users') return null; - - return await userByIdCache.fetchMaybe(parsed.id, () => Users.findOneBy({ - id: parsed.id, - }).then(x => x ?? undefined)) ?? null; - } else { - return await uriPersonCache.fetch(parsed.uri, () => Users.findOneBy({ - uri: parsed.uri, - })); - } - } - - /** - * AP KeyId => Misskey User and Key - */ - public async getAuthUserFromKeyId(keyId: string): Promise<{ - user: CacheableRemoteUser; - key: UserPublickey; - } | null> { - const key = await publicKeyCache.fetch(keyId, async () => { - const key = await UserPublickeys.findOneBy({ - keyId, - }); - - if (key == null) return null; - - return key; - }, key => key != null); - - if (key == null) return null; - - return { - user: await userByIdCache.fetch(key.userId, () => Users.findOneByOrFail({ id: key.userId })) as CacheableRemoteUser, - key, - }; - } - - /** - * AP Actor id => Misskey User and Key - */ - public async getAuthUserFromApId(uri: string): Promise<{ - user: CacheableRemoteUser; - key: UserPublickey | null; - } | null> { - const user = await resolvePerson(uri) as CacheableRemoteUser; - - if (user == null) return null; - - const key = await publicKeyByUserIdCache.fetch(user.id, () => UserPublickeys.findOneBy({ userId: user.id }), v => v != null); - - return { - user, - key, - }; - } -} diff --git a/packages/backend/src/remote/activitypub/kernel/accept/follow.ts b/packages/backend/src/remote/activitypub/kernel/accept/follow.ts deleted file mode 100644 index 4350ef133..000000000 --- a/packages/backend/src/remote/activitypub/kernel/accept/follow.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import accept from '@/services/following/requests/accept.js'; -import { IFollow } from '../../type.js'; -import DbResolver from '../../db-resolver.js'; -import { relayAccepted } from '@/services/relay.js'; - -export default async (actor: CacheableRemoteUser, activity: IFollow): Promise => { - // ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある - - const dbResolver = new DbResolver(); - const follower = await dbResolver.getUserFromApId(activity.actor); - - if (follower == null) { - return `skip: follower not found`; - } - - if (follower.host != null) { - return `skip: follower is not a local user`; - } - - // relay - const match = activity.id?.match(/follow-relay\/(\w+)/); - if (match) { - return await relayAccepted(match[1]); - } - - await accept(actor, follower); - return `ok`; -}; diff --git a/packages/backend/src/remote/activitypub/kernel/accept/index.ts b/packages/backend/src/remote/activitypub/kernel/accept/index.ts deleted file mode 100644 index 78ef75ade..000000000 --- a/packages/backend/src/remote/activitypub/kernel/accept/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -import Resolver from '../../resolver.js'; -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import acceptFollow from './follow.js'; -import { IAccept, isFollow, getApType } from '../../type.js'; -import { apLogger } from '../../logger.js'; - -const logger = apLogger; - -export default async (actor: CacheableRemoteUser, activity: IAccept): Promise => { - const uri = activity.id || activity; - - logger.info(`Accept: ${uri}`); - - const resolver = new Resolver(); - - const object = await resolver.resolve(activity.object).catch(e => { - logger.error(`Resolution failed: ${e}`); - throw e; - }); - - if (isFollow(object)) return await acceptFollow(actor, object); - - return `skip: Unknown Accept type: ${getApType(object)}`; -}; diff --git a/packages/backend/src/remote/activitypub/kernel/add/index.ts b/packages/backend/src/remote/activitypub/kernel/add/index.ts deleted file mode 100644 index c813414f9..000000000 --- a/packages/backend/src/remote/activitypub/kernel/add/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import { IAdd } from '../../type.js'; -import { resolveNote } from '../../models/note.js'; -import { addPinned } from '@/services/i/pin.js'; - -export default async (actor: CacheableRemoteUser, activity: IAdd): Promise => { - if ('actor' in activity && actor.uri !== activity.actor) { - throw new Error('invalid actor'); - } - - if (activity.target == null) { - throw new Error('target is null'); - } - - if (activity.target === actor.featured) { - const note = await resolveNote(activity.object); - if (note == null) throw new Error('note not found'); - await addPinned(actor, note.id); - return; - } - - throw new Error(`unknown target: ${activity.target}`); -}; diff --git a/packages/backend/src/remote/activitypub/kernel/announce/index.ts b/packages/backend/src/remote/activitypub/kernel/announce/index.ts deleted file mode 100644 index ae7e507c9..000000000 --- a/packages/backend/src/remote/activitypub/kernel/announce/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import Resolver from '../../resolver.js'; -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import announceNote from './note.js'; -import { IAnnounce, getApId } from '../../type.js'; -import { apLogger } from '../../logger.js'; - -const logger = apLogger; - -export default async (actor: CacheableRemoteUser, activity: IAnnounce): Promise => { - const uri = getApId(activity); - - logger.info(`Announce: ${uri}`); - - const resolver = new Resolver(); - - const targetUri = getApId(activity.object); - - announceNote(resolver, actor, activity, targetUri); -}; diff --git a/packages/backend/src/remote/activitypub/kernel/announce/note.ts b/packages/backend/src/remote/activitypub/kernel/announce/note.ts deleted file mode 100644 index 759cb4ae8..000000000 --- a/packages/backend/src/remote/activitypub/kernel/announce/note.ts +++ /dev/null @@ -1,72 +0,0 @@ -import Resolver from '../../resolver.js'; -import post from '@/services/note/create.js'; -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import { IAnnounce, getApId } from '../../type.js'; -import { fetchNote, resolveNote } from '../../models/note.js'; -import { apLogger } from '../../logger.js'; -import { extractDbHost } from '@/misc/convert-host.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { getApLock } from '@/misc/app-lock.js'; -import { parseAudience } from '../../audience.js'; -import { StatusError } from '@/misc/fetch.js'; -import { Notes } from '@/models/index.js'; - -const logger = apLogger; - -/** - * アナウンスアクティビティを捌きます - */ -export default async function(resolver: Resolver, actor: CacheableRemoteUser, activity: IAnnounce, targetUri: string): Promise { - const uri = getApId(activity); - - if (actor.isSuspended) { - return; - } - - // アナウンス先をブロックしてたら中断 - const meta = await fetchMeta(); - if (meta.blockedHosts.includes(extractDbHost(uri))) return; - - const unlock = await getApLock(uri); - - try { - // 既に同じURIを持つものが登録されていないかチェック - const exist = await fetchNote(uri); - if (exist) { - return; - } - - // Announce対象をresolve - let renote; - try { - renote = await resolveNote(targetUri); - } catch (e) { - // 対象が4xxならスキップ - if (e instanceof StatusError) { - if (e.isClientError) { - logger.warn(`Ignored announce target ${targetUri} - ${e.statusCode}`); - return; - } - - logger.warn(`Error in announce target ${targetUri} - ${e.statusCode || e}`); - } - throw e; - } - - if (!await Notes.isVisibleForMe(renote, actor.id)) return 'skip: invalid actor for this activity'; - - logger.info(`Creating the (Re)Note: ${uri}`); - - const activityAudience = await parseAudience(actor, activity.to, activity.cc); - - await post(actor, { - createdAt: activity.published ? new Date(activity.published) : null, - renote, - visibility: activityAudience.visibility, - visibleUsers: activityAudience.visibleUsers, - uri, - }); - } finally { - unlock(); - } -} diff --git a/packages/backend/src/remote/activitypub/kernel/block/index.ts b/packages/backend/src/remote/activitypub/kernel/block/index.ts deleted file mode 100644 index 5e230ad7b..000000000 --- a/packages/backend/src/remote/activitypub/kernel/block/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { IBlock } from '../../type.js'; -import block from '@/services/blocking/create.js'; -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import DbResolver from '../../db-resolver.js'; -import { Users } from '@/models/index.js'; - -export default async (actor: CacheableRemoteUser, activity: IBlock): Promise => { - // ※ activity.objectにブロック対象があり、それは存在するローカルユーザーのはず - - const dbResolver = new DbResolver(); - const blockee = await dbResolver.getUserFromApId(activity.object); - - if (blockee == null) { - return `skip: blockee not found`; - } - - if (blockee.host != null) { - return `skip: ブロックしようとしているユーザーはローカルユーザーではありません`; - } - - await block(await Users.findOneByOrFail({ id: actor.id }), await Users.findOneByOrFail({ id: blockee.id })); - return `ok`; -}; diff --git a/packages/backend/src/remote/activitypub/kernel/create/index.ts b/packages/backend/src/remote/activitypub/kernel/create/index.ts deleted file mode 100644 index c253f9f66..000000000 --- a/packages/backend/src/remote/activitypub/kernel/create/index.ts +++ /dev/null @@ -1,43 +0,0 @@ -import Resolver from '../../resolver.js'; -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import createNote from './note.js'; -import { ICreate, getApId, isPost, getApType } from '../../type.js'; -import { apLogger } from '../../logger.js'; -import { toArray, concat, unique } from '@/prelude/array.js'; - -const logger = apLogger; - -export default async (actor: CacheableRemoteUser, activity: ICreate): Promise => { - const uri = getApId(activity); - - logger.info(`Create: ${uri}`); - - // copy audiences between activity <=> object. - if (typeof activity.object === 'object') { - const to = unique(concat([toArray(activity.to), toArray(activity.object.to)])); - const cc = unique(concat([toArray(activity.cc), toArray(activity.object.cc)])); - - activity.to = to; - activity.cc = cc; - activity.object.to = to; - activity.object.cc = cc; - } - - // If there is no attributedTo, use Activity actor. - if (typeof activity.object === 'object' && !activity.object.attributedTo) { - activity.object.attributedTo = activity.actor; - } - - const resolver = new Resolver(); - - const object = await resolver.resolve(activity.object).catch(e => { - logger.error(`Resolution failed: ${e}`); - throw e; - }); - - if (isPost(object)) { - createNote(resolver, actor, object, false, activity); - } else { - logger.warn(`Unknown type: ${getApType(object)}`); - } -}; diff --git a/packages/backend/src/remote/activitypub/kernel/create/note.ts b/packages/backend/src/remote/activitypub/kernel/create/note.ts deleted file mode 100644 index f8dabe06e..000000000 --- a/packages/backend/src/remote/activitypub/kernel/create/note.ts +++ /dev/null @@ -1,44 +0,0 @@ -import Resolver from '../../resolver.js'; -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import { createNote, fetchNote } from '../../models/note.js'; -import { getApId, IObject, ICreate } from '../../type.js'; -import { getApLock } from '@/misc/app-lock.js'; -import { extractDbHost } from '@/misc/convert-host.js'; -import { StatusError } from '@/misc/fetch.js'; - -/** - * 投稿作成アクティビティを捌きます - */ -export default async function(resolver: Resolver, actor: CacheableRemoteUser, note: IObject, silent = false, activity?: ICreate): 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 (extractDbHost(actor.uri) !== extractDbHost(note.id)) { - return `skip: host in actor.uri !== note.id`; - } - } - } - - const unlock = await getApLock(uri); - - try { - const exist = await fetchNote(note); - if (exist) return 'skip: note exists'; - - await createNote(note, resolver, silent); - return 'ok'; - } catch (e) { - if (e instanceof StatusError && e.isClientError) { - return `skip ${e.statusCode}`; - } else { - throw e; - } - } finally { - unlock(); - } -} diff --git a/packages/backend/src/remote/activitypub/kernel/delete/actor.ts b/packages/backend/src/remote/activitypub/kernel/delete/actor.ts deleted file mode 100644 index 1f94df033..000000000 --- a/packages/backend/src/remote/activitypub/kernel/delete/actor.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { apLogger } from '../../logger.js'; -import { createDeleteAccountJob } from '@/queue/index.js'; -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import { Users } from '@/models/index.js'; - -const logger = apLogger; - -export async function deleteActor(actor: CacheableRemoteUser, uri: string): Promise { - logger.info(`Deleting the Actor: ${uri}`); - - if (actor.uri !== uri) { - return `skip: delete actor ${actor.uri} !== ${uri}`; - } - - const user = await Users.findOneByOrFail({ id: actor.id }); - if (user.isDeleted) { - logger.info(`skip: already deleted`); - } - - const job = await createDeleteAccountJob(actor); - - await Users.update(actor.id, { - isDeleted: true, - }); - - return `ok: queued ${job.name} ${job.id}`; -} diff --git a/packages/backend/src/remote/activitypub/kernel/delete/index.ts b/packages/backend/src/remote/activitypub/kernel/delete/index.ts deleted file mode 100644 index c7064f553..000000000 --- a/packages/backend/src/remote/activitypub/kernel/delete/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -import deleteNote from './note.js'; -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import { IDelete, getApId, isTombstone, IObject, validPost, validActor } from '../../type.js'; -import { toSingle } from '@/prelude/array.js'; -import { deleteActor } from './actor.js'; - -/** - * 削除アクティビティを捌きます - */ -export default async (actor: CacheableRemoteUser, activity: IDelete): Promise => { - if ('actor' in activity && actor.uri !== activity.actor) { - throw new Error('invalid actor'); - } - - // 削除対象objectのtype - let formerType: string | undefined; - - if (typeof activity.object === 'string') { - // typeが不明だけど、どうせ消えてるのでremote resolveしない - formerType = undefined; - } else { - const object = activity.object as IObject; - if (isTombstone(object)) { - formerType = toSingle(object.formerType); - } else { - formerType = toSingle(object.type); - } - } - - const uri = getApId(activity.object); - - // type不明でもactorとobjectが同じならばそれはPersonに違いない - if (!formerType && actor.uri === uri) { - formerType = 'Person'; - } - - // それでもなかったらおそらくNote - if (!formerType) { - formerType = 'Note'; - } - - if (validPost.includes(formerType)) { - return await deleteNote(actor, uri); - } else if (validActor.includes(formerType)) { - return await deleteActor(actor, uri); - } else { - return `Unknown type ${formerType}`; - } -}; diff --git a/packages/backend/src/remote/activitypub/kernel/delete/note.ts b/packages/backend/src/remote/activitypub/kernel/delete/note.ts deleted file mode 100644 index 1f44c3556..000000000 --- a/packages/backend/src/remote/activitypub/kernel/delete/note.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import deleteNode from '@/services/note/delete.js'; -import { apLogger } from '../../logger.js'; -import DbResolver from '../../db-resolver.js'; -import { getApLock } from '@/misc/app-lock.js'; -import { deleteMessage } from '@/services/messages/delete.js'; - -const logger = apLogger; - -export default async function(actor: CacheableRemoteUser, uri: string): Promise { - logger.info(`Deleting the Note: ${uri}`); - - const unlock = await getApLock(uri); - - try { - const dbResolver = new DbResolver(); - const note = await dbResolver.getNoteFromApId(uri); - - if (note == null) { - const message = await dbResolver.getMessageFromApId(uri); - if (message == null) return 'message not found'; - - if (message.userId !== actor.id) { - return '投稿を削除しようとしているユーザーは投稿の作成者ではありません'; - } - - await deleteMessage(message); - - return 'ok: message deleted'; - } - - if (note.userId !== actor.id) { - return '投稿を削除しようとしているユーザーは投稿の作成者ではありません'; - } - - await deleteNode(actor, note); - return 'ok: note deleted'; - } finally { - unlock(); - } -} diff --git a/packages/backend/src/remote/activitypub/kernel/flag/index.ts b/packages/backend/src/remote/activitypub/kernel/flag/index.ts deleted file mode 100644 index aa2f1f536..000000000 --- a/packages/backend/src/remote/activitypub/kernel/flag/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import config from '@/config/index.js'; -import { IFlag, getApIds } from '../../type.js'; -import { AbuseUserReports, Users } from '@/models/index.js'; -import { In } from 'typeorm'; -import { genId } from '@/misc/gen-id.js'; - -export default async (actor: CacheableRemoteUser, activity: IFlag): Promise => { - // objectは `(User|Note) | (User|Note)[]` だけど、全パターンDBスキーマと対応させられないので - // 対象ユーザーは一番最初のユーザー として あとはコメントとして格納する - const uris = getApIds(activity.object); - - const userIds = uris.filter(uri => uri.startsWith(config.url + '/users/')).map(uri => uri.split('/').pop()!); - const users = await Users.findBy({ - id: In(userIds), - }); - if (users.length < 1) return `skip`; - - await AbuseUserReports.insert({ - id: genId(), - createdAt: new Date(), - targetUserId: users[0].id, - targetUserHost: users[0].host, - reporterId: actor.id, - reporterHost: actor.host, - comment: `${activity.content}\n${JSON.stringify(uris, null, 2)}`, - }); - - return `ok`; -}; diff --git a/packages/backend/src/remote/activitypub/kernel/follow.ts b/packages/backend/src/remote/activitypub/kernel/follow.ts deleted file mode 100644 index a9e92fa22..000000000 --- a/packages/backend/src/remote/activitypub/kernel/follow.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import follow from '@/services/following/create.js'; -import { IFollow } from '../type.js'; -import DbResolver from '../db-resolver.js'; - -export default async (actor: CacheableRemoteUser, activity: IFollow): Promise => { - const dbResolver = new DbResolver(); - const followee = await dbResolver.getUserFromApId(activity.object); - - if (followee == null) { - return `skip: followee not found`; - } - - if (followee.host != null) { - return `skip: フォローしようとしているユーザーはローカルユーザーではありません`; - } - - await follow(actor, followee, activity.id); - return `ok`; -}; diff --git a/packages/backend/src/remote/activitypub/kernel/index.ts b/packages/backend/src/remote/activitypub/kernel/index.ts deleted file mode 100644 index 254a12160..000000000 --- a/packages/backend/src/remote/activitypub/kernel/index.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { IObject, isCreate, isDelete, isUpdate, isRead, isFollow, isAccept, isReject, isAdd, isRemove, isAnnounce, isLike, isUndo, isBlock, isCollectionOrOrderedCollection, isCollection, isFlag } from '../type.js'; -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import create from './create/index.js'; -import performDeleteActivity from './delete/index.js'; -import performUpdateActivity from './update/index.js'; -import { performReadActivity } from './read.js'; -import follow from './follow.js'; -import undo from './undo/index.js'; -import like from './like.js'; -import announce from './announce/index.js'; -import accept from './accept/index.js'; -import reject from './reject/index.js'; -import add from './add/index.js'; -import remove from './remove/index.js'; -import block from './block/index.js'; -import flag from './flag/index.js'; -import { apLogger } from '../logger.js'; -import Resolver from '../resolver.js'; -import { toArray } from '@/prelude/array.js'; -import { Users } from '@/models/index.js'; - -export async function performActivity(actor: CacheableRemoteUser, activity: IObject) { - if (isCollectionOrOrderedCollection(activity)) { - const resolver = new Resolver(); - for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) { - const act = await resolver.resolve(item); - try { - await performOneActivity(actor, act); - } catch (err) { - if (err instanceof Error || typeof err === 'string') { - apLogger.error(err); - } - } - } - } else { - await performOneActivity(actor, activity); - } -} - -async function performOneActivity(actor: CacheableRemoteUser, activity: IObject): Promise { - if (actor.isSuspended) return; - - if (isCreate(activity)) { - await create(actor, activity); - } else if (isDelete(activity)) { - await performDeleteActivity(actor, activity); - } else if (isUpdate(activity)) { - await performUpdateActivity(actor, activity); - } else if (isRead(activity)) { - await performReadActivity(actor, activity); - } else if (isFollow(activity)) { - await follow(actor, activity); - } else if (isAccept(activity)) { - await accept(actor, activity); - } else if (isReject(activity)) { - await reject(actor, activity); - } else if (isAdd(activity)) { - await add(actor, activity).catch(err => apLogger.error(err)); - } else if (isRemove(activity)) { - await remove(actor, activity).catch(err => apLogger.error(err)); - } else if (isAnnounce(activity)) { - await announce(actor, activity); - } else if (isLike(activity)) { - await like(actor, activity); - } else if (isUndo(activity)) { - await undo(actor, activity); - } else if (isBlock(activity)) { - await block(actor, activity); - } else if (isFlag(activity)) { - await flag(actor, activity); - } else { - apLogger.warn(`unrecognized activity type: ${(activity as any).type}`); - } -} diff --git a/packages/backend/src/remote/activitypub/kernel/like.ts b/packages/backend/src/remote/activitypub/kernel/like.ts deleted file mode 100644 index 2b65ff738..000000000 --- a/packages/backend/src/remote/activitypub/kernel/like.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import { ILike, getApId } from '../type.js'; -import create from '@/services/note/reaction/create.js'; -import { fetchNote, extractEmojis } from '../models/note.js'; - -export default async (actor: CacheableRemoteUser, activity: ILike) => { - const targetUri = getApId(activity.object); - - const note = await fetchNote(targetUri); - if (!note) return `skip: target note not found ${targetUri}`; - - await extractEmojis(activity.tag || [], actor.host).catch(() => null); - - return await create(actor, note, activity._misskey_reaction || activity.content || activity.name).catch(e => { - if (e.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') { - return 'skip: already reacted'; - } else { - throw e; - } - }).then(() => 'ok'); -}; diff --git a/packages/backend/src/remote/activitypub/kernel/read.ts b/packages/backend/src/remote/activitypub/kernel/read.ts deleted file mode 100644 index f7b0bcecd..000000000 --- a/packages/backend/src/remote/activitypub/kernel/read.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import { IRead, getApId } from '../type.js'; -import { isSelfHost, extractDbHost } from '@/misc/convert-host.js'; -import { MessagingMessages } from '@/models/index.js'; -import { readUserMessagingMessage } from '../../../server/api/common/read-messaging-message.js'; - -export const performReadActivity = async (actor: CacheableRemoteUser, activity: IRead): Promise => { - const id = await getApId(activity.object); - - if (!isSelfHost(extractDbHost(id))) { - return `skip: Read to foreign host (${id})`; - } - - const messageId = id.split('/').pop(); - - const message = await MessagingMessages.findOneBy({ id: messageId }); - if (message == null) { - return `skip: message not found`; - } - - if (actor.id !== message.recipientId) { - return `skip: actor is not a message recipient`; - } - - await readUserMessagingMessage(message.recipientId!, message.userId, [message.id]); - return `ok: mark as read (${message.userId} => ${message.recipientId} ${message.id})`; -}; diff --git a/packages/backend/src/remote/activitypub/kernel/reject/follow.ts b/packages/backend/src/remote/activitypub/kernel/reject/follow.ts deleted file mode 100644 index 824ac69d7..000000000 --- a/packages/backend/src/remote/activitypub/kernel/reject/follow.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import { remoteReject } from '@/services/following/reject.js'; -import { IFollow } from '../../type.js'; -import DbResolver from '../../db-resolver.js'; -import { relayRejected } from '@/services/relay.js'; -import { Users } from '@/models/index.js'; - -export default async (actor: CacheableRemoteUser, activity: IFollow): Promise => { - // ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある - - const dbResolver = new DbResolver(); - const follower = await dbResolver.getUserFromApId(activity.actor); - - if (follower == null) { - return `skip: follower not found`; - } - - if (!Users.isLocalUser(follower)) { - return `skip: follower is not a local user`; - } - - // relay - const match = activity.id?.match(/follow-relay\/(\w+)/); - if (match) { - return await relayRejected(match[1]); - } - - await remoteReject(actor, follower); - return `ok`; -}; diff --git a/packages/backend/src/remote/activitypub/kernel/reject/index.ts b/packages/backend/src/remote/activitypub/kernel/reject/index.ts deleted file mode 100644 index 00f08842f..000000000 --- a/packages/backend/src/remote/activitypub/kernel/reject/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -import Resolver from '../../resolver.js'; -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import rejectFollow from './follow.js'; -import { IReject, isFollow, getApType } from '../../type.js'; -import { apLogger } from '../../logger.js'; - -const logger = apLogger; - -export default async (actor: CacheableRemoteUser, activity: IReject): Promise => { - const uri = activity.id || activity; - - logger.info(`Reject: ${uri}`); - - const resolver = new Resolver(); - - const object = await resolver.resolve(activity.object).catch(e => { - logger.error(`Resolution failed: ${e}`); - throw e; - }); - - if (isFollow(object)) return await rejectFollow(actor, object); - - return `skip: Unknown Reject type: ${getApType(object)}`; -}; diff --git a/packages/backend/src/remote/activitypub/kernel/remove/index.ts b/packages/backend/src/remote/activitypub/kernel/remove/index.ts deleted file mode 100644 index 11a994a83..000000000 --- a/packages/backend/src/remote/activitypub/kernel/remove/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import { IRemove } from '../../type.js'; -import { resolveNote } from '../../models/note.js'; -import { removePinned } from '@/services/i/pin.js'; - -export default async (actor: CacheableRemoteUser, activity: IRemove): Promise => { - if ('actor' in activity && actor.uri !== activity.actor) { - throw new Error('invalid actor'); - } - - if (activity.target == null) { - throw new Error('target is null'); - } - - if (activity.target === actor.featured) { - const note = await resolveNote(activity.object); - if (note == null) throw new Error('note not found'); - await removePinned(actor, note.id); - return; - } - - throw new Error(`unknown target: ${activity.target}`); -}; diff --git a/packages/backend/src/remote/activitypub/kernel/undo/accept.ts b/packages/backend/src/remote/activitypub/kernel/undo/accept.ts deleted file mode 100644 index a6e3929b0..000000000 --- a/packages/backend/src/remote/activitypub/kernel/undo/accept.ts +++ /dev/null @@ -1,27 +0,0 @@ -import unfollow from '@/services/following/delete.js'; -import cancelRequest from '@/services/following/requests/cancel.js'; -import { IAccept } from '../../type.js'; -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import { Followings } from '@/models/index.js'; -import DbResolver from '../../db-resolver.js'; - -export default async (actor: CacheableRemoteUser, activity: IAccept): Promise => { - const dbResolver = new DbResolver(); - - const follower = await dbResolver.getUserFromApId(activity.object); - if (follower == null) { - return `skip: follower not found`; - } - - const following = await Followings.findOneBy({ - followerId: follower.id, - followeeId: actor.id, - }); - - if (following) { - await unfollow(follower, actor); - return `ok: unfollowed`; - } - - return `skip: フォローされていない`; -}; diff --git a/packages/backend/src/remote/activitypub/kernel/undo/announce.ts b/packages/backend/src/remote/activitypub/kernel/undo/announce.ts deleted file mode 100644 index 417f39722..000000000 --- a/packages/backend/src/remote/activitypub/kernel/undo/announce.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Notes } from '@/models/index.js'; -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import { IAnnounce, getApId } from '../../type.js'; -import deleteNote from '@/services/note/delete.js'; - -export const undoAnnounce = async (actor: CacheableRemoteUser, activity: IAnnounce): Promise => { - const uri = getApId(activity); - - const note = await Notes.findOneBy({ - uri, - userId: actor.id, - }); - - if (!note) return 'skip: no such Announce'; - - await deleteNote(actor, note); - return 'ok: deleted'; -}; diff --git a/packages/backend/src/remote/activitypub/kernel/undo/block.ts b/packages/backend/src/remote/activitypub/kernel/undo/block.ts deleted file mode 100644 index 4ac669857..000000000 --- a/packages/backend/src/remote/activitypub/kernel/undo/block.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { IBlock } from '../../type.js'; -import unblock from '@/services/blocking/delete.js'; -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import DbResolver from '../../db-resolver.js'; -import { Users } from '@/models/index.js'; - -export default async (actor: CacheableRemoteUser, activity: IBlock): Promise => { - const dbResolver = new DbResolver(); - const blockee = await dbResolver.getUserFromApId(activity.object); - - if (blockee == null) { - return `skip: blockee not found`; - } - - if (blockee.host != null) { - return `skip: ブロック解除しようとしているユーザーはローカルユーザーではありません`; - } - - await unblock(await Users.findOneByOrFail({ id: actor.id }), blockee); - return `ok`; -}; diff --git a/packages/backend/src/remote/activitypub/kernel/undo/follow.ts b/packages/backend/src/remote/activitypub/kernel/undo/follow.ts deleted file mode 100644 index 6a43c1444..000000000 --- a/packages/backend/src/remote/activitypub/kernel/undo/follow.ts +++ /dev/null @@ -1,41 +0,0 @@ -import unfollow from '@/services/following/delete.js'; -import cancelRequest from '@/services/following/requests/cancel.js'; -import { IFollow } from '../../type.js'; -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import { FollowRequests, Followings } from '@/models/index.js'; -import DbResolver from '../../db-resolver.js'; - -export default async (actor: CacheableRemoteUser, activity: IFollow): Promise => { - const dbResolver = new DbResolver(); - - const followee = await dbResolver.getUserFromApId(activity.object); - if (followee == null) { - return `skip: followee not found`; - } - - if (followee.host != null) { - return `skip: フォロー解除しようとしているユーザーはローカルユーザーではありません`; - } - - const req = await FollowRequests.findOneBy({ - followerId: actor.id, - followeeId: followee.id, - }); - - const following = await Followings.findOneBy({ - followerId: actor.id, - followeeId: followee.id, - }); - - if (req) { - await cancelRequest(followee, actor); - return `ok: follow request canceled`; - } - - if (following) { - await unfollow(actor, followee); - return `ok: unfollowed`; - } - - return `skip: リクエストもフォローもされていない`; -}; diff --git a/packages/backend/src/remote/activitypub/kernel/undo/index.ts b/packages/backend/src/remote/activitypub/kernel/undo/index.ts deleted file mode 100644 index 27d433eb3..000000000 --- a/packages/backend/src/remote/activitypub/kernel/undo/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import { IUndo, isFollow, isBlock, isLike, isAnnounce, getApType, isAccept } from '../../type.js'; -import unfollow from './follow.js'; -import unblock from './block.js'; -import undoLike from './like.js'; -import undoAccept from './accept.js'; -import { undoAnnounce } from './announce.js'; -import Resolver from '../../resolver.js'; -import { apLogger } from '../../logger.js'; - -const logger = apLogger; - -export default async (actor: CacheableRemoteUser, activity: IUndo): Promise => { - if ('actor' in activity && actor.uri !== activity.actor) { - throw new Error('invalid actor'); - } - - const uri = activity.id || activity; - - logger.info(`Undo: ${uri}`); - - const resolver = new Resolver(); - - const object = await resolver.resolve(activity.object).catch(e => { - logger.error(`Resolution failed: ${e}`); - throw e; - }); - - if (isFollow(object)) return await unfollow(actor, object); - if (isBlock(object)) return await unblock(actor, object); - if (isLike(object)) return await undoLike(actor, object); - if (isAnnounce(object)) return await undoAnnounce(actor, object); - if (isAccept(object)) return await undoAccept(actor, object); - - return `skip: unknown object type ${getApType(object)}`; -}; diff --git a/packages/backend/src/remote/activitypub/kernel/undo/like.ts b/packages/backend/src/remote/activitypub/kernel/undo/like.ts deleted file mode 100644 index 01aeba1fb..000000000 --- a/packages/backend/src/remote/activitypub/kernel/undo/like.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import { ILike, getApId } from '../../type.js'; -import deleteReaction from '@/services/note/reaction/delete.js'; -import { fetchNote } from '../../models/note.js'; - -/** - * Process Undo.Like activity - */ -export default async (actor: CacheableRemoteUser, activity: ILike) => { - const targetUri = getApId(activity.object); - - const note = await fetchNote(targetUri); - if (!note) return `skip: target note not found ${targetUri}`; - - await deleteReaction(actor, note).catch(e => { - if (e.id === '60527ec9-b4cb-4a88-a6bd-32d3ad26817d') return; - throw e; - }); - - return `ok`; -}; diff --git a/packages/backend/src/remote/activitypub/kernel/update/index.ts b/packages/backend/src/remote/activitypub/kernel/update/index.ts deleted file mode 100644 index 9e8a81bb3..000000000 --- a/packages/backend/src/remote/activitypub/kernel/update/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import { getApType, IUpdate, isActor } from '../../type.js'; -import { apLogger } from '../../logger.js'; -import { updateQuestion } from '../../models/question.js'; -import Resolver from '../../resolver.js'; -import { updatePerson } from '../../models/person.js'; - -/** - * Updateアクティビティを捌きます - */ -export default async (actor: CacheableRemoteUser, activity: IUpdate): Promise => { - if ('actor' in activity && actor.uri !== activity.actor) { - return `skip: invalid actor`; - } - - apLogger.debug('Update'); - - const resolver = new Resolver(); - - const object = await resolver.resolve(activity.object).catch(e => { - apLogger.error(`Resolution failed: ${e}`); - throw e; - }); - - if (isActor(object)) { - await updatePerson(actor.uri!, resolver, object); - return `ok: Person updated`; - } else if (getApType(object) === 'Question') { - await updateQuestion(object).catch(e => console.log(e)); - return `ok: Question updated`; - } else { - return `skip: Unknown type: ${getApType(object)}`; - } -}; diff --git a/packages/backend/src/remote/activitypub/logger.ts b/packages/backend/src/remote/activitypub/logger.ts deleted file mode 100644 index cab51b3bf..000000000 --- a/packages/backend/src/remote/activitypub/logger.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { remoteLogger } from '../logger.js'; - -export const apLogger = remoteLogger.createSubLogger('ap', 'magenta'); diff --git a/packages/backend/src/remote/activitypub/misc/html-to-mfm.ts b/packages/backend/src/remote/activitypub/misc/html-to-mfm.ts deleted file mode 100644 index bb1ba7925..000000000 --- a/packages/backend/src/remote/activitypub/misc/html-to-mfm.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { IObject } from '../type.js'; -import { extractApHashtagObjects } from '../models/tag.js'; -import { fromHtml } from '../../../mfm/from-html.js'; - -export function htmlToMfm(html: string, tag?: IObject | IObject[]) { - const hashtagNames = extractApHashtagObjects(tag).map(x => x.name).filter((x): x is string => x != null); - - return fromHtml(html, hashtagNames); -} diff --git a/packages/backend/src/remote/activitypub/models/image.ts b/packages/backend/src/remote/activitypub/models/image.ts deleted file mode 100644 index 102b7b134..000000000 --- a/packages/backend/src/remote/activitypub/models/image.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { uploadFromUrl } from '@/services/drive/upload-from-url.js'; -import { CacheableRemoteUser, IRemoteUser } from '@/models/entities/user.js'; -import Resolver from '../resolver.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { apLogger } from '../logger.js'; -import { DriveFile } from '@/models/entities/drive-file.js'; -import { DriveFiles, Users } from '@/models/index.js'; -import { truncate } from '@/misc/truncate.js'; -import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js'; - -const logger = apLogger; - -/** - * Imageを作成します。 - */ -export async function createImage(actor: CacheableRemoteUser, value: any): Promise { - // 投稿者が凍結されていたらスキップ - if (actor.isSuspended) { - throw new Error('actor has been suspended'); - } - - const image = await new Resolver().resolve(value) as any; - - if (image.url == null) { - throw new Error('invalid image: url not privided'); - } - - logger.info(`Creating the Image: ${image.url}`); - - const instance = await fetchMeta(); - - let file = await uploadFromUrl({ - url: image.url, - user: actor, - uri: image.url, - sensitive: image.sensitive, - isLink: !instance.cacheRemoteFiles, - comment: truncate(image.name, DB_MAX_IMAGE_COMMENT_LENGTH) - }); - - if (file.isLink) { - // URLが異なっている場合、同じ画像が以前に異なるURLで登録されていたということなので、 - // URLを更新する - if (file.url !== image.url) { - await DriveFiles.update({ id: file.id }, { - url: image.url, - uri: image.url, - }); - - file = await DriveFiles.findOneByOrFail({ id: file.id }); - } - } - - return file; -} - -/** - * Imageを解決します。 - * - * Misskeyに対象のImageが登録されていればそれを返し、そうでなければ - * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 - */ -export async function resolveImage(actor: CacheableRemoteUser, value: any): Promise { - // TODO - - // リモートサーバーからフェッチしてきて登録 - return await createImage(actor, value); -} diff --git a/packages/backend/src/remote/activitypub/models/mention.ts b/packages/backend/src/remote/activitypub/models/mention.ts deleted file mode 100644 index 13f77424e..000000000 --- a/packages/backend/src/remote/activitypub/models/mention.ts +++ /dev/null @@ -1,24 +0,0 @@ -import promiseLimit from 'promise-limit'; -import { toArray, unique } from '@/prelude/array.js'; -import { CacheableUser, User } from '@/models/entities/user.js'; -import { IObject, isMention, IApMention } from '../type.js'; -import Resolver from '../resolver.js'; -import { resolvePerson } from './person.js'; - -export async function extractApMentions(tags: IObject | IObject[] | null | undefined) { - const hrefs = unique(extractApMentionObjects(tags).map(x => x.href as string)); - - const resolver = new Resolver(); - - const limit = promiseLimit(2); - const mentionedUsers = (await Promise.all( - hrefs.map(x => limit(() => resolvePerson(x, resolver).catch(() => null))), - )).filter((x): x is CacheableUser => x != null); - - return mentionedUsers; -} - -export function extractApMentionObjects(tags: IObject | IObject[] | null | undefined): IApMention[] { - if (tags == null) return []; - return toArray(tags).filter(isMention); -} diff --git a/packages/backend/src/remote/activitypub/models/note.ts b/packages/backend/src/remote/activitypub/models/note.ts deleted file mode 100644 index 5d63f2605..000000000 --- a/packages/backend/src/remote/activitypub/models/note.ts +++ /dev/null @@ -1,359 +0,0 @@ -import promiseLimit from 'promise-limit'; - -import config from '@/config/index.js'; -import Resolver from '../resolver.js'; -import post from '@/services/note/create.js'; -import { resolvePerson } from './person.js'; -import { resolveImage } from './image.js'; -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import { htmlToMfm } from '../misc/html-to-mfm.js'; -import { extractApHashtags } from './tag.js'; -import { unique, toArray, toSingle } from '@/prelude/array.js'; -import { extractPollFromQuestion } from './question.js'; -import vote from '@/services/note/polls/vote.js'; -import { apLogger } from '../logger.js'; -import { DriveFile } from '@/models/entities/drive-file.js'; -import { deliverQuestionUpdate } from '@/services/note/polls/update.js'; -import { extractDbHost, toPuny } from '@/misc/convert-host.js'; -import { Emojis, Polls, MessagingMessages } from '@/models/index.js'; -import { Note } from '@/models/entities/note.js'; -import { IObject, getOneApId, getApId, getOneApHrefNullable, validPost, IPost, isEmoji, getApType } from '../type.js'; -import { Emoji } from '@/models/entities/emoji.js'; -import { genId } from '@/misc/gen-id.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { getApLock } from '@/misc/app-lock.js'; -import { createMessage } from '@/services/messages/create.js'; -import { parseAudience } from '../audience.js'; -import { extractApMentions } from './mention.js'; -import DbResolver from '../db-resolver.js'; -import { StatusError } from '@/misc/fetch.js'; - -const logger = apLogger; - -export function validateNote(object: any, uri: string) { - const expectHost = extractDbHost(uri); - - if (object == null) { - return new Error('invalid Note: object is null'); - } - - if (!validPost.includes(getApType(object))) { - return new Error(`invalid Note: invalid object type ${getApType(object)}`); - } - - if (object.id && extractDbHost(object.id) !== expectHost) { - return new Error(`invalid Note: id has different host. expected: ${expectHost}, actual: ${extractDbHost(object.id)}`); - } - - if (object.attributedTo && extractDbHost(getOneApId(object.attributedTo)) !== expectHost) { - return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${extractDbHost(object.attributedTo)}`); - } - - return null; -} - -/** - * Noteをフェッチします。 - * - * Misskeyに対象のNoteが登録されていればそれを返します。 - */ -export async function fetchNote(object: string | IObject): Promise { - const dbResolver = new DbResolver(); - return await dbResolver.getNoteFromApId(object); -} - -/** - * Noteを作成します。 - */ -export async function createNote(value: string | IObject, resolver?: Resolver, silent = false): Promise { - if (resolver == null) resolver = new Resolver(); - - const object: any = await resolver.resolve(value); - - const entryUri = getApId(value); - const err = validateNote(object, entryUri); - if (err) { - logger.error(`${err.message}`, { - resolver: { - history: resolver.getHistory(), - }, - value: value, - object: object, - }); - throw new Error('invalid note'); - } - - const note: IPost = object; - - logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); - - logger.info(`Creating the Note: ${note.id}`); - - // 投稿者をフェッチ - const actor = await resolvePerson(getOneApId(note.attributedTo), resolver) as CacheableRemoteUser; - - // 投稿者が凍結されていたらスキップ - if (actor.isSuspended) { - throw new Error('actor has been suspended'); - } - - const noteAudience = await parseAudience(actor, note.to, note.cc); - let visibility = noteAudience.visibility; - const visibleUsers = noteAudience.visibleUsers; - - // Audience (to, cc) が指定されてなかった場合 - if (visibility === 'specified' && visibleUsers.length === 0) { - if (typeof value === 'string') { // 入力がstringならばresolverでGETが発生している - // こちらから匿名GET出来たものならばpublic - visibility = 'public'; - } - } - - let isTalk = note._misskey_talk && visibility === 'specified'; - - const apMentions = await extractApMentions(note.tag); - const apHashtags = await extractApHashtags(note.tag); - - // 添付ファイル - // TODO: attachmentは必ずしもImageではない - // TODO: attachmentは必ずしも配列ではない - // Noteがsensitiveなら添付もsensitiveにする - const limit = promiseLimit(2); - - note.attachment = Array.isArray(note.attachment) ? note.attachment : note.attachment ? [note.attachment] : []; - const files = note.attachment - .map(attach => attach.sensitive = note.sensitive) - ? (await Promise.all(note.attachment.map(x => limit(() => resolveImage(actor, x)) as Promise))) - .filter(image => image != null) - : []; - - // リプライ - const reply: Note | null = note.inReplyTo - ? await resolveNote(note.inReplyTo, resolver).then(x => { - if (x == null) { - logger.warn(`Specified inReplyTo, but nout found`); - throw new Error('inReplyTo not found'); - } else { - return x; - } - }).catch(async e => { - // トークだったらinReplyToのエラーは無視 - const uri = getApId(note.inReplyTo); - if (uri.startsWith(config.url + '/')) { - const id = uri.split('/').pop(); - const talk = await MessagingMessages.findOneBy({ id }); - if (talk) { - isTalk = true; - return null; - } - } - - logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${e.statusCode || e}`); - throw e; - }) - : null; - - // 引用 - let quote: Note | undefined | null; - - if (note._misskey_quote || note.quoteUrl) { - const tryResolveNote = async (uri: string): Promise<{ - status: 'ok'; - res: Note | null; - } | { - status: 'permerror' | 'temperror'; - }> => { - if (typeof uri !== 'string' || !uri.match(/^https?:/)) return { status: 'permerror' }; - try { - const res = await resolveNote(uri); - if (res) { - return { - status: 'ok', - res, - }; - } else { - return { - status: 'permerror', - }; - } - } catch (e) { - return { - status: (e instanceof StatusError && e.isClientError) ? 'permerror' : 'temperror', - }; - } - }; - - const uris = unique([note._misskey_quote, note.quoteUrl].filter((x): x is string => typeof x === 'string')); - const results = await Promise.all(uris.map(uri => tryResolveNote(uri))); - - quote = results.filter((x): x is { status: 'ok', res: Note | null } => x.status === 'ok').map(x => x.res).find(x => x); - if (!quote) { - if (results.some(x => x.status === 'temperror')) { - throw 'quote resolve failed'; - } - } - } - - 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 = htmlToMfm(note.content, note.tag); - } - - // vote - if (reply && reply.hasPoll) { - const poll = await Polls.findOneByOrFail({ noteId: reply.id }); - - const tryCreateVote = async (name: string, index: number): Promise => { - if (poll.expiresAt && Date.now() > new Date(poll.expiresAt).getTime()) { - logger.warn(`vote to expired poll from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`); - } else if (index >= 0) { - logger.info(`vote from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`); - await vote(actor, reply, index); - - // リモートフォロワーにUpdate配信 - deliverQuestionUpdate(reply.id); - } - return null; - }; - - if (note.name) { - return await tryCreateVote(note.name, poll.choices.findIndex(x => x === note.name)); - } - } - - const emojis = await extractEmojis(note.tag || [], actor.host).catch(e => { - logger.info(`extractEmojis: ${e}`); - return [] as Emoji[]; - }); - - const apEmojis = emojis.map(emoji => emoji.name); - - const poll = await extractPollFromQuestion(note, resolver).catch(() => undefined); - - if (isTalk) { - for (const recipient of visibleUsers) { - await createMessage(actor, recipient, undefined, text || undefined, (files && files.length > 0) ? files[0] : null, object.id); - return null; - } - } - - return await post(actor, { - createdAt: note.published ? new Date(note.published) : null, - files, - reply, - renote: quote, - name: note.name, - cw, - text, - localOnly: false, - visibility, - visibleUsers, - apMentions, - apHashtags, - apEmojis, - poll, - uri: note.id, - url: getOneApHrefNullable(note.url), - }, silent); -} - -/** - * Noteを解決します。 - * - * Misskeyに対象のNoteが登録されていればそれを返し、そうでなければ - * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 - */ -export async function resolveNote(value: string | IObject, resolver?: Resolver): Promise { - const uri = typeof value === 'string' ? value : value.id; - if (uri == null) throw new Error('missing uri'); - - // ブロックしてたら中断 - const meta = await fetchMeta(); - if (meta.blockedHosts.includes(extractDbHost(uri))) throw { statusCode: 451 }; - - const unlock = await getApLock(uri); - - try { - //#region このサーバーに既に登録されていたらそれを返す - const exist = await fetchNote(uri); - - if (exist) { - return exist; - } - //#endregion - - if (uri.startsWith(config.url)) { - throw new StatusError('cannot resolve local note', 400, 'cannot resolve local note'); - } - - // リモートサーバーからフェッチしてきて登録 - // ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにノートが生成されるが - // 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。 - return await createNote(uri, resolver, true); - } finally { - unlock(); - } -} - -export async function extractEmojis(tags: IObject | IObject[], host: string): Promise { - host = toPuny(host); - - if (!tags) return []; - - const eomjiTags = toArray(tags).filter(isEmoji); - - return await Promise.all(eomjiTags.map(async tag => { - const name = tag.name!.replace(/^:/, '').replace(/:$/, ''); - tag.icon = toSingle(tag.icon); - - const exists = await Emojis.findOneBy({ - host, - name, - }); - - if (exists) { - if ((tag.updated != null && exists.updatedAt == null) - || (tag.id != null && exists.uri == null) - || (tag.updated != null && exists.updatedAt != null && new Date(tag.updated) > exists.updatedAt) - || (tag.icon!.url !== exists.originalUrl) - ) { - await Emojis.update({ - host, - name, - }, { - uri: tag.id, - originalUrl: tag.icon!.url, - publicUrl: tag.icon!.url, - updatedAt: new Date(), - }); - - return await Emojis.findOneBy({ - host, - name, - }) as Emoji; - } - - return exists; - } - - logger.info(`register emoji host=${host}, name=${name}`); - - return await Emojis.insert({ - id: genId(), - host, - name, - uri: tag.id, - originalUrl: tag.icon!.url, - publicUrl: tag.icon!.url, - updatedAt: new Date(), - aliases: [], - } as Partial).then(x => Emojis.findOneByOrFail(x.identifiers[0])); - })); -} diff --git a/packages/backend/src/remote/activitypub/models/person.ts b/packages/backend/src/remote/activitypub/models/person.ts deleted file mode 100644 index 6097e3b6e..000000000 --- a/packages/backend/src/remote/activitypub/models/person.ts +++ /dev/null @@ -1,504 +0,0 @@ -import { URL } from 'node:url'; -import promiseLimit from 'promise-limit'; - -import config from '@/config/index.js'; -import { registerOrFetchInstanceDoc } from '@/services/register-or-fetch-instance-doc.js'; -import { Note } from '@/models/entities/note.js'; -import { updateUsertags } from '@/services/update-hashtag.js'; -import { Users, Instances, DriveFiles, Followings, UserProfiles, UserPublickeys } from '@/models/index.js'; -import { User, IRemoteUser, CacheableUser } from '@/models/entities/user.js'; -import { Emoji } from '@/models/entities/emoji.js'; -import { UserNotePining } from '@/models/entities/user-note-pining.js'; -import { genId } from '@/misc/gen-id.js'; -import { instanceChart, usersChart } from '@/services/chart/index.js'; -import { UserPublickey } from '@/models/entities/user-publickey.js'; -import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; -import { toPuny } from '@/misc/convert-host.js'; -import { UserProfile } from '@/models/entities/user-profile.js'; -import { toArray } from '@/prelude/array.js'; -import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata.js'; -import { normalizeForSearch } from '@/misc/normalize-for-search.js'; -import { truncate } from '@/misc/truncate.js'; -import { StatusError } from '@/misc/fetch.js'; -import { uriPersonCache } from '@/services/user-cache.js'; -import { publishInternalEvent } from '@/services/stream.js'; -import { db } from '@/db/postgre.js'; -import { apLogger } from '../logger.js'; -import { htmlToMfm } from '../misc/html-to-mfm.js'; -import { fromHtml } from '../../../mfm/from-html.js'; -import { isCollectionOrOrderedCollection, isCollection, IActor, getApId, getOneApHrefNullable, IObject, isPropertyValue, IApPropertyValue, getApType, isActor } from '../type.js'; -import Resolver from '../resolver.js'; -import { extractApHashtags } from './tag.js'; -import { resolveNote, extractEmojis } from './note.js'; -import { resolveImage } from './image.js'; - -const logger = apLogger; - -const nameLength = 128; -const summaryLength = 2048; - -/** - * Validate and convert to actor object - * @param x Fetched object - * @param uri Fetch target URI - */ -function validateActor(x: IObject, uri: string): IActor { - const expectHost = toPuny(new URL(uri).hostname); - - if (x == null) { - throw new Error('invalid Actor: object is null'); - } - - if (!isActor(x)) { - throw new Error(`invalid Actor type '${x.type}'`); - } - - if (!(typeof x.id === 'string' && x.id.length > 0)) { - throw new Error('invalid Actor: wrong id'); - } - - if (!(typeof x.inbox === 'string' && x.inbox.length > 0)) { - throw new Error('invalid Actor: wrong inbox'); - } - - if (!(typeof x.preferredUsername === 'string' && x.preferredUsername.length > 0 && x.preferredUsername.length <= 128 && /^\w([\w-.]*\w)?$/.test(x.preferredUsername))) { - throw new Error('invalid Actor: wrong username'); - } - - // These fields are only informational, and some AP software allows these - // fields to be very long. If they are too long, we cut them off. This way - // we can at least see these users and their activities. - if (x.name) { - if (!(typeof x.name === 'string' && x.name.length > 0)) { - throw new Error('invalid Actor: wrong name'); - } - x.name = truncate(x.name, nameLength); - } - if (x.summary) { - if (!(typeof x.summary === 'string' && x.summary.length > 0)) { - throw new Error('invalid Actor: wrong summary'); - } - x.summary = truncate(x.summary, summaryLength); - } - - const idHost = toPuny(new URL(x.id!).hostname); - if (idHost !== expectHost) { - throw new Error('invalid Actor: id has different host'); - } - - if (x.publicKey) { - if (typeof x.publicKey.id !== 'string') { - throw new Error('invalid Actor: publicKey.id is not a string'); - } - - const publicKeyIdHost = toPuny(new URL(x.publicKey.id).hostname); - if (publicKeyIdHost !== expectHost) { - throw new Error('invalid Actor: publicKey.id has different host'); - } - } - - return x; -} - -/** - * Personをフェッチします。 - * - * Misskeyに対象のPersonが登録されていればそれを返します。 - */ -export async function fetchPerson(uri: string, resolver?: Resolver): Promise { - if (typeof uri !== 'string') throw new Error('uri is not string'); - - const cached = uriPersonCache.get(uri); - if (cached) return cached; - - // URIがこのサーバーを指しているならデータベースからフェッチ - if (uri.startsWith(config.url + '/')) { - const id = uri.split('/').pop(); - const u = await Users.findOneBy({ id }); - if (u) uriPersonCache.set(uri, u); - return u; - } - - //#region このサーバーに既に登録されていたらそれを返す - const exist = await Users.findOneBy({ uri }); - - if (exist) { - uriPersonCache.set(uri, exist); - return exist; - } - //#endregion - - return null; -} - -/** - * Personを作成します。 - */ -export async function createPerson(uri: string, resolver?: Resolver): Promise { - if (typeof uri !== 'string') throw new Error('uri is not string'); - - if (uri.startsWith(config.url)) { - throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user'); - } - - if (resolver == null) resolver = new Resolver(); - - const object = await resolver.resolve(uri) as any; - - const person = validateActor(object, uri); - - logger.info(`Creating the Person: ${person.id}`); - - const host = toPuny(new URL(object.id).hostname); - - const { fields } = analyzeAttachments(person.attachment || []); - - const tags = extractApHashtags(person.tag).map(tag => normalizeForSearch(tag)).splice(0, 32); - - const isBot = getApType(object) === 'Service'; - - const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); - - // Create user - let user: IRemoteUser; - try { - // Start transaction - await db.transaction(async transactionalEntityManager => { - user = await transactionalEntityManager.save(new User({ - id: genId(), - avatarId: null, - bannerId: null, - createdAt: new Date(), - lastFetchedAt: new Date(), - name: truncate(person.name, nameLength), - isLocked: !!person.manuallyApprovesFollowers, - isExplorable: !!person.discoverable, - username: person.preferredUsername, - usernameLower: person.preferredUsername!.toLowerCase(), - host, - inbox: person.inbox, - sharedInbox: person.sharedInbox || (person.endpoints ? person.endpoints.sharedInbox : undefined), - followersUri: person.followers ? getApId(person.followers) : undefined, - featured: person.featured ? getApId(person.featured) : undefined, - uri: person.id, - tags, - isBot, - isCat: (person as any).isCat === true, - showTimelineReplies: false, - })) as IRemoteUser; - - await transactionalEntityManager.save(new UserProfile({ - userId: user.id, - description: person.summary ? htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null, - url: getOneApHrefNullable(person.url), - fields, - birthday: bday ? bday[0] : null, - location: person['vcard:Address'] || null, - userHost: host, - })); - - if (person.publicKey) { - await transactionalEntityManager.save(new UserPublickey({ - userId: user.id, - keyId: person.publicKey.id, - keyPem: person.publicKey.publicKeyPem, - })); - } - }); - } catch (e) { - // duplicate key error - if (isDuplicateKeyValueError(e)) { - // /users/@a => /users/:id のように入力がaliasなときにエラーになることがあるのを対応 - const u = await Users.findOneBy({ - uri: person.id, - }); - - if (u) { - user = u as IRemoteUser; - } else { - throw new Error('already registered'); - } - } else { - logger.error(e instanceof Error ? e : new Error(e as string)); - throw e; - } - } - - // Register host - registerOrFetchInstanceDoc(host).then(i => { - Instances.increment({ id: i.id }, 'usersCount', 1); - instanceChart.newUser(i.host); - fetchInstanceMetadata(i); - }); - - usersChart.update(user!, true); - - // ハッシュタグ更新 - updateUsertags(user!, tags); - - //#region アバターとヘッダー画像をフェッチ - const [avatar, banner] = await Promise.all([ - person.icon, - person.image, - ].map(img => - img == null - ? Promise.resolve(null) - : resolveImage(user!, img).catch(() => null), - )); - - const avatarId = avatar ? avatar.id : null; - const bannerId = banner ? banner.id : null; - - await Users.update(user!.id, { - avatarId, - bannerId, - }); - - user!.avatarId = avatarId; - user!.bannerId = bannerId; - //#endregion - - //#region カスタム絵文字取得 - const emojis = await extractEmojis(person.tag || [], host).catch(e => { - logger.info(`extractEmojis: ${e}`); - return [] as Emoji[]; - }); - - const emojiNames = emojis.map(emoji => emoji.name); - - await Users.update(user!.id, { - emojis: emojiNames, - }); - //#endregion - - await updateFeatured(user!.id).catch(err => logger.error(err)); - - return user!; -} - -/** - * Personの情報を更新します。 - * Misskeyに対象のPersonが登録されていなければ無視します。 - * @param uri URI of Person - * @param resolver Resolver - * @param hint Hint of Person object (この値が正当なPersonの場合、Remote resolveをせずに更新に利用します) - */ -export async function updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject): Promise { - if (typeof uri !== 'string') throw new Error('uri is not string'); - - // URIがこのサーバーを指しているならスキップ - if (uri.startsWith(config.url + '/')) { - return; - } - - //#region このサーバーに既に登録されているか - const exist = await Users.findOneBy({ uri }) as IRemoteUser; - - if (exist == null) { - return; - } - //#endregion - - if (resolver == null) resolver = new Resolver(); - - const object = hint || await resolver.resolve(uri); - - const person = validateActor(object, uri); - - logger.info(`Updating the Person: ${person.id}`); - - // アバターとヘッダー画像をフェッチ - const [avatar, banner] = await Promise.all([ - person.icon, - person.image, - ].map(img => - img == null - ? Promise.resolve(null) - : resolveImage(exist, img).catch(() => null), - )); - - // カスタム絵文字取得 - const emojis = await extractEmojis(person.tag || [], exist.host).catch(e => { - logger.info(`extractEmojis: ${e}`); - return [] as Emoji[]; - }); - - const emojiNames = emojis.map(emoji => emoji.name); - - const { fields } = analyzeAttachments(person.attachment || []); - - const tags = extractApHashtags(person.tag).map(tag => normalizeForSearch(tag)).splice(0, 32); - - const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); - - const updates = { - lastFetchedAt: new Date(), - inbox: person.inbox, - sharedInbox: person.sharedInbox || (person.endpoints ? person.endpoints.sharedInbox : undefined), - followersUri: person.followers ? getApId(person.followers) : undefined, - featured: person.featured, - emojis: emojiNames, - name: truncate(person.name, nameLength), - tags, - isBot: getApType(object) === 'Service', - isCat: (person as any).isCat === true, - isLocked: !!person.manuallyApprovesFollowers, - isExplorable: !!person.discoverable, - } as Partial; - - if (avatar) { - updates.avatarId = avatar.id; - } - - if (banner) { - updates.bannerId = banner.id; - } - - // Update user - await Users.update(exist.id, updates); - - if (person.publicKey) { - await UserPublickeys.update({ userId: exist.id }, { - keyId: person.publicKey.id, - keyPem: person.publicKey.publicKeyPem, - }); - } - - await UserProfiles.update({ userId: exist.id }, { - url: getOneApHrefNullable(person.url), - fields, - description: person.summary ? htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null, - birthday: bday ? bday[0] : null, - location: person['vcard:Address'] || null, - }); - - publishInternalEvent('remoteUserUpdated', { id: exist.id }); - - // ハッシュタグ更新 - updateUsertags(exist, tags); - - // 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする - await Followings.update({ - followerId: exist.id, - }, { - followerSharedInbox: person.sharedInbox || (person.endpoints ? person.endpoints.sharedInbox : undefined), - }); - - await updateFeatured(exist.id).catch(err => logger.error(err)); -} - -/** - * Personを解決します。 - * - * Misskeyに対象のPersonが登録されていればそれを返し、そうでなければ - * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 - */ -export async function resolvePerson(uri: string, resolver?: Resolver): Promise { - if (typeof uri !== 'string') throw new Error('uri is not string'); - - //#region このサーバーに既に登録されていたらそれを返す - const exist = await fetchPerson(uri); - - if (exist) { - return exist; - } - //#endregion - - // リモートサーバーからフェッチしてきて登録 - if (resolver == null) resolver = new Resolver(); - return await createPerson(uri, resolver); -} - -const services: { - [x: string]: (id: string, username: string) => any - } = { - 'misskey:authentication:twitter': (userId, screenName) => ({ userId, screenName }), - 'misskey:authentication:github': (id, login) => ({ id, login }), - 'misskey:authentication:discord': (id, name) => $discord(id, name), - }; - -const $discord = (id: string, name: string) => { - if (typeof name !== 'string') { - name = 'unknown#0000'; - } - const [username, discriminator] = name.split('#'); - return { id, username, discriminator }; -}; - -function addService(target: { [x: string]: any }, source: IApPropertyValue) { - const service = services[source.name]; - - if (typeof source.value !== 'string') { - source.value = 'unknown'; - } - - const [id, username] = source.value.split('@'); - - if (service) { - target[source.name.split(':')[2]] = service(id, username); - } -} - -export function analyzeAttachments(attachments: IObject | IObject[] | undefined) { - const fields: { - name: string, - value: string - }[] = []; - const services: { [x: string]: any } = {}; - - if (Array.isArray(attachments)) { - for (const attachment of attachments.filter(isPropertyValue)) { - if (isPropertyValue(attachment.identifier)) { - addService(services, attachment.identifier); - } else { - fields.push({ - name: attachment.name, - value: fromHtml(attachment.value), - }); - } - } - } - - return { fields, services }; -} - -export async function updateFeatured(userId: User['id']) { - const user = await Users.findOneByOrFail({ id: userId }); - if (!Users.isRemoteUser(user)) return; - if (!user.featured) return; - - logger.info(`Updating the featured: ${user.uri}`); - - const resolver = new Resolver(); - - // Resolve to (Ordered)Collection Object - const collection = await resolver.resolveCollection(user.featured); - if (!isCollectionOrOrderedCollection(collection)) throw new Error('Object is not Collection or OrderedCollection'); - - // Resolve to Object(may be Note) arrays - const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems; - const items = await Promise.all(toArray(unresolvedItems).map(x => resolver.resolve(x))); - - // Resolve and regist Notes - const limit = promiseLimit(2); - const featuredNotes = await Promise.all(items - .filter(item => getApType(item) === 'Note') // TODO: Noteでなくてもいいかも - .slice(0, 5) - .map(item => limit(() => resolveNote(item, resolver)))); - - await db.transaction(async transactionalEntityManager => { - await transactionalEntityManager.delete(UserNotePining, { userId: user.id }); - - // とりあえずidを別の時間で生成して順番を維持 - let td = 0; - for (const note of featuredNotes.filter(note => note != null)) { - td -= 1000; - transactionalEntityManager.insert(UserNotePining, { - id: genId(new Date(Date.now() + td)), - createdAt: new Date(), - userId: user.id, - noteId: note!.id, - }); - } - }); -} diff --git a/packages/backend/src/remote/activitypub/models/question.ts b/packages/backend/src/remote/activitypub/models/question.ts deleted file mode 100644 index f0321fdf2..000000000 --- a/packages/backend/src/remote/activitypub/models/question.ts +++ /dev/null @@ -1,83 +0,0 @@ -import config from '@/config/index.js'; -import Resolver from '../resolver.js'; -import { IObject, IQuestion, isQuestion } from '../type.js'; -import { apLogger } from '../logger.js'; -import { Notes, Polls } from '@/models/index.js'; -import { IPoll } from '@/models/entities/poll.js'; - -export async function extractPollFromQuestion(source: string | IObject, resolver?: Resolver): Promise { - if (resolver == null) resolver = new Resolver(); - - const question = await resolver.resolve(source); - - if (!isQuestion(question)) { - throw new Error('invalid type'); - } - - const multiple = !question.oneOf; - const expiresAt = question.endTime ? new Date(question.endTime) : question.closed ? new Date(question.closed) : null; - - if (multiple && !question.anyOf) { - throw new Error('invalid question'); - } - - const choices = question[multiple ? 'anyOf' : 'oneOf']! - .map((x, i) => x.name!); - - const votes = question[multiple ? 'anyOf' : 'oneOf']! - .map((x, i) => x.replies && x.replies.totalItems || x._misskey_votes || 0); - - return { - choices, - votes, - multiple, - expiresAt, - }; -} - -/** - * Update votes of Question - * @param uri URI of AP Question object - * @returns true if updated - */ -export async function updateQuestion(value: any) { - const uri = typeof value === 'string' ? value : value.id; - - // URIがこのサーバーを指しているならスキップ - if (uri.startsWith(config.url + '/')) throw new Error('uri points local'); - - //#region このサーバーに既に登録されているか - const note = await Notes.findOneBy({ uri }); - if (note == null) throw new Error('Question is not registed'); - - const poll = await Polls.findOneBy({ noteId: note.id }); - if (poll == null) throw new Error('Question is not registed'); - //#endregion - - // resolve new Question object - const resolver = new Resolver(); - const question = await resolver.resolve(value) as IQuestion; - apLogger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`); - - if (question.type !== 'Question') throw new Error('object is not a Question'); - - const apChoices = question.oneOf || question.anyOf; - - let changed = false; - - for (const choice of poll.choices) { - const oldCount = poll.votes[poll.choices.indexOf(choice)]; - const newCount = apChoices!.filter(ap => ap.name === choice)[0].replies!.totalItems; - - if (oldCount !== newCount) { - changed = true; - poll.votes[poll.choices.indexOf(choice)] = newCount; - } - } - - await Polls.update({ noteId: note.id }, { - votes: poll.votes, - }); - - return changed; -} diff --git a/packages/backend/src/remote/activitypub/perform.ts b/packages/backend/src/remote/activitypub/perform.ts deleted file mode 100644 index a3c10ba94..000000000 --- a/packages/backend/src/remote/activitypub/perform.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { IObject } from './type.js'; -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import { performActivity } from './kernel/index.js'; -import { updatePerson } from './models/person.js'; - -export default async (actor: CacheableRemoteUser, activity: IObject): Promise => { - await performActivity(actor, activity); - - // ついでにリモートユーザーの情報が古かったら更新しておく - if (actor.uri) { - if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { - setImmediate(() => { - updatePerson(actor.uri!); - }); - } - } -}; diff --git a/packages/backend/src/remote/activitypub/renderer/accept.ts b/packages/backend/src/remote/activitypub/renderer/accept.ts deleted file mode 100644 index cb01f6a91..000000000 --- a/packages/backend/src/remote/activitypub/renderer/accept.ts +++ /dev/null @@ -1,8 +0,0 @@ -import config from '@/config/index.js'; -import { User } from '@/models/entities/user.js'; - -export default (object: any, user: { id: User['id']; host: null }) => ({ - type: 'Accept', - actor: `${config.url}/users/${user.id}`, - object, -}); diff --git a/packages/backend/src/remote/activitypub/renderer/add.ts b/packages/backend/src/remote/activitypub/renderer/add.ts deleted file mode 100644 index ec4788429..000000000 --- a/packages/backend/src/remote/activitypub/renderer/add.ts +++ /dev/null @@ -1,9 +0,0 @@ -import config from '@/config/index.js'; -import { ILocalUser } from '@/models/entities/user.js'; - -export default (user: ILocalUser, target: any, object: any) => ({ - type: 'Add', - actor: `${config.url}/users/${user.id}`, - target, - object, -}); diff --git a/packages/backend/src/remote/activitypub/renderer/announce.ts b/packages/backend/src/remote/activitypub/renderer/announce.ts deleted file mode 100644 index 2709fea51..000000000 --- a/packages/backend/src/remote/activitypub/renderer/announce.ts +++ /dev/null @@ -1,29 +0,0 @@ -import config from '@/config/index.js'; -import { Note } from '@/models/entities/note.js'; - -export default (object: any, note: Note) => { - const attributedTo = `${config.url}/users/${note.userId}`; - - let to: string[] = []; - let cc: string[] = []; - - if (note.visibility === 'public') { - to = ['https://www.w3.org/ns/activitystreams#Public']; - cc = [`${attributedTo}/followers`]; - } else if (note.visibility === 'home') { - to = [`${attributedTo}/followers`]; - cc = ['https://www.w3.org/ns/activitystreams#Public']; - } else { - return null; - } - - return { - id: `${config.url}/notes/${note.id}/activity`, - actor: `${config.url}/users/${note.userId}`, - type: 'Announce', - published: note.createdAt.toISOString(), - to, - cc, - object, - }; -}; diff --git a/packages/backend/src/remote/activitypub/renderer/block.ts b/packages/backend/src/remote/activitypub/renderer/block.ts deleted file mode 100644 index 802d7280b..000000000 --- a/packages/backend/src/remote/activitypub/renderer/block.ts +++ /dev/null @@ -1,20 +0,0 @@ -import config from '@/config/index.js'; -import { Blocking } from '@/models/entities/blocking.js'; - -/** - * Renders a block into its ActivityPub representation. - * - * @param block The block to be rendered. The blockee relation must be loaded. - */ -export function renderBlock(block: Blocking) { - if (block.blockee?.uri == null) { - throw new Error('renderBlock: missing blockee uri'); - } - - return { - type: 'Block', - id: `${config.url}/blocks/${block.id}`, - actor: `${config.url}/users/${block.blockerId}`, - object: block.blockee.uri, - }; -} diff --git a/packages/backend/src/remote/activitypub/renderer/create.ts b/packages/backend/src/remote/activitypub/renderer/create.ts deleted file mode 100644 index 281a3cb2a..000000000 --- a/packages/backend/src/remote/activitypub/renderer/create.ts +++ /dev/null @@ -1,17 +0,0 @@ -import config from '@/config/index.js'; -import { Note } from '@/models/entities/note.js'; - -export default (object: any, note: Note) => { - const activity = { - id: `${config.url}/notes/${note.id}/activity`, - actor: `${config.url}/users/${note.userId}`, - type: 'Create', - published: note.createdAt.toISOString(), - object, - } as any; - - if (object.to) activity.to = object.to; - if (object.cc) activity.cc = object.cc; - - return activity; -}; diff --git a/packages/backend/src/remote/activitypub/renderer/delete.ts b/packages/backend/src/remote/activitypub/renderer/delete.ts deleted file mode 100644 index 4edd3a880..000000000 --- a/packages/backend/src/remote/activitypub/renderer/delete.ts +++ /dev/null @@ -1,9 +0,0 @@ -import config from '@/config/index.js'; -import { User } from '@/models/entities/user.js'; - -export default (object: any, user: { id: User['id']; host: null }) => ({ - type: 'Delete', - actor: `${config.url}/users/${user.id}`, - object, - published: new Date().toISOString(), -}); diff --git a/packages/backend/src/remote/activitypub/renderer/document.ts b/packages/backend/src/remote/activitypub/renderer/document.ts deleted file mode 100644 index c973de4c4..000000000 --- a/packages/backend/src/remote/activitypub/renderer/document.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { DriveFile } from '@/models/entities/drive-file.js'; -import { DriveFiles } from '@/models/index.js'; - -export default (file: DriveFile) => ({ - type: 'Document', - mediaType: file.type, - url: DriveFiles.getPublicUrl(file), - name: file.comment, -}); diff --git a/packages/backend/src/remote/activitypub/renderer/emoji.ts b/packages/backend/src/remote/activitypub/renderer/emoji.ts deleted file mode 100644 index 0bf15eefd..000000000 --- a/packages/backend/src/remote/activitypub/renderer/emoji.ts +++ /dev/null @@ -1,14 +0,0 @@ -import config from '@/config/index.js'; -import { Emoji } from '@/models/entities/emoji.js'; - -export default (emoji: Emoji) => ({ - id: `${config.url}/emojis/${emoji.name}`, - type: 'Emoji', - name: `:${emoji.name}:`, - updated: emoji.updatedAt != null ? emoji.updatedAt.toISOString() : new Date().toISOString, - icon: { - type: 'Image', - mediaType: emoji.type || 'image/png', - url: emoji.publicUrl || emoji.originalUrl, // || emoji.originalUrl してるのは後方互換性のため - }, -}); diff --git a/packages/backend/src/remote/activitypub/renderer/flag.ts b/packages/backend/src/remote/activitypub/renderer/flag.ts deleted file mode 100644 index 58eadddba..000000000 --- a/packages/backend/src/remote/activitypub/renderer/flag.ts +++ /dev/null @@ -1,15 +0,0 @@ -import config from '@/config/index.js'; -import { IObject, IActivity } from '@/remote/activitypub/type.js'; -import { ILocalUser, IRemoteUser } from '@/models/entities/user.js'; -import { getInstanceActor } from '@/services/instance-actor.js'; - -// to anonymise reporters, the reporting actor must be a system user -// object has to be a uri or array of uris -export const renderFlag = (user: ILocalUser, object: [string], content: string) => { - return { - type: 'Flag', - actor: `${config.url}/users/${user.id}`, - content, - object, - }; -}; diff --git a/packages/backend/src/remote/activitypub/renderer/follow-relay.ts b/packages/backend/src/remote/activitypub/renderer/follow-relay.ts deleted file mode 100644 index 2c9678090..000000000 --- a/packages/backend/src/remote/activitypub/renderer/follow-relay.ts +++ /dev/null @@ -1,14 +0,0 @@ -import config from '@/config/index.js'; -import { Relay } from '@/models/entities/relay.js'; -import { ILocalUser } from '@/models/entities/user.js'; - -export function renderFollowRelay(relay: Relay, relayActor: ILocalUser) { - const follow = { - id: `${config.url}/activities/follow-relay/${relay.id}`, - type: 'Follow', - actor: `${config.url}/users/${relayActor.id}`, - object: 'https://www.w3.org/ns/activitystreams#Public', - }; - - return follow; -} diff --git a/packages/backend/src/remote/activitypub/renderer/follow-user.ts b/packages/backend/src/remote/activitypub/renderer/follow-user.ts deleted file mode 100644 index 9a8a16d74..000000000 --- a/packages/backend/src/remote/activitypub/renderer/follow-user.ts +++ /dev/null @@ -1,12 +0,0 @@ -import config from '@/config/index.js'; -import { Users } from '@/models/index.js'; -import { User } from '@/models/entities/user.js'; - -/** - * Convert (local|remote)(Follower|Followee)ID to URL - * @param id Follower|Followee ID - */ -export default async function renderFollowUser(id: User['id']): Promise { - const user = await Users.findOneByOrFail({ id: id }); - return Users.isLocalUser(user) ? `${config.url}/users/${user.id}` : user.uri; -} diff --git a/packages/backend/src/remote/activitypub/renderer/follow.ts b/packages/backend/src/remote/activitypub/renderer/follow.ts deleted file mode 100644 index 00fac18ad..000000000 --- a/packages/backend/src/remote/activitypub/renderer/follow.ts +++ /dev/null @@ -1,14 +0,0 @@ -import config from '@/config/index.js'; -import { User } from '@/models/entities/user.js'; -import { Users } from '@/models/index.js'; - -export default (follower: { id: User['id']; host: User['host']; uri: User['host'] }, followee: { id: User['id']; host: User['host']; uri: User['host'] }, requestId?: string) => { - const follow = { - id: requestId ?? `${config.url}/follows/${follower.id}/${followee.id}`, - type: 'Follow', - actor: Users.isLocalUser(follower) ? `${config.url}/users/${follower.id}` : follower.uri, - object: Users.isLocalUser(followee) ? `${config.url}/users/${followee.id}` : followee.uri, - } as any; - - return follow; -}; diff --git a/packages/backend/src/remote/activitypub/renderer/hashtag.ts b/packages/backend/src/remote/activitypub/renderer/hashtag.ts deleted file mode 100644 index a7b441e00..000000000 --- a/packages/backend/src/remote/activitypub/renderer/hashtag.ts +++ /dev/null @@ -1,7 +0,0 @@ -import config from '@/config/index.js'; - -export default (tag: string) => ({ - type: 'Hashtag', - href: `${config.url}/tags/${encodeURIComponent(tag)}`, - name: `#${tag}`, -}); diff --git a/packages/backend/src/remote/activitypub/renderer/image.ts b/packages/backend/src/remote/activitypub/renderer/image.ts deleted file mode 100644 index c7d5a31a2..000000000 --- a/packages/backend/src/remote/activitypub/renderer/image.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { DriveFile } from '@/models/entities/drive-file.js'; -import { DriveFiles } from '@/models/index.js'; - -export default (file: DriveFile) => ({ - type: 'Image', - url: DriveFiles.getPublicUrl(file), - sensitive: file.isSensitive, - name: file.comment, -}); diff --git a/packages/backend/src/remote/activitypub/renderer/index.ts b/packages/backend/src/remote/activitypub/renderer/index.ts deleted file mode 100644 index f100b77ce..000000000 --- a/packages/backend/src/remote/activitypub/renderer/index.ts +++ /dev/null @@ -1,59 +0,0 @@ -import config from '@/config/index.js'; -import { v4 as uuid } from 'uuid'; -import { IActivity } from '../type.js'; -import { LdSignature } from '../misc/ld-signature.js'; -import { getUserKeypair } from '@/misc/keypair-store.js'; -import { User } from '@/models/entities/user.js'; - -export const renderActivity = (x: any): IActivity | null => { - if (x == null) return null; - - if (typeof x === 'object' && x.id == null) { - x.id = `${config.url}/${uuid()}`; - } - - return Object.assign({ - '@context': [ - 'https://www.w3.org/ns/activitystreams', - 'https://w3id.org/security/v1', - { - // as non-standards - manuallyApprovesFollowers: 'as:manuallyApprovesFollowers', - sensitive: 'as:sensitive', - Hashtag: 'as:Hashtag', - quoteUrl: 'as:quoteUrl', - // Mastodon - toot: 'http://joinmastodon.org/ns#', - Emoji: 'toot:Emoji', - featured: 'toot:featured', - discoverable: 'toot:discoverable', - // schema - schema: 'http://schema.org#', - PropertyValue: 'schema:PropertyValue', - value: 'schema:value', - // Misskey - misskey: 'https://misskey-hub.net/ns#', - '_misskey_content': 'misskey:_misskey_content', - '_misskey_quote': 'misskey:_misskey_quote', - '_misskey_reaction': 'misskey:_misskey_reaction', - '_misskey_votes': 'misskey:_misskey_votes', - '_misskey_talk': 'misskey:_misskey_talk', - 'isCat': 'misskey:isCat', - // vcard - vcard: 'http://www.w3.org/2006/vcard/ns#', - }, - ], - }, x); -}; - -export const attachLdSignature = async (activity: any, user: { id: User['id']; host: null; }): Promise => { - if (activity == null) return null; - - const keypair = await getUserKeypair(user.id); - - const ldSignature = new LdSignature(); - ldSignature.debug = false; - activity = await ldSignature.signRsaSignature2017(activity, keypair.privateKey, `${config.url}/users/${user.id}#main-key`); - - return activity; -}; diff --git a/packages/backend/src/remote/activitypub/renderer/key.ts b/packages/backend/src/remote/activitypub/renderer/key.ts deleted file mode 100644 index c4f3d464f..000000000 --- a/packages/backend/src/remote/activitypub/renderer/key.ts +++ /dev/null @@ -1,14 +0,0 @@ -import config from '@/config/index.js'; -import { ILocalUser } from '@/models/entities/user.js'; -import { UserKeypair } from '@/models/entities/user-keypair.js'; -import { createPublicKey } from 'node:crypto'; - -export default (user: ILocalUser, key: UserKeypair, postfix?: string) => ({ - id: `${config.url}/users/${user.id}${postfix || '/publickey'}`, - type: 'Key', - owner: `${config.url}/users/${user.id}`, - publicKeyPem: createPublicKey(key.publicKey).export({ - type: 'spki', - format: 'pem', - }), -}); diff --git a/packages/backend/src/remote/activitypub/renderer/like.ts b/packages/backend/src/remote/activitypub/renderer/like.ts deleted file mode 100644 index 00fb72e8a..000000000 --- a/packages/backend/src/remote/activitypub/renderer/like.ts +++ /dev/null @@ -1,31 +0,0 @@ -import config from '@/config/index.js'; -import { NoteReaction } from '@/models/entities/note-reaction.js'; -import { Note } from '@/models/entities/note.js'; -import { Emojis } from '@/models/index.js'; -import { IsNull } from 'typeorm'; -import renderEmoji from './emoji.js'; - -export const renderLike = async (noteReaction: NoteReaction, note: Note) => { - const reaction = noteReaction.reaction; - - const object = { - type: 'Like', - id: `${config.url}/likes/${noteReaction.id}`, - actor: `${config.url}/users/${noteReaction.userId}`, - object: note.uri ? note.uri : `${config.url}/notes/${noteReaction.noteId}`, - content: reaction, - _misskey_reaction: reaction, - } as any; - - if (reaction.startsWith(':')) { - const name = reaction.replace(/:/g, ''); - const emoji = await Emojis.findOneBy({ - name, - host: IsNull(), - }); - - if (emoji) object.tag = [ renderEmoji(emoji) ]; - } - - return object; -}; diff --git a/packages/backend/src/remote/activitypub/renderer/mention.ts b/packages/backend/src/remote/activitypub/renderer/mention.ts deleted file mode 100644 index c7e62e884..000000000 --- a/packages/backend/src/remote/activitypub/renderer/mention.ts +++ /dev/null @@ -1,9 +0,0 @@ -import config from '@/config/index.js'; -import { User, ILocalUser } from '@/models/entities/user.js'; -import { Users } from '@/models/index.js'; - -export default (mention: User) => ({ - type: 'Mention', - href: Users.isRemoteUser(mention) ? mention.uri : `${config.url}/users/${(mention as ILocalUser).id}`, - name: Users.isRemoteUser(mention) ? `@${mention.username}@${mention.host}` : `@${(mention as ILocalUser).username}`, -}); diff --git a/packages/backend/src/remote/activitypub/renderer/note.ts b/packages/backend/src/remote/activitypub/renderer/note.ts deleted file mode 100644 index b3bafaa3a..000000000 --- a/packages/backend/src/remote/activitypub/renderer/note.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { In, IsNull } from 'typeorm'; -import config from '@/config/index.js'; -import { Note, IMentionedRemoteUsers } from '@/models/entities/note.js'; -import { DriveFile } from '@/models/entities/drive-file.js'; -import { DriveFiles, Notes, Users, Emojis, Polls } from '@/models/index.js'; -import { Emoji } from '@/models/entities/emoji.js'; -import { Poll } from '@/models/entities/poll.js'; -import toHtml from '../misc/get-note-html.js'; -import renderEmoji from './emoji.js'; -import renderMention from './mention.js'; -import renderHashtag from './hashtag.js'; -import renderDocument from './document.js'; - -export default async function renderNote(note: Note, dive = true, isTalk = false): Promise> { - const getPromisedFiles = async (ids: string[]) => { - if (!ids || ids.length === 0) return []; - const items = await DriveFiles.findBy({ id: In(ids) }); - return ids.map(id => items.find(item => item.id === id)).filter(item => item != null) as DriveFile[]; - }; - - let inReplyTo; - let inReplyToNote: Note | null; - - if (note.replyId) { - inReplyToNote = await Notes.findOneBy({ id: note.replyId }); - - if (inReplyToNote != null) { - const inReplyToUser = await Users.findOneBy({ id: inReplyToNote.userId }); - - if (inReplyToUser != null) { - if (inReplyToNote.uri) { - inReplyTo = inReplyToNote.uri; - } else { - if (dive) { - inReplyTo = await renderNote(inReplyToNote, false); - } else { - inReplyTo = `${config.url}/notes/${inReplyToNote.id}`; - } - } - } - } - } else { - inReplyTo = null; - } - - let quote; - - if (note.renoteId) { - const renote = await Notes.findOneBy({ id: note.renoteId }); - - if (renote) { - quote = renote.uri ? renote.uri : `${config.url}/notes/${renote.id}`; - } - } - - const attributedTo = `${config.url}/users/${note.userId}`; - - const mentions = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri); - - let to: string[] = []; - let cc: string[] = []; - - if (note.visibility === 'public') { - to = ['https://www.w3.org/ns/activitystreams#Public']; - cc = [`${attributedTo}/followers`].concat(mentions); - } else if (note.visibility === 'home') { - to = [`${attributedTo}/followers`]; - cc = ['https://www.w3.org/ns/activitystreams#Public'].concat(mentions); - } else if (note.visibility === 'followers') { - to = [`${attributedTo}/followers`]; - cc = mentions; - } else { - to = mentions; - } - - const mentionedUsers = note.mentions.length > 0 ? await Users.findBy({ - id: In(note.mentions), - }) : []; - - const hashtagTags = (note.tags || []).map(tag => renderHashtag(tag)); - const mentionTags = mentionedUsers.map(u => renderMention(u)); - - const files = await getPromisedFiles(note.fileIds); - - const text = note.text ?? ''; - let poll: Poll | null = null; - - if (note.hasPoll) { - poll = await Polls.findOneBy({ noteId: note.id }); - } - - let apText = text; - - if (quote) { - apText += `\n\nRE: ${quote}`; - } - - const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; - - const content = toHtml(Object.assign({}, note, { - text: apText, - })); - - const emojis = await getEmojis(note.emojis); - const apemojis = emojis.map(emoji => renderEmoji(emoji)); - - const tag = [ - ...hashtagTags, - ...mentionTags, - ...apemojis, - ]; - - const asPoll = poll ? { - type: 'Question', - content: toHtml(Object.assign({}, note, { - text: text, - })), - [poll.expiresAt && poll.expiresAt < new Date() ? 'closed' : 'endTime']: poll.expiresAt, - [poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({ - type: 'Note', - name: text, - replies: { - type: 'Collection', - totalItems: poll!.votes[i], - }, - })), - } : {}; - - const asTalk = isTalk ? { - _misskey_talk: true, - } : {}; - - return { - id: `${config.url}/notes/${note.id}`, - type: 'Note', - attributedTo, - summary, - content, - _misskey_content: text, - source: { - content: text, - mediaType: "text/x.misskeymarkdown", - }, - _misskey_quote: quote, - quoteUrl: quote, - published: note.createdAt.toISOString(), - to, - cc, - inReplyTo, - attachment: files.map(renderDocument), - sensitive: note.cw != null || files.some(file => file.isSensitive), - tag, - ...asPoll, - ...asTalk, - }; -} - -export async function getEmojis(names: string[]): Promise { - if (names == null || names.length === 0) return []; - - const emojis = await Promise.all( - names.map(name => Emojis.findOneBy({ - name, - host: IsNull(), - })), - ); - - return emojis.filter(emoji => emoji != null) as Emoji[]; -} diff --git a/packages/backend/src/remote/activitypub/renderer/ordered-collection-page.ts b/packages/backend/src/remote/activitypub/renderer/ordered-collection-page.ts deleted file mode 100644 index c5e25f577..000000000 --- a/packages/backend/src/remote/activitypub/renderer/ordered-collection-page.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Render OrderedCollectionPage - * @param id URL of self - * @param totalItems Number of total items - * @param orderedItems Items - * @param partOf URL of base - * @param prev URL of prev page (optional) - * @param next URL of next page (optional) - */ -export default function(id: string, totalItems: any, orderedItems: any, partOf: string, prev?: string, next?: string) { - const page = { - id, - partOf, - type: 'OrderedCollectionPage', - totalItems, - orderedItems, - } as any; - - if (prev) page.prev = prev; - if (next) page.next = next; - - return page; -} diff --git a/packages/backend/src/remote/activitypub/renderer/ordered-collection.ts b/packages/backend/src/remote/activitypub/renderer/ordered-collection.ts deleted file mode 100644 index ff9a77be3..000000000 --- a/packages/backend/src/remote/activitypub/renderer/ordered-collection.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Render OrderedCollection - * @param id URL of self - * @param totalItems Total number of items - * @param first URL of first page (optional) - * @param last URL of last page (optional) - * @param orderedItems attached objects (optional) - */ -export default function(id: string | null, totalItems: any, first?: string, last?: string, orderedItems?: Record[]): { - id: string | null; - type: 'OrderedCollection'; - totalItems: any; - first?: string; - last?: string; - orderedItems?: Record[]; -} { - const page: any = { - id, - type: 'OrderedCollection', - totalItems, - }; - - if (first) page.first = first; - if (last) page.last = last; - if (orderedItems) page.orderedItems = orderedItems; - - return page; -} diff --git a/packages/backend/src/remote/activitypub/renderer/person.ts b/packages/backend/src/remote/activitypub/renderer/person.ts deleted file mode 100644 index cd2fd74d4..000000000 --- a/packages/backend/src/remote/activitypub/renderer/person.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { URL } from 'node:url'; -import * as mfm from 'mfm-js'; -import renderImage from './image.js'; -import renderKey from './key.js'; -import config from '@/config/index.js'; -import { ILocalUser } from '@/models/entities/user.js'; -import { toHtml } from '../../../mfm/to-html.js'; -import { getEmojis } from './note.js'; -import renderEmoji from './emoji.js'; -import { IIdentifier } from '../models/identifier.js'; -import renderHashtag from './hashtag.js'; -import { DriveFiles, UserProfiles } from '@/models/index.js'; -import { getUserKeypair } from '@/misc/keypair-store.js'; - -export async function renderPerson(user: ILocalUser) { - const id = `${config.url}/users/${user.id}`; - const isSystem = !!user.username.match(/\./); - - const [avatar, banner, profile] = await Promise.all([ - user.avatarId ? DriveFiles.findOneBy({ id: user.avatarId }) : Promise.resolve(undefined), - user.bannerId ? DriveFiles.findOneBy({ id: user.bannerId }) : Promise.resolve(undefined), - UserProfiles.findOneByOrFail({ userId: user.id }), - ]); - - const attachment: { - type: 'PropertyValue', - name: string, - value: string, - identifier?: IIdentifier - }[] = []; - - if (profile.fields) { - for (const field of profile.fields) { - attachment.push({ - type: 'PropertyValue', - name: field.name, - value: (field.value != null && field.value.match(/^https?:/)) - ? `${new URL(field.value).href}` - : field.value, - }); - } - } - - const emojis = await getEmojis(user.emojis); - const apemojis = emojis.map(emoji => renderEmoji(emoji)); - - const hashtagTags = (user.tags || []).map(tag => renderHashtag(tag)); - - const tag = [ - ...apemojis, - ...hashtagTags, - ]; - - const keypair = await getUserKeypair(user.id); - - const person = { - type: isSystem ? 'Application' : user.isBot ? 'Service' : 'Person', - id, - inbox: `${id}/inbox`, - outbox: `${id}/outbox`, - followers: `${id}/followers`, - following: `${id}/following`, - featured: `${id}/collections/featured`, - sharedInbox: `${config.url}/inbox`, - endpoints: { sharedInbox: `${config.url}/inbox` }, - url: `${config.url}/@${user.username}`, - preferredUsername: user.username, - name: user.name, - summary: profile.description ? toHtml(mfm.parse(profile.description)) : null, - icon: avatar ? renderImage(avatar) : null, - image: banner ? renderImage(banner) : null, - tag, - manuallyApprovesFollowers: user.isLocked, - discoverable: !!user.isExplorable, - publicKey: renderKey(user, keypair, `#main-key`), - isCat: user.isCat, - attachment: attachment.length ? attachment : undefined, - } as any; - - if (profile?.birthday) { - person['vcard:bday'] = profile.birthday; - } - - if (profile?.location) { - person['vcard:Address'] = profile.location; - } - - return person; -} diff --git a/packages/backend/src/remote/activitypub/renderer/question.ts b/packages/backend/src/remote/activitypub/renderer/question.ts deleted file mode 100644 index d4d1b590a..000000000 --- a/packages/backend/src/remote/activitypub/renderer/question.ts +++ /dev/null @@ -1,23 +0,0 @@ -import config from '@/config/index.js'; -import { User } from '@/models/entities/user.js'; -import { Note } from '@/models/entities/note.js'; -import { Poll } from '@/models/entities/poll.js'; - -export default async function renderQuestion(user: { id: User['id'] }, note: Note, poll: Poll) { - const question = { - type: 'Question', - id: `${config.url}/questions/${note.id}`, - actor: `${config.url}/users/${user.id}`, - content: note.text || '', - [poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({ - name: text, - _misskey_votes: poll.votes[i], - replies: { - type: 'Collection', - totalItems: poll.votes[i], - }, - })), - }; - - return question; -} diff --git a/packages/backend/src/remote/activitypub/renderer/read.ts b/packages/backend/src/remote/activitypub/renderer/read.ts deleted file mode 100644 index a30e649f6..000000000 --- a/packages/backend/src/remote/activitypub/renderer/read.ts +++ /dev/null @@ -1,9 +0,0 @@ -import config from '@/config/index.js'; -import { User } from '@/models/entities/user.js'; -import { MessagingMessage } from '@/models/entities/messaging-message.js'; - -export const renderReadActivity = (user: { id: User['id'] }, message: MessagingMessage) => ({ - type: 'Read', - actor: `${config.url}/users/${user.id}`, - object: message.uri, -}); diff --git a/packages/backend/src/remote/activitypub/renderer/reject.ts b/packages/backend/src/remote/activitypub/renderer/reject.ts deleted file mode 100644 index ab4cc1646..000000000 --- a/packages/backend/src/remote/activitypub/renderer/reject.ts +++ /dev/null @@ -1,8 +0,0 @@ -import config from '@/config/index.js'; -import { User } from '@/models/entities/user.js'; - -export default (object: any, user: { id: User['id'] }) => ({ - type: 'Reject', - actor: `${config.url}/users/${user.id}`, - object, -}); diff --git a/packages/backend/src/remote/activitypub/renderer/remove.ts b/packages/backend/src/remote/activitypub/renderer/remove.ts deleted file mode 100644 index 1be3edc5d..000000000 --- a/packages/backend/src/remote/activitypub/renderer/remove.ts +++ /dev/null @@ -1,9 +0,0 @@ -import config from '@/config/index.js'; -import { User } from '@/models/entities/user.js'; - -export default (user: { id: User['id'] }, target: any, object: any) => ({ - type: 'Remove', - actor: `${config.url}/users/${user.id}`, - target, - object, -}); diff --git a/packages/backend/src/remote/activitypub/renderer/tombstone.ts b/packages/backend/src/remote/activitypub/renderer/tombstone.ts deleted file mode 100644 index 313ca74e9..000000000 --- a/packages/backend/src/remote/activitypub/renderer/tombstone.ts +++ /dev/null @@ -1,4 +0,0 @@ -export default (id: string) => ({ - id, - type: 'Tombstone', -}); diff --git a/packages/backend/src/remote/activitypub/renderer/undo.ts b/packages/backend/src/remote/activitypub/renderer/undo.ts deleted file mode 100644 index 46631df9e..000000000 --- a/packages/backend/src/remote/activitypub/renderer/undo.ts +++ /dev/null @@ -1,15 +0,0 @@ -import config from '@/config/index.js'; -import { ILocalUser, User } from '@/models/entities/user.js'; - -export default (object: any, user: { id: User['id'] }) => { - if (object == null) return null; - const id = typeof object.id === 'string' && object.id.startsWith(config.url) ? `${object.id}/undo` : undefined; - - return { - type: 'Undo', - ...(id ? { id } : {}), - actor: `${config.url}/users/${user.id}`, - object, - published: new Date().toISOString(), - }; -}; diff --git a/packages/backend/src/remote/activitypub/renderer/update.ts b/packages/backend/src/remote/activitypub/renderer/update.ts deleted file mode 100644 index cf880f03f..000000000 --- a/packages/backend/src/remote/activitypub/renderer/update.ts +++ /dev/null @@ -1,15 +0,0 @@ -import config from '@/config/index.js'; -import { User } from '@/models/entities/user.js'; - -export default (object: any, user: { id: User['id'] }) => { - const activity = { - id: `${config.url}/users/${user.id}#updates/${new Date().getTime()}`, - actor: `${config.url}/users/${user.id}`, - type: 'Update', - to: [ 'https://www.w3.org/ns/activitystreams#Public' ], - object, - published: new Date().toISOString(), - } as any; - - return activity; -}; diff --git a/packages/backend/src/remote/activitypub/renderer/vote.ts b/packages/backend/src/remote/activitypub/renderer/vote.ts deleted file mode 100644 index b6eb8e095..000000000 --- a/packages/backend/src/remote/activitypub/renderer/vote.ts +++ /dev/null @@ -1,23 +0,0 @@ -import config from '@/config/index.js'; -import { Note } from '@/models/entities/note.js'; -import { IRemoteUser, User } from '@/models/entities/user.js'; -import { PollVote } from '@/models/entities/poll-vote.js'; -import { Poll } from '@/models/entities/poll.js'; - -export default async function renderVote(user: { id: User['id'] }, vote: PollVote, note: Note, poll: Poll, pollOwner: IRemoteUser): Promise { - return { - id: `${config.url}/users/${user.id}#votes/${vote.id}/activity`, - actor: `${config.url}/users/${user.id}`, - type: 'Create', - to: [pollOwner.uri], - published: new Date().toISOString(), - object: { - id: `${config.url}/users/${user.id}#votes/${vote.id}`, - type: 'Note', - attributedTo: `${config.url}/users/${user.id}`, - to: [pollOwner.uri], - inReplyTo: note.uri, - name: poll.choices[vote.choice], - }, - }; -} diff --git a/packages/backend/src/remote/activitypub/request.ts b/packages/backend/src/remote/activitypub/request.ts deleted file mode 100644 index 5cbfd8c25..000000000 --- a/packages/backend/src/remote/activitypub/request.ts +++ /dev/null @@ -1,58 +0,0 @@ -import config from '@/config/index.js'; -import { getUserKeypair } from '@/misc/keypair-store.js'; -import { User } from '@/models/entities/user.js'; -import { getResponse } from '../../misc/fetch.js'; -import { createSignedPost, createSignedGet } from './ap-request.js'; - -export default async (user: { id: User['id'] }, url: string, object: any) => { - const body = JSON.stringify(object); - - const keypair = await getUserKeypair(user.id); - - const req = createSignedPost({ - key: { - privateKeyPem: keypair.privateKey, - keyId: `${config.url}/users/${user.id}#main-key`, - }, - url, - body, - additionalHeaders: { - 'User-Agent': config.userAgent, - }, - }); - - await getResponse({ - url, - method: req.request.method, - headers: req.request.headers, - body, - }); -}; - -/** - * Get AP object with http-signature - * @param user http-signature user - * @param url URL to fetch - */ -export async function signedGet(url: string, user: { id: User['id'] }) { - const keypair = await getUserKeypair(user.id); - - const req = createSignedGet({ - key: { - privateKeyPem: keypair.privateKey, - keyId: `${config.url}/users/${user.id}#main-key`, - }, - url, - additionalHeaders: { - 'User-Agent': config.userAgent, - }, - }); - - const res = await getResponse({ - url, - method: req.request.method, - headers: req.request.headers, - }); - - return await res.json(); -} diff --git a/packages/backend/src/remote/activitypub/resolver.ts b/packages/backend/src/remote/activitypub/resolver.ts deleted file mode 100644 index 2f9af43c0..000000000 --- a/packages/backend/src/remote/activitypub/resolver.ts +++ /dev/null @@ -1,133 +0,0 @@ -import config from '@/config/index.js'; -import { getJson } from '@/misc/fetch.js'; -import { ILocalUser } from '@/models/entities/user.js'; -import { getInstanceActor } from '@/services/instance-actor.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { extractDbHost, isSelfHost } from '@/misc/convert-host.js'; -import { signedGet } from './request.js'; -import { IObject, isCollectionOrOrderedCollection, ICollection, IOrderedCollection } from './type.js'; -import { FollowRequests, Notes, NoteReactions, Polls, Users } from '@/models/index.js'; -import { parseUri } from './db-resolver.js'; -import renderNote from '@/remote/activitypub/renderer/note.js'; -import { renderLike } from '@/remote/activitypub/renderer/like.js'; -import { renderPerson } from '@/remote/activitypub/renderer/person.js'; -import renderQuestion from '@/remote/activitypub/renderer/question.js'; -import renderCreate from '@/remote/activitypub/renderer/create.js'; -import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import renderFollow from '@/remote/activitypub/renderer/follow.js'; - -export default class Resolver { - private history: Set; - private user?: ILocalUser; - - constructor() { - this.history = new Set(); - } - - public getHistory(): string[] { - return Array.from(this.history); - } - - public async resolveCollection(value: string | IObject): Promise { - const collection = typeof value === 'string' - ? await this.resolve(value) - : value; - - if (isCollectionOrOrderedCollection(collection)) { - return collection; - } else { - throw new Error(`unrecognized collection type: ${collection.type}`); - } - } - - public async resolve(value: string | IObject): Promise { - if (value == null) { - throw new Error('resolvee is null (or undefined)'); - } - - if (typeof value !== 'string') { - return value; - } - - if (value.includes('#')) { - // URLs with fragment parts cannot be resolved correctly because - // the fragment part does not get transmitted over HTTP(S). - // Avoid strange behaviour by not trying to resolve these at all. - throw new Error(`cannot resolve URL with fragment: ${value}`); - } - - if (this.history.has(value)) { - throw new Error('cannot resolve already resolved one'); - } - - this.history.add(value); - - const host = extractDbHost(value); - if (isSelfHost(host)) { - return await this.resolveLocal(value); - } - - const meta = await fetchMeta(); - if (meta.blockedHosts.includes(host)) { - throw new Error('Instance is blocked'); - } - - if (config.signToActivityPubGet && !this.user) { - this.user = await getInstanceActor(); - } - - const object = (this.user - ? await signedGet(value, this.user) - : await getJson(value, 'application/activity+json, application/ld+json')) as IObject; - - if (object == null || ( - Array.isArray(object['@context']) ? - !(object['@context'] as unknown[]).includes('https://www.w3.org/ns/activitystreams') : - object['@context'] !== 'https://www.w3.org/ns/activitystreams' - )) { - throw new Error('invalid response'); - } - - return object; - } - - private resolveLocal(url: string): Promise { - const parsed = parseUri(url); - if (!parsed.local) throw new Error('resolveLocal: not local'); - - switch (parsed.type) { - case 'notes': - return Notes.findOneByOrFail({ id: parsed.id }) - .then(note => { - if (parsed.rest === 'activity') { - // this refers to the create activity and not the note itself - return renderActivity(renderCreate(renderNote(note))); - } else { - return renderNote(note); - } - }); - case 'users': - return Users.findOneByOrFail({ id: parsed.id }) - .then(user => renderPerson(user as ILocalUser)); - case 'questions': - // Polls are indexed by the note they are attached to. - return Promise.all([ - Notes.findOneByOrFail({ id: parsed.id }), - Polls.findOneByOrFail({ noteId: parsed.id }), - ]) - .then(([note, poll]) => renderQuestion({ id: note.userId }, note, poll)); - case 'likes': - return NoteReactions.findOneByOrFail({ id: parsed.id }).then(reaction => renderActivity(renderLike(reaction, { uri: null }))); - case 'follows': - // rest should be - if (parsed.rest == null || !/^\w+$/.test(parsed.rest)) throw new Error('resolveLocal: invalid follow URI'); - - return Promise.all( - [parsed.id, parsed.rest].map(id => Users.findOneByOrFail({ id })) - ) - .then(([follower, followee]) => renderActivity(renderFollow(follower, followee, url))); - default: - throw new Error(`resolveLocal: type ${type} unhandled`); - } - } -} diff --git a/packages/backend/src/remote/logger.ts b/packages/backend/src/remote/logger.ts deleted file mode 100644 index 4921f53bd..000000000 --- a/packages/backend/src/remote/logger.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Logger from '@/services/logger.js'; - -export const remoteLogger = new Logger('remote', 'cyan'); diff --git a/packages/backend/src/remote/resolve-user.ts b/packages/backend/src/remote/resolve-user.ts deleted file mode 100644 index 6fc6f2c4d..000000000 --- a/packages/backend/src/remote/resolve-user.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { URL } from 'node:url'; -import webFinger from './webfinger.js'; -import config from '@/config/index.js'; -import { createPerson, updatePerson } from './activitypub/models/person.js'; -import { remoteLogger } from './logger.js'; -import chalk from 'chalk'; -import { User, IRemoteUser } from '@/models/entities/user.js'; -import { Users } from '@/models/index.js'; -import { toPuny } from '@/misc/convert-host.js'; -import { IsNull } from 'typeorm'; - -const logger = remoteLogger.createSubLogger('resolve-user'); - -export async function resolveUser(username: string, host: string | null): Promise { - const usernameLower = username.toLowerCase(); - - if (host == null) { - logger.info(`return local user: ${usernameLower}`); - return await Users.findOneBy({ usernameLower, host: IsNull() }).then(u => { - if (u == null) { - throw new Error('user not found'); - } else { - return u; - } - }); - } - - host = toPuny(host); - - if (config.host === host) { - logger.info(`return local user: ${usernameLower}`); - return await Users.findOneBy({ usernameLower, host: IsNull() }).then(u => { - if (u == null) { - throw new Error('user not found'); - } else { - return u; - } - }); - } - - const user = await Users.findOneBy({ usernameLower, host }) as IRemoteUser | null; - - const acctLower = `${usernameLower}@${host}`; - - if (user == null) { - const self = await resolveSelf(acctLower); - - logger.succ(`return new remote user: ${chalk.magenta(acctLower)}`); - return await createPerson(self.href); - } - - // ユーザー情報が古い場合は、WebFilgerからやりなおして返す - if (user.lastFetchedAt == null || Date.now() - user.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { - // 繋がらないインスタンスに何回も試行するのを防ぐ, 後続の同様処理の連続試行を防ぐ ため 試行前にも更新する - await Users.update(user.id, { - lastFetchedAt: new Date(), - }); - - logger.info(`try resync: ${acctLower}`); - const self = await resolveSelf(acctLower); - - if (user.uri !== self.href) { - // if uri mismatch, Fix (user@host <=> AP's Person id(IRemoteUser.uri)) mapping. - logger.info(`uri missmatch: ${acctLower}`); - logger.info(`recovery missmatch uri for (username=${username}, host=${host}) from ${user.uri} to ${self.href}`); - - // validate uri - const uri = new URL(self.href); - if (uri.hostname !== host) { - throw new Error(`Invalid uri`); - } - - await Users.update({ - usernameLower, - host: host, - }, { - uri: self.href, - }); - } else { - logger.info(`uri is fine: ${acctLower}`); - } - - await updatePerson(self.href); - - logger.info(`return resynced remote user: ${acctLower}`); - return await Users.findOneBy({ uri: self.href }).then(u => { - if (u == null) { - throw new Error('user not found'); - } else { - return u; - } - }); - } - - logger.info(`return existing remote user: ${acctLower}`); - return user; -} - -async function resolveSelf(acctLower: string) { - logger.info(`WebFinger for ${chalk.yellow(acctLower)}`); - const finger = await webFinger(acctLower).catch(e => { - logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: ${ e.statusCode || e.message }`); - throw new Error(`Failed to WebFinger for ${acctLower}: ${ e.statusCode || e.message }`); - }); - const self = finger.links.find(link => link.rel != null && link.rel.toLowerCase() === 'self'); - if (!self) { - logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: self link not found`); - throw new Error('self link not found'); - } - return self; -} diff --git a/packages/backend/src/remote/webfinger.ts b/packages/backend/src/remote/webfinger.ts deleted file mode 100644 index 337df34c2..000000000 --- a/packages/backend/src/remote/webfinger.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { URL } from 'node:url'; -import { getJson } from '@/misc/fetch.js'; -import { query as urlQuery } from '@/prelude/url.js'; - -type ILink = { - href: string; - rel?: string; -}; - -type IWebFinger = { - links: ILink[]; - subject: string; -}; - -export default async function(query: string): Promise { - const url = genUrl(query); - - return await getJson(url, 'application/jrd+json, application/json') as IWebFinger; -} - -function genUrl(query: string) { - if (query.match(/^https?:\/\//)) { - const u = new URL(query); - return `${u.protocol}//${u.hostname}/.well-known/webfinger?` + urlQuery({ resource: query }); - } - - const m = query.match(/^([^@]+)@(.*)/); - if (m) { - const hostname = m[2]; - return `https://${hostname}/.well-known/webfinger?` + urlQuery({ resource: `acct:${query}` }); - } - - throw new Error(`Invalid query (${query})`); -} diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts new file mode 100644 index 000000000..21ecc7177 --- /dev/null +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -0,0 +1,584 @@ +import { Inject, Injectable } from '@nestjs/common'; +import Router from '@koa/router'; +import json from 'koa-json-body'; +import httpSignature from '@peertube/http-signature'; +import { Brackets, In, IsNull, LessThan, Not } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository } from '@/models/index.js'; +import * as url from '@/misc/prelude/url.js'; +import { Config } from '@/config.js'; +import { ApRendererService } from '@/core/remote/activitypub/ApRendererService.js'; +import { QueueService } from '@/core/QueueService.js'; +import type { ILocalUser, User } from '@/models/entities/User.js'; +import { UserKeypairStoreService } from '@/core/UserKeypairStoreService.js'; +import type { Following } from '@/models/entities/Following.js'; +import { countIf } from '@/misc/prelude/array.js'; +import type { Note } from '@/models/entities/Note.js'; +import { QueryService } from '@/core/QueryService.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import type { FindOptionsWhere } from 'typeorm'; + +const ACTIVITY_JSON = 'application/activity+json; charset=utf-8'; +const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8'; + +@Injectable() +export class ActivityPubServerService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.noteReactionsRepository) + private noteReactionsRepository: NoteReactionsRepository, + + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, + + @Inject(DI.userNotePiningsRepository) + private userNotePiningsRepository: UserNotePiningsRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + private utilityService: UtilityService, + private userEntityService: UserEntityService, + private apRendererService: ApRendererService, + private queueService: QueueService, + private userKeypairStoreService: UserKeypairStoreService, + private queryService: QueryService, + ) { + } + + private setResponseType(ctx: Router.RouterContext) { + const accept = ctx.accepts(ACTIVITY_JSON, LD_JSON); + if (accept === LD_JSON) { + ctx.response.type = LD_JSON; + } else { + ctx.response.type = ACTIVITY_JSON; + } + } + + /** + * Pack Create or Announce Activity + * @param note Note + */ + private async packActivity(note: Note): Promise { + if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) { + const renote = await Notes.findOneByOrFail({ id: note.renoteId }); + return this.apRendererService.renderAnnounce(renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`, note); + } + + return this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false), note); + } + + private inbox(ctx: Router.RouterContext) { + let signature; + + try { + signature = httpSignature.parseRequest(ctx.req, { 'headers': [] }); + } catch (e) { + ctx.status = 401; + return; + } + + this.queueService.inbox(ctx.request.body, signature); + + ctx.status = 202; + } + + private async followers(ctx: Router.RouterContext) { + const userId = ctx.params.user; + + const cursor = ctx.request.query.cursor; + if (cursor != null && typeof cursor !== 'string') { + ctx.status = 400; + return; + } + + const page = ctx.request.query.page === 'true'; + + const user = await this.usersRepository.findOneBy({ + id: userId, + host: IsNull(), + }); + + if (user == null) { + ctx.status = 404; + return; + } + + //#region Check ff visibility + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + + if (profile.ffVisibility === 'private') { + ctx.status = 403; + ctx.set('Cache-Control', 'public, max-age=30'); + return; + } else if (profile.ffVisibility === 'followers') { + ctx.status = 403; + ctx.set('Cache-Control', 'public, max-age=30'); + return; + } + //#endregion + + const limit = 10; + const partOf = `${this.config.url}/users/${userId}/followers`; + + if (page) { + const query = { + followeeId: user.id, + } as FindOptionsWhere; + + // カーソルが指定されている場合 + if (cursor) { + query.id = LessThan(cursor); + } + + // Get followers + const followings = await this.followingsRepository.find({ + where: query, + take: limit + 1, + order: { id: -1 }, + }); + + // 「次のページ」があるかどうか + const inStock = followings.length === limit + 1; + if (inStock) followings.pop(); + + const renderedFollowers = await Promise.all(followings.map(following => this.apRendererService.renderFollowUser(following.followerId))); + const rendered = this.apRendererService.renderOrderedCollectionPage( + `${partOf}?${url.query({ + page: 'true', + cursor, + })}`, + user.followersCount, renderedFollowers, partOf, + undefined, + inStock ? `${partOf}?${url.query({ + page: 'true', + cursor: followings[followings.length - 1].id, + })}` : undefined, + ); + + ctx.body = this.apRendererService.renderActivity(rendered); + this.setResponseType(ctx); + } else { + // index page + const rendered = this.apRendererService.renderOrderedCollection(partOf, user.followersCount, `${partOf}?page=true`); + ctx.body = this.apRendererService.renderActivity(rendered); + ctx.set('Cache-Control', 'public, max-age=180'); + this.setResponseType(ctx); + } + } + + private async following(ctx: Router.RouterContext) { + const userId = ctx.params.user; + + const cursor = ctx.request.query.cursor; + if (cursor != null && typeof cursor !== 'string') { + ctx.status = 400; + return; + } + + const page = ctx.request.query.page === 'true'; + + const user = await this.usersRepository.findOneBy({ + id: userId, + host: IsNull(), + }); + + if (user == null) { + ctx.status = 404; + return; + } + + //#region Check ff visibility + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + + if (profile.ffVisibility === 'private') { + ctx.status = 403; + ctx.set('Cache-Control', 'public, max-age=30'); + return; + } else if (profile.ffVisibility === 'followers') { + ctx.status = 403; + ctx.set('Cache-Control', 'public, max-age=30'); + return; + } + //#endregion + + const limit = 10; + const partOf = `${this.config.url}/users/${userId}/following`; + + if (page) { + const query = { + followerId: user.id, + } as FindOptionsWhere; + + // カーソルが指定されている場合 + if (cursor) { + query.id = LessThan(cursor); + } + + // Get followings + const followings = await this.followingsRepository.find({ + where: query, + take: limit + 1, + order: { id: -1 }, + }); + + // 「次のページ」があるかどうか + const inStock = followings.length === limit + 1; + if (inStock) followings.pop(); + + const renderedFollowees = await Promise.all(followings.map(following => this.apRendererService.renderFollowUser(following.followeeId))); + const rendered = this.apRendererService.renderOrderedCollectionPage( + `${partOf}?${url.query({ + page: 'true', + cursor, + })}`, + user.followingCount, renderedFollowees, partOf, + undefined, + inStock ? `${partOf}?${url.query({ + page: 'true', + cursor: followings[followings.length - 1].id, + })}` : undefined, + ); + + ctx.body = this.apRendererService.renderActivity(rendered); + this.setResponseType(ctx); + } else { + // index page + const rendered = this.apRendererService.renderOrderedCollection(partOf, user.followingCount, `${partOf}?page=true`); + ctx.body = this.apRendererService.renderActivity(rendered); + ctx.set('Cache-Control', 'public, max-age=180'); + this.setResponseType(ctx); + } + } + + private async featured(ctx: Router.RouterContext) { + const userId = ctx.params.user; + + const user = await this.usersRepository.findOneBy({ + id: userId, + host: IsNull(), + }); + + if (user == null) { + ctx.status = 404; + return; + } + + const pinings = await this.userNotePiningsRepository.find({ + where: { userId: user.id }, + order: { id: 'DESC' }, + }); + + const pinnedNotes = await Promise.all(pinings.map(pining => + this.notesRepository.findOneByOrFail({ id: pining.noteId }))); + + const renderedNotes = await Promise.all(pinnedNotes.map(note => this.apRendererService.renderNote(note))); + + const rendered = this.apRendererService.renderOrderedCollection( + `${this.config.url}/users/${userId}/collections/featured`, + renderedNotes.length, undefined, undefined, renderedNotes, + ); + + ctx.body = this.apRendererService.renderActivity(rendered); + ctx.set('Cache-Control', 'public, max-age=180'); + this.setResponseType(ctx); + } + + private async outbox(ctx: Router.RouterContext) { + const userId = ctx.params.user; + + const sinceId = ctx.request.query.since_id; + if (sinceId != null && typeof sinceId !== 'string') { + ctx.status = 400; + return; + } + + const untilId = ctx.request.query.until_id; + if (untilId != null && typeof untilId !== 'string') { + ctx.status = 400; + return; + } + + const page = ctx.request.query.page === 'true'; + + if (countIf(x => x != null, [sinceId, untilId]) > 1) { + ctx.status = 400; + return; + } + + const user = await this.usersRepository.findOneBy({ + id: userId, + host: IsNull(), + }); + + if (user == null) { + ctx.status = 404; + return; + } + + const limit = 20; + const partOf = `${this.config.url}/users/${userId}/outbox`; + + if (page) { + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId) + .andWhere('note.userId = :userId', { userId: user.id }) + .andWhere(new Brackets(qb => { qb + .where('note.visibility = \'public\'') + .orWhere('note.visibility = \'home\''); + })) + .andWhere('note.localOnly = FALSE'); + + const notes = await query.take(limit).getMany(); + + if (sinceId) notes.reverse(); + + const activities = await Promise.all(notes.map(note => this.packActivity(note))); + const rendered = this.apRendererService.renderOrderedCollectionPage( + `${partOf}?${url.query({ + page: 'true', + since_id: sinceId, + until_id: untilId, + })}`, + user.notesCount, activities, partOf, + notes.length ? `${partOf}?${url.query({ + page: 'true', + since_id: notes[0].id, + })}` : undefined, + notes.length ? `${partOf}?${url.query({ + page: 'true', + until_id: notes[notes.length - 1].id, + })}` : undefined, + ); + + ctx.body = this.apRendererService.renderActivity(rendered); + this.setResponseType(ctx); + } else { + // index page + const rendered = this.apRendererService.renderOrderedCollection(partOf, user.notesCount, + `${partOf}?page=true`, + `${partOf}?page=true&since_id=000000000000000000000000`, + ); + ctx.body = this.apRendererService.renderActivity(rendered); + ctx.set('Cache-Control', 'public, max-age=180'); + this.setResponseType(ctx); + } + } + + private async userInfo(ctx: Router.RouterContext, user: User | null) { + if (user == null) { + ctx.status = 404; + return; + } + + ctx.body = this.apRendererService.renderActivity(await this.apRendererService.renderPerson(user as ILocalUser)); + ctx.set('Cache-Control', 'public, max-age=180'); + this.setResponseType(ctx); + } + + public createRouter() { + // Init router + const router = new Router(); + + //#region Routing + function isActivityPubReq(ctx: Router.RouterContext) { + ctx.response.vary('Accept'); + const accepted = ctx.accepts('html', ACTIVITY_JSON, LD_JSON); + return typeof accepted === 'string' && !accepted.match(/html/); + } + + // inbox + router.post('/inbox', json(), ctx => this.inbox(ctx)); + router.post('/users/:user/inbox', json(), ctx => this.inbox(ctx)); + + // note + router.get('/notes/:note', async (ctx, next) => { + if (!isActivityPubReq(ctx)) return await next(); + + const note = await this.notesRepository.findOneBy({ + id: ctx.params.note, + visibility: In(['public' as const, 'home' as const]), + localOnly: false, + }); + + if (note == null) { + ctx.status = 404; + return; + } + + // リモートだったらリダイレクト + if (note.userHost != null) { + if (note.uri == null || this.utilityService.isSelfHost(note.userHost)) { + ctx.status = 500; + return; + } + ctx.redirect(note.uri); + return; + } + + ctx.body = this.apRendererService.renderActivity(await this.apRendererService.renderNote(note, false)); + ctx.set('Cache-Control', 'public, max-age=180'); + this.setResponseType(ctx); + }); + + // note activity + router.get('/notes/:note/activity', async ctx => { + const note = await this.notesRepository.findOneBy({ + id: ctx.params.note, + userHost: IsNull(), + visibility: In(['public' as const, 'home' as const]), + localOnly: false, + }); + + if (note == null) { + ctx.status = 404; + return; + } + + ctx.body = this.apRendererService.renderActivity(await this.packActivity(note)); + ctx.set('Cache-Control', 'public, max-age=180'); + this.setResponseType(ctx); + }); + + // outbox + router.get('/users/:user/outbox', (ctx) => this.outbox(ctx)); + + // followers + router.get('/users/:user/followers', (ctx) => this.followers(ctx)); + + // following + router.get('/users/:user/following', (ctx) => this.following(ctx)); + + // featured + router.get('/users/:user/collections/featured', (ctx) => this.featured(ctx)); + + // publickey + router.get('/users/:user/publickey', async ctx => { + const userId = ctx.params.user; + + const user = await this.usersRepository.findOneBy({ + id: userId, + host: IsNull(), + }); + + if (user == null) { + ctx.status = 404; + return; + } + + const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); + + if (this.userEntityService.isLocalUser(user)) { + ctx.body = this.apRendererService.renderActivity(this.apRendererService.renderKey(user, keypair)); + ctx.set('Cache-Control', 'public, max-age=180'); + this.setResponseType(ctx); + } else { + ctx.status = 400; + } + }); + + router.get('/users/:user', async (ctx, next) => { + if (!isActivityPubReq(ctx)) return await next(); + + const userId = ctx.params.user; + + const user = await this.usersRepository.findOneBy({ + id: userId, + host: IsNull(), + isSuspended: false, + }); + + await this.userInfo(ctx, user); + }); + + router.get('/@:user', async (ctx, next) => { + if (!isActivityPubReq(ctx)) return await next(); + + const user = await this.usersRepository.findOneBy({ + usernameLower: ctx.params.user.toLowerCase(), + host: IsNull(), + isSuspended: false, + }); + + await this.userInfo(ctx, user); + }); + //#endregion + + // emoji + router.get('/emojis/:emoji', async ctx => { + const emoji = await this.emojisRepository.findOneBy({ + host: IsNull(), + name: ctx.params.emoji, + }); + + if (emoji == null) { + ctx.status = 404; + return; + } + + ctx.body = this.apRendererService.renderActivity(await this.apRendererService.renderEmoji(emoji)); + ctx.set('Cache-Control', 'public, max-age=180'); + this.setResponseType(ctx); + }); + + // like + router.get('/likes/:like', async ctx => { + const reaction = await this.noteReactionsRepository.findOneBy({ id: ctx.params.like }); + + if (reaction == null) { + ctx.status = 404; + return; + } + + const note = await this.notesRepository.findOneBy({ id: reaction.noteId }); + + if (note == null) { + ctx.status = 404; + return; + } + + ctx.body = this.apRendererService.renderActivity(await this.apRendererService.renderLike(reaction, note)); + ctx.set('Cache-Control', 'public, max-age=180'); + this.setResponseType(ctx); + }); + + // follow + router.get('/follows/:follower/:followee', async ctx => { + // This may be used before the follow is completed, so we do not + // check if the following exists. + + const [follower, followee] = await Promise.all([ + this.usersRepository.findOneBy({ + id: ctx.params.follower, + host: IsNull(), + }), + this.usersRepository.findOneBy({ + id: ctx.params.followee, + host: Not(IsNull()), + }), + ]); + + if (follower == null || followee == null) { + ctx.status = 404; + return; + } + + ctx.body = this.apRendererService.renderActivity(this.apRendererService.renderFollow(follower, followee)); + ctx.set('Cache-Control', 'public, max-age=180'); + this.setResponseType(ctx); + }); + + return router; + } +} diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts new file mode 100644 index 000000000..becf0592d --- /dev/null +++ b/packages/backend/src/server/FileServerService.ts @@ -0,0 +1,183 @@ +import * as fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; +import { Inject, Injectable } from '@nestjs/common'; +import Koa from 'koa'; +import cors from '@koa/cors'; +import Router from '@koa/router'; +import send from 'koa-send'; +import rename from 'rename'; +import { Config } from '@/config.js'; +import { DriveFilesRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { createTemp } from '@/misc/create-temp.js'; +import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; +import { StatusError } from '@/misc/status-error.js'; +import type Logger from '@/logger.js'; +import { DownloadService } from '@/core/DownloadService.js'; +import { ImageProcessingService } from '@/core/ImageProcessingService.js'; +import { VideoProcessingService } from '@/core/VideoProcessingService.js'; +import { InternalStorageService } from '@/core/InternalStorageService.js'; +import { contentDisposition } from '@/misc/content-disposition.js'; +import { FileInfoService } from '@/core/FileInfoService.js'; +import { LoggerService } from '@/core/LoggerService.js'; + +const _filename = fileURLToPath(import.meta.url); +const _dirname = dirname(_filename); + +const assets = `${_dirname}/../../server/file/assets/`; + +@Injectable() +export class FileServerService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private fileInfoService: FileInfoService, + private downloadService: DownloadService, + private imageProcessingService: ImageProcessingService, + private videoProcessingService: VideoProcessingService, + private internalStorageService: InternalStorageService, + private loggerService: LoggerService, + ) { + this.logger = this.loggerService.getLogger('server', 'gray', false); + } + + public commonReadableHandlerGenerator(ctx: Koa.Context) { + return (e: Error): void => { + this.logger.error(e); + ctx.status = 500; + ctx.set('Cache-Control', 'max-age=300'); + }; + } + + public createServer() { + const app = new Koa(); + app.use(cors()); + app.use(async (ctx, next) => { + ctx.set('Content-Security-Policy', 'default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\''); + await next(); + }); + + // Init router + const router = new Router(); + + router.get('/app-default.jpg', ctx => { + const file = fs.createReadStream(`${_dirname}/assets/dummy.png`); + ctx.body = file; + ctx.set('Content-Type', 'image/jpeg'); + ctx.set('Cache-Control', 'max-age=31536000, immutable'); + }); + + router.get('/:key', ctx => this.sendDriveFile(ctx)); + router.get('/:key/(.*)', ctx => this.sendDriveFile(ctx)); + + // Register router + app.use(router.routes()); + + return app; + } + + private async sendDriveFile(ctx: Koa.Context) { + const key = ctx.params.key; + + // Fetch drive file + const file = await this.driveFilesRepository.createQueryBuilder('file') + .where('file.accessKey = :accessKey', { accessKey: key }) + .orWhere('file.thumbnailAccessKey = :thumbnailAccessKey', { thumbnailAccessKey: key }) + .orWhere('file.webpublicAccessKey = :webpublicAccessKey', { webpublicAccessKey: key }) + .getOne(); + + if (file == null) { + ctx.status = 404; + ctx.set('Cache-Control', 'max-age=86400'); + await send(ctx as any, '/dummy.png', { root: assets }); + return; + } + + const isThumbnail = file.thumbnailAccessKey === key; + const isWebpublic = file.webpublicAccessKey === key; + + if (!file.storedInternal) { + if (file.isLink && file.uri) { // 期限切れリモートファイル + const [path, cleanup] = await createTemp(); + + try { + await this.downloadService.downloadUrl(file.uri, path); + + const { mime, ext } = await this.fileInfoService.detectType(path); + + const convertFile = async () => { + if (isThumbnail) { + if (['image/jpeg', 'image/webp', 'image/png', 'image/svg+xml'].includes(mime)) { + return await this.imageProcessingService.convertToWebp(path, 498, 280); + } else if (mime.startsWith('video/')) { + return await this.videoProcessingService.generateVideoThumbnail(path); + } + } + + if (isWebpublic) { + if (['image/svg+xml'].includes(mime)) { + return await this.imageProcessingService.convertToPng(path, 2048, 2048); + } + } + + return { + data: fs.readFileSync(path), + ext, + type: mime, + }; + }; + + const image = await convertFile(); + ctx.body = image.data; + ctx.set('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream'); + ctx.set('Cache-Control', 'max-age=31536000, immutable'); + } catch (err) { + this.logger.error(`${err}`); + + if (err instanceof StatusError && err.isClientError) { + ctx.status = err.statusCode; + ctx.set('Cache-Control', 'max-age=86400'); + } else { + ctx.status = 500; + ctx.set('Cache-Control', 'max-age=300'); + } + } finally { + cleanup(); + } + return; + } + + ctx.status = 204; + ctx.set('Cache-Control', 'max-age=86400'); + return; + } + + if (isThumbnail || isWebpublic) { + const { mime, ext } = await this.fileInfoService.detectType(this.internalStorageService.resolvePath(key)); + const filename = rename(file.name, { + suffix: isThumbnail ? '-thumb' : '-web', + extname: ext ? `.${ext}` : undefined, + }).toString(); + + ctx.body = this.internalStorageService.read(key); + ctx.set('Content-Type', FILE_TYPE_BROWSERSAFE.includes(mime) ? mime : 'application/octet-stream'); + ctx.set('Cache-Control', 'max-age=31536000, immutable'); + ctx.set('Content-Disposition', contentDisposition('inline', filename)); + } else { + const readable = this.internalStorageService.read(file.accessKey!); + readable.on('error', this.commonReadableHandlerGenerator(ctx)); + ctx.body = readable; + ctx.set('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.type) ? file.type : 'application/octet-stream'); + ctx.set('Cache-Control', 'max-age=31536000, immutable'); + ctx.set('Content-Disposition', contentDisposition('inline', file.name)); + } + } +} + diff --git a/packages/backend/src/server/MediaProxyServerService.ts b/packages/backend/src/server/MediaProxyServerService.ts new file mode 100644 index 000000000..1e0385602 --- /dev/null +++ b/packages/backend/src/server/MediaProxyServerService.ts @@ -0,0 +1,140 @@ +import * as fs from 'node:fs'; +import { Inject, Injectable } from '@nestjs/common'; +import Koa from 'koa'; +import cors from '@koa/cors'; +import Router from '@koa/router'; +import sharp from 'sharp'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import { isMimeImage } from '@/misc/is-mime-image.js'; +import { createTemp } from '@/misc/create-temp.js'; +import { DownloadService } from '@/core/DownloadService.js'; +import { ImageProcessingService } from '@/core/ImageProcessingService.js'; +import type { IImage } from '@/core/ImageProcessingService.js'; +import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; +import { StatusError } from '@/misc/status-error.js'; +import type Logger from '@/logger.js'; +import { FileInfoService } from '@/core/FileInfoService.js'; +import { LoggerService } from '@/core/LoggerService.js'; + +@Injectable() +export class MediaProxyServerService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + private fileInfoService: FileInfoService, + private downloadService: DownloadService, + private imageProcessingService: ImageProcessingService, + private loggerService: LoggerService, + ) { + this.logger = this.loggerService.getLogger('server', 'gray', false); + } + + public createServer() { + const app = new Koa(); + app.use(cors()); + app.use(async (ctx, next) => { + ctx.set('Content-Security-Policy', 'default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\''); + await next(); + }); + + // Init router + const router = new Router(); + + router.get('/:url*', ctx => this.handler(ctx)); + + // Register router + app.use(router.routes()); + + return app; + } + + private async handler(ctx: Koa.Context) { + const url = 'url' in ctx.query ? ctx.query.url : 'https://' + ctx.params.url; + + if (typeof url !== 'string') { + ctx.status = 400; + return; + } + + // Create temp file + const [path, cleanup] = await createTemp(); + + try { + await this.downloadService.downloadUrl(url, path); + + const { mime, ext } = await this.fileInfoService.detectType(path); + const isConvertibleImage = isMimeImage(mime, 'sharp-convertible-image'); + + let image: IImage; + + if ('static' in ctx.query && isConvertibleImage) { + image = await this.imageProcessingService.convertToWebp(path, 498, 280); + } else if ('preview' in ctx.query && isConvertibleImage) { + image = await this.imageProcessingService.convertToWebp(path, 200, 200); + } else if ('badge' in ctx.query) { + if (!isConvertibleImage) { + // 画像でないなら404でお茶を濁す + throw new StatusError('Unexpected mime', 404); + } + + const mask = sharp(path) + .resize(96, 96, { + fit: 'inside', + withoutEnlargement: false, + }) + .greyscale() + .normalise() + .linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast + .flatten({ background: '#000' }) + .toColorspace('b-w'); + + const stats = await mask.clone().stats(); + + if (stats.entropy < 0.1) { + // エントロピーがあまりない場合は404にする + throw new StatusError('Skip to provide badge', 404); + } + + const data = sharp({ + create: { width: 96, height: 96, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, + }) + .pipelineColorspace('b-w') + .boolean(await mask.png().toBuffer(), 'eor'); + + image = { + data: await data.png().toBuffer(), + ext: 'png', + type: 'image/png', + }; + } else if (mime === 'image/svg+xml') { + image = await this.imageProcessingService.convertToWebp(path, 2048, 2048, 1); + } else if (!mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(mime)) { + throw new StatusError('Rejected type', 403, 'Rejected type'); + } else { + image = { + data: fs.readFileSync(path), + ext, + type: mime, + }; + } + + ctx.set('Content-Type', image.type); + ctx.set('Cache-Control', 'max-age=31536000, immutable'); + ctx.body = image.data; + } catch (err) { + this.logger.error(`${err}`); + + if (err instanceof StatusError && (err.statusCode === 302 || err.isClientError)) { + ctx.status = err.statusCode; + } else { + ctx.status = 500; + } + } finally { + cleanup(); + } + } +} diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts new file mode 100644 index 000000000..04a5f1484 --- /dev/null +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -0,0 +1,129 @@ +import { Inject, Injectable } from '@nestjs/common'; +import Router from '@koa/router'; +import { IsNull, MoreThan } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { NotesRepository, UsersRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import { MetaService } from '@/core/MetaService.js'; +import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; +import { Cache } from '@/misc/cache.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; + +const nodeinfo2_1path = '/nodeinfo/2.1'; +const nodeinfo2_0path = '/nodeinfo/2.0'; + +@Injectable() +export class NodeinfoServerService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private userEntityService: UserEntityService, + private metaService: MetaService, + ) { + } + + public getLinks() { + return [/* (awaiting release) { + rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1', + href: config.url + nodeinfo2_1path + }, */{ + rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0', + href: this.config.url + nodeinfo2_0path, + }]; + } + + public createRouter() { + const router = new Router(); + + const nodeinfo2 = async () => { + const now = Date.now(); + const [ + meta, + total, + activeHalfyear, + activeMonth, + localPosts, + ] = await Promise.all([ + this.metaService.fetch(true), + this.usersRepository.count({ where: { host: IsNull() } }), + this.usersRepository.count({ where: { host: IsNull(), lastActiveDate: MoreThan(new Date(now - 15552000000)) } }), + this.usersRepository.count({ where: { host: IsNull(), lastActiveDate: MoreThan(new Date(now - 2592000000)) } }), + this.notesRepository.count({ where: { userHost: IsNull() } }), + ]); + + const proxyAccount = meta.proxyAccountId ? await this.userEntityService.pack(meta.proxyAccountId).catch(() => null) : null; + + return { + software: { + name: 'misskey', + version: this.config.version, + repository: meta.repositoryUrl, + }, + protocols: ['activitypub'], + services: { + inbound: [] as string[], + outbound: ['atom1.0', 'rss2.0'], + }, + openRegistrations: !meta.disableRegistration, + usage: { + users: { total, activeHalfyear, activeMonth }, + localPosts, + localComments: 0, + }, + metadata: { + nodeName: meta.name, + nodeDescription: meta.description, + maintainer: { + name: meta.maintainerName, + email: meta.maintainerEmail, + }, + langs: meta.langs, + tosUrl: meta.ToSUrl, + repositoryUrl: meta.repositoryUrl, + feedbackUrl: meta.feedbackUrl, + disableRegistration: meta.disableRegistration, + disableLocalTimeline: meta.disableLocalTimeline, + disableGlobalTimeline: meta.disableGlobalTimeline, + emailRequiredForSignup: meta.emailRequiredForSignup, + enableHcaptcha: meta.enableHcaptcha, + enableRecaptcha: meta.enableRecaptcha, + maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, + enableTwitterIntegration: meta.enableTwitterIntegration, + enableGithubIntegration: meta.enableGithubIntegration, + enableDiscordIntegration: meta.enableDiscordIntegration, + enableEmail: meta.enableEmail, + enableServiceWorker: meta.enableServiceWorker, + proxyAccountName: proxyAccount ? proxyAccount.username : null, + themeColor: meta.themeColor ?? '#86b300', + }, + }; + }; + + const cache = new Cache>>(1000 * 60 * 10); + + router.get(nodeinfo2_1path, async ctx => { + const base = await cache.fetch(null, () => nodeinfo2()); + + ctx.body = { version: '2.1', ...base }; + ctx.set('Cache-Control', 'public, max-age=600'); + }); + + router.get(nodeinfo2_0path, async ctx => { + const base = await cache.fetch(null, () => nodeinfo2()); + + delete base.software.repository; + + ctx.body = { version: '2.0', ...base }; + ctx.set('Cache-Control', 'public, max-age=600'); + }); + + return router; + } +} diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts new file mode 100644 index 000000000..f05eda1cb --- /dev/null +++ b/packages/backend/src/server/ServerModule.ts @@ -0,0 +1,92 @@ +import { Module } from '@nestjs/common'; +import { EndpointsModule } from '@/server/api/EndpointsModule.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import { ApiCallService } from './api/ApiCallService.js'; +import { FileServerService } from './FileServerService.js'; +import { MediaProxyServerService } from './MediaProxyServerService.js'; +import { NodeinfoServerService } from './NodeinfoServerService.js'; +import { ServerService } from './ServerService.js'; +import { WellKnownServerService } from './WellKnownServerService.js'; +import { GetterService } from './api/common/GetterService.js'; +import { DiscordServerService } from './api/integration/DiscordServerService.js'; +import { GithubServerService } from './api/integration/GithubServerService.js'; +import { TwitterServerService } from './api/integration/TwitterServerService.js'; +import { ChannelsService } from './api/stream/ChannelsService.js'; +import { ActivityPubServerService } from './ActivityPubServerService.js'; +import { ApiLoggerService } from './api/ApiLoggerService.js'; +import { ApiServerService } from './api/ApiServerService.js'; +import { AuthenticateService } from './api/AuthenticateService.js'; +import { RateLimiterService } from './api/RateLimiterService.js'; +import { SigninApiService } from './api/SigninApiService.js'; +import { SigninService } from './api/SigninService.js'; +import { SignupApiService } from './api/SignupApiService.js'; +import { StreamingApiServerService } from './api/StreamingApiServerService.js'; +import { ClientServerService } from './web/ClientServerService.js'; +import { FeedService } from './web/FeedService.js'; +import { UrlPreviewService } from './web/UrlPreviewService.js'; +import { MainChannelService } from './api/stream/channels/main.js'; +import { AdminChannelService } from './api/stream/channels/admin.js'; +import { AntennaChannelService } from './api/stream/channels/antenna.js'; +import { ChannelChannelService } from './api/stream/channels/channel.js'; +import { DriveChannelService } from './api/stream/channels/drive.js'; +import { GlobalTimelineChannelService } from './api/stream/channels/global-timeline.js'; +import { HashtagChannelService } from './api/stream/channels/hashtag.js'; +import { HomeTimelineChannelService } from './api/stream/channels/home-timeline.js'; +import { HybridTimelineChannelService } from './api/stream/channels/hybrid-timeline.js'; +import { LocalTimelineChannelService } from './api/stream/channels/local-timeline.js'; +import { MessagingIndexChannelService } from './api/stream/channels/messaging-index.js'; +import { MessagingChannelService } from './api/stream/channels/messaging.js'; +import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js'; +import { ServerStatsChannelService } from './api/stream/channels/server-stats.js'; +import { UserListChannelService } from './api/stream/channels/user-list.js'; + +@Module({ + imports: [ + EndpointsModule, + CoreModule, + ], + providers: [ + ClientServerService, + FeedService, + UrlPreviewService, + ActivityPubServerService, + FileServerService, + MediaProxyServerService, + NodeinfoServerService, + ServerService, + WellKnownServerService, + GetterService, + DiscordServerService, + GithubServerService, + TwitterServerService, + ChannelsService, + ApiCallService, + ApiLoggerService, + ApiServerService, + AuthenticateService, + RateLimiterService, + SigninApiService, + SigninService, + SignupApiService, + StreamingApiServerService, + MainChannelService, + AdminChannelService, + AntennaChannelService, + ChannelChannelService, + DriveChannelService, + GlobalTimelineChannelService, + HashtagChannelService, + HomeTimelineChannelService, + HybridTimelineChannelService, + LocalTimelineChannelService, + MessagingIndexChannelService, + MessagingChannelService, + QueueStatsChannelService, + ServerStatsChannelService, + UserListChannelService, + ], + exports: [ + ServerService, + ], +}) +export class ServerModule {} diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts new file mode 100644 index 000000000..14d5ed45a --- /dev/null +++ b/packages/backend/src/server/ServerService.ts @@ -0,0 +1,180 @@ +import cluster from 'node:cluster'; +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import { Inject, Injectable } from '@nestjs/common'; +import Koa from 'koa'; +import Router from '@koa/router'; +import mount from 'koa-mount'; +import koaLogger from 'koa-logger'; +import * as slow from 'koa-slow'; +import { IsNull } from 'typeorm'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { Config } from '@/config.js'; +import { UserProfilesRepository, UsersRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import type Logger from '@/logger.js'; +import { envOption } from '@/env.js'; +import * as Acct from '@/misc/acct.js'; +import { genIdenticon } from '@/misc/gen-identicon.js'; +import { createTemp } from '@/misc/create-temp.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import { ActivityPubServerService } from './ActivityPubServerService.js'; +import { NodeinfoServerService } from './NodeinfoServerService.js'; +import { ApiServerService } from './api/ApiServerService.js'; +import { StreamingApiServerService } from './api/StreamingApiServerService.js'; +import { WellKnownServerService } from './WellKnownServerService.js'; +import { MediaProxyServerService } from './MediaProxyServerService.js'; +import { FileServerService } from './FileServerService.js'; +import { ClientServerService } from './web/ClientServerService.js'; + +@Injectable() +export class ServerService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + private userEntityService: UserEntityService, + private apiServerService: ApiServerService, + private streamingApiServerService: StreamingApiServerService, + private activityPubServerService: ActivityPubServerService, + private wellKnownServerService: WellKnownServerService, + private nodeinfoServerService: NodeinfoServerService, + private fileServerService: FileServerService, + private mediaProxyServerService: MediaProxyServerService, + private clientServerService: ClientServerService, + private globalEventService: GlobalEventService, + private loggerService: LoggerService, + ) { + this.logger = this.loggerService.getLogger('server', 'gray', false); + } + + public launch() { + // Init app + const koa = new Koa(); + koa.proxy = true; + + if (!['production', 'test'].includes(process.env.NODE_ENV ?? '')) { + // Logger + koa.use(koaLogger(str => { + this.logger.info(str); + })); + + // Delay + if (envOption.slow) { + koa.use(slow({ + delay: 3000, + })); + } + } + + // HSTS + // 6months (15552000sec) + if (this.config.url.startsWith('https') && !this.config.disableHsts) { + koa.use(async (ctx, next) => { + ctx.set('strict-transport-security', 'max-age=15552000; preload'); + await next(); + }); + } + + koa.use(mount('/api', this.apiServerService.createApiServer(koa))); + koa.use(mount('/files', this.fileServerService.createServer())); + koa.use(mount('/proxy', this.mediaProxyServerService.createServer())); + + // Init router + const router = new Router(); + + // Routing + router.use(this.activityPubServerService.createRouter().routes()); + router.use(this.nodeinfoServerService.createRouter().routes()); + router.use(this.wellKnownServerService.createRouter().routes()); + + router.get('/avatar/@:acct', async ctx => { + const { username, host } = Acct.parse(ctx.params.acct); + const user = await this.usersRepository.findOne({ + where: { + usernameLower: username.toLowerCase(), + host: (host == null) || (host === this.config.host) ? IsNull() : host, + isSuspended: false, + }, + relations: ['avatar'], + }); + + if (user) { + ctx.redirect(this.userEntityService.getAvatarUrlSync(user)); + } else { + ctx.redirect('/static-assets/user-unknown.png'); + } + }); + + router.get('/identicon/:x', async ctx => { + const [temp, cleanup] = await createTemp(); + await genIdenticon(ctx.params.x, fs.createWriteStream(temp)); + ctx.set('Content-Type', 'image/png'); + ctx.body = fs.createReadStream(temp).on('close', () => cleanup()); + }); + + router.get('/verify-email/:code', async ctx => { + const profile = await this.userProfilesRepository.findOneBy({ + emailVerifyCode: ctx.params.code, + }); + + if (profile != null) { + ctx.body = 'Verify succeeded!'; + ctx.status = 200; + + await this.userProfilesRepository.update({ userId: profile.userId }, { + emailVerified: true, + emailVerifyCode: null, + }); + + this.globalEventService.publishMainStream(profile.userId, 'meUpdated', await this.userEntityService.pack(profile.userId, { id: profile.userId }, { + detail: true, + includeSecrets: true, + })); + } else { + ctx.status = 404; + } + }); + + // Register router + koa.use(router.routes()); + + koa.use(mount(this.clientServerService.createApp())); + + const server = http.createServer(koa.callback()); + + this.streamingApiServerService.attachStreamingApi(server); + + server.on('error', err => { + switch ((err as any).code) { + case 'EACCES': + this.logger.error(`You do not have permission to listen on port ${this.config.port}.`); + break; + case 'EADDRINUSE': + this.logger.error(`Port ${this.config.port} is already in use by another process.`); + break; + default: + this.logger.error(err); + break; + } + + if (cluster.isWorker) { + process.send!('listenFailed'); + } else { + // disableClustering + process.exit(1); + } + }); + + server.listen(this.config.port); + } +} diff --git a/packages/backend/src/server/WellKnownServerService.ts b/packages/backend/src/server/WellKnownServerService.ts new file mode 100644 index 000000000..7f827d439 --- /dev/null +++ b/packages/backend/src/server/WellKnownServerService.ts @@ -0,0 +1,168 @@ +import { Inject, Injectable } from '@nestjs/common'; +import Router from '@koa/router'; +import { IsNull, MoreThan } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { UsersRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import { escapeAttribute, escapeValue } from '@/misc/prelude/xml.js'; +import type { User } from '@/models/entities/User.js'; +import * as Acct from '@/misc/acct.js'; +import { NodeinfoServerService } from './NodeinfoServerService.js'; +import type { FindOptionsWhere } from 'typeorm'; + +@Injectable() +export class WellKnownServerService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private nodeinfoServerService: NodeinfoServerService, + ) { + } + + public createRouter() { + const router = new Router(); + + const XRD = (...x: { element: string, value?: string, attributes?: Record }[]) => + `${x.map(({ element, value, attributes }) => + `<${ + Object.entries(typeof attributes === 'object' && attributes || {}).reduce((a, [k, v]) => `${a} ${k}="${escapeAttribute(v)}"`, element) + }${ + typeof value === 'string' ? `>${escapeValue(value)}`).reduce((a, c) => a + c, '')}`; + + const allPath = '/.well-known/(.*)'; + const webFingerPath = '/.well-known/webfinger'; + const jrd = 'application/jrd+json'; + const xrd = 'application/xrd+xml'; + + router.use(allPath, async (ctx, next) => { + ctx.set({ + 'Access-Control-Allow-Headers': 'Accept', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Expose-Headers': 'Vary', + }); + await next(); + }); + + router.options(allPath, async ctx => { + ctx.status = 204; + }); + + router.get('/.well-known/host-meta', async ctx => { + ctx.set('Content-Type', xrd); + ctx.body = XRD({ element: 'Link', attributes: { + rel: 'lrdd', + type: xrd, + template: `${this.config.url}${webFingerPath}?resource={uri}`, + } }); + }); + + router.get('/.well-known/host-meta.json', async ctx => { + ctx.set('Content-Type', jrd); + ctx.body = { + links: [{ + rel: 'lrdd', + type: jrd, + template: `${this.config.url}${webFingerPath}?resource={uri}`, + }], + }; + }); + + router.get('/.well-known/nodeinfo', async ctx => { + ctx.body = { links: this.nodeinfoServerService.getLinks() }; + }); + + /* TODO +router.get('/.well-known/change-password', async ctx => { +}); +*/ + + router.get(webFingerPath, async ctx => { + const fromId = (id: User['id']): FindOptionsWhere => ({ + id, + host: IsNull(), + isSuspended: false, + }); + + const generateQuery = (resource: string): FindOptionsWhere | number => + resource.startsWith(`${this.config.url.toLowerCase()}/users/`) ? + fromId(resource.split('/').pop()!) : + fromAcct(Acct.parse( + resource.startsWith(`${this.config.url.toLowerCase()}/@`) ? resource.split('/').pop()! : + resource.startsWith('acct:') ? resource.slice('acct:'.length) : + resource)); + + const fromAcct = (acct: Acct.Acct): FindOptionsWhere | number => + !acct.host || acct.host === this.config.host.toLowerCase() ? { + usernameLower: acct.username, + host: IsNull(), + isSuspended: false, + } : 422; + + if (typeof ctx.query.resource !== 'string') { + ctx.status = 400; + return; + } + + const query = generateQuery(ctx.query.resource.toLowerCase()); + + if (typeof query === 'number') { + ctx.status = query; + return; + } + + const user = await this.usersRepository.findOneBy(query); + + if (user == null) { + ctx.status = 404; + return; + } + + const subject = `acct:${user.username}@${this.config.host}`; + const self = { + rel: 'self', + type: 'application/activity+json', + href: `${this.config.url}/users/${user.id}`, + }; + const profilePage = { + rel: 'http://webfinger.net/rel/profile-page', + type: 'text/html', + href: `${this.config.url}/@${user.username}`, + }; + const subscribe = { + rel: 'http://ostatus.org/schema/1.0/subscribe', + template: `${this.config.url}/authorize-follow?acct={uri}`, + }; + + if (ctx.accepts(jrd, xrd) === xrd) { + ctx.body = XRD( + { element: 'Subject', value: subject }, + { element: 'Link', attributes: self }, + { element: 'Link', attributes: profilePage }, + { element: 'Link', attributes: subscribe }); + ctx.type = xrd; + } else { + ctx.body = { + subject, + links: [self, profilePage, subscribe], + }; + ctx.type = jrd; + } + + ctx.vary('Accept'); + ctx.set('Cache-Control', 'public, max-age=180'); + }); + + // Return 404 for other .well-known + router.all(allPath, async ctx => { + ctx.status = 404; + }); + + return router; + } +} diff --git a/packages/backend/src/server/activitypub.ts b/packages/backend/src/server/activitypub.ts deleted file mode 100644 index cd5f917c4..000000000 --- a/packages/backend/src/server/activitypub.ts +++ /dev/null @@ -1,254 +0,0 @@ -import Router from '@koa/router'; -import json from 'koa-json-body'; -import httpSignature from '@peertube/http-signature'; - -import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import renderNote from '@/remote/activitypub/renderer/note.js'; -import renderKey from '@/remote/activitypub/renderer/key.js'; -import { renderPerson } from '@/remote/activitypub/renderer/person.js'; -import renderEmoji from '@/remote/activitypub/renderer/emoji.js'; -import Outbox, { packActivity } from './activitypub/outbox.js'; -import Followers from './activitypub/followers.js'; -import Following from './activitypub/following.js'; -import Featured from './activitypub/featured.js'; -import { inbox as processInbox } from '@/queue/index.js'; -import { isSelfHost } from '@/misc/convert-host.js'; -import { Notes, Users, Emojis, NoteReactions } from '@/models/index.js'; -import { ILocalUser, User } from '@/models/entities/user.js'; -import { In, IsNull, Not } from 'typeorm'; -import { renderLike } from '@/remote/activitypub/renderer/like.js'; -import { getUserKeypair } from '@/misc/keypair-store.js'; -import renderFollow from '@/remote/activitypub/renderer/follow.js'; - -// Init router -const router = new Router(); - -//#region Routing - -function inbox(ctx: Router.RouterContext) { - let signature; - - try { - signature = httpSignature.parseRequest(ctx.req, { 'headers': [] }); - } catch (e) { - ctx.status = 401; - return; - } - - processInbox(ctx.request.body, signature); - - ctx.status = 202; -} - -const ACTIVITY_JSON = 'application/activity+json; charset=utf-8'; -const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8'; - -function isActivityPubReq(ctx: Router.RouterContext) { - ctx.response.vary('Accept'); - const accepted = ctx.accepts('html', ACTIVITY_JSON, LD_JSON); - return typeof accepted === 'string' && !accepted.match(/html/); -} - -export function setResponseType(ctx: Router.RouterContext) { - const accept = ctx.accepts(ACTIVITY_JSON, LD_JSON); - if (accept === LD_JSON) { - ctx.response.type = LD_JSON; - } else { - ctx.response.type = ACTIVITY_JSON; - } -} - -// inbox -router.post('/inbox', json(), inbox); -router.post('/users/:user/inbox', json(), inbox); - -// note -router.get('/notes/:note', async (ctx, next) => { - if (!isActivityPubReq(ctx)) return await next(); - - const note = await Notes.findOneBy({ - id: ctx.params.note, - visibility: In(['public' as const, 'home' as const]), - localOnly: false, - }); - - if (note == null) { - ctx.status = 404; - return; - } - - // リモートだったらリダイレクト - if (note.userHost != null) { - if (note.uri == null || isSelfHost(note.userHost)) { - ctx.status = 500; - return; - } - ctx.redirect(note.uri); - return; - } - - ctx.body = renderActivity(await renderNote(note, false)); - ctx.set('Cache-Control', 'public, max-age=180'); - setResponseType(ctx); -}); - -// note activity -router.get('/notes/:note/activity', async ctx => { - const note = await Notes.findOneBy({ - id: ctx.params.note, - userHost: IsNull(), - visibility: In(['public' as const, 'home' as const]), - localOnly: false, - }); - - if (note == null) { - ctx.status = 404; - return; - } - - ctx.body = renderActivity(await packActivity(note)); - ctx.set('Cache-Control', 'public, max-age=180'); - setResponseType(ctx); -}); - -// outbox -router.get('/users/:user/outbox', Outbox); - -// followers -router.get('/users/:user/followers', Followers); - -// following -router.get('/users/:user/following', Following); - -// featured -router.get('/users/:user/collections/featured', Featured); - -// publickey -router.get('/users/:user/publickey', async ctx => { - const userId = ctx.params.user; - - const user = await Users.findOneBy({ - id: userId, - host: IsNull(), - }); - - if (user == null) { - ctx.status = 404; - return; - } - - const keypair = await getUserKeypair(user.id); - - if (Users.isLocalUser(user)) { - ctx.body = renderActivity(renderKey(user, keypair)); - ctx.set('Cache-Control', 'public, max-age=180'); - setResponseType(ctx); - } else { - ctx.status = 400; - } -}); - -// user -async function userInfo(ctx: Router.RouterContext, user: User | null) { - if (user == null) { - ctx.status = 404; - return; - } - - ctx.body = renderActivity(await renderPerson(user as ILocalUser)); - ctx.set('Cache-Control', 'public, max-age=180'); - setResponseType(ctx); -} - -router.get('/users/:user', async (ctx, next) => { - if (!isActivityPubReq(ctx)) return await next(); - - const userId = ctx.params.user; - - const user = await Users.findOneBy({ - id: userId, - host: IsNull(), - isSuspended: false, - }); - - await userInfo(ctx, user); -}); - -router.get('/@:user', async (ctx, next) => { - if (!isActivityPubReq(ctx)) return await next(); - - const user = await Users.findOneBy({ - usernameLower: ctx.params.user.toLowerCase(), - host: IsNull(), - isSuspended: false, - }); - - await userInfo(ctx, user); -}); -//#endregion - -// emoji -router.get('/emojis/:emoji', async ctx => { - const emoji = await Emojis.findOneBy({ - host: IsNull(), - name: ctx.params.emoji, - }); - - if (emoji == null) { - ctx.status = 404; - return; - } - - ctx.body = renderActivity(await renderEmoji(emoji)); - ctx.set('Cache-Control', 'public, max-age=180'); - setResponseType(ctx); -}); - -// like -router.get('/likes/:like', async ctx => { - const reaction = await NoteReactions.findOneBy({ id: ctx.params.like }); - - if (reaction == null) { - ctx.status = 404; - return; - } - - const note = await Notes.findOneBy({ id: reaction.noteId }); - - if (note == null) { - ctx.status = 404; - return; - } - - ctx.body = renderActivity(await renderLike(reaction, note)); - ctx.set('Cache-Control', 'public, max-age=180'); - setResponseType(ctx); -}); - -// follow -router.get('/follows/:follower/:followee', async ctx => { - // This may be used before the follow is completed, so we do not - // check if the following exists. - - const [follower, followee] = await Promise.all([ - Users.findOneBy({ - id: ctx.params.follower, - host: IsNull(), - }), - Users.findOneBy({ - id: ctx.params.followee, - host: Not(IsNull()), - }), - ]); - - if (follower == null || followee == null) { - ctx.status = 404; - return; - } - - ctx.body = renderActivity(renderFollow(follower, followee)); - ctx.set('Cache-Control', 'public, max-age=180'); - setResponseType(ctx); -}); - -export default router; diff --git a/packages/backend/src/server/activitypub/featured.ts b/packages/backend/src/server/activitypub/featured.ts deleted file mode 100644 index c03fd1049..000000000 --- a/packages/backend/src/server/activitypub/featured.ts +++ /dev/null @@ -1,41 +0,0 @@ -import Router from '@koa/router'; -import config from '@/config/index.js'; -import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import renderOrderedCollection from '@/remote/activitypub/renderer/ordered-collection.js'; -import { setResponseType } from '../activitypub.js'; -import renderNote from '@/remote/activitypub/renderer/note.js'; -import { Users, Notes, UserNotePinings } from '@/models/index.js'; -import { IsNull } from 'typeorm'; - -export default async (ctx: Router.RouterContext) => { - const userId = ctx.params.user; - - const user = await Users.findOneBy({ - id: userId, - host: IsNull(), - }); - - if (user == null) { - ctx.status = 404; - return; - } - - const pinings = await UserNotePinings.find({ - where: { userId: user.id }, - order: { id: 'DESC' }, - }); - - const pinnedNotes = await Promise.all(pinings.map(pining => - Notes.findOneByOrFail({ id: pining.noteId }))); - - const renderedNotes = await Promise.all(pinnedNotes.map(note => renderNote(note))); - - const rendered = renderOrderedCollection( - `${config.url}/users/${userId}/collections/featured`, - renderedNotes.length, undefined, undefined, renderedNotes, - ); - - ctx.body = renderActivity(rendered); - ctx.set('Cache-Control', 'public, max-age=180'); - setResponseType(ctx); -}; diff --git a/packages/backend/src/server/activitypub/followers.ts b/packages/backend/src/server/activitypub/followers.ts deleted file mode 100644 index beb48713a..000000000 --- a/packages/backend/src/server/activitypub/followers.ts +++ /dev/null @@ -1,95 +0,0 @@ -import Router from '@koa/router'; -import { FindOptionsWhere, IsNull, LessThan } from 'typeorm'; -import config from '@/config/index.js'; -import * as url from '@/prelude/url.js'; -import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import renderOrderedCollection from '@/remote/activitypub/renderer/ordered-collection.js'; -import renderOrderedCollectionPage from '@/remote/activitypub/renderer/ordered-collection-page.js'; -import renderFollowUser from '@/remote/activitypub/renderer/follow-user.js'; -import { Users, Followings, UserProfiles } from '@/models/index.js'; -import { Following } from '@/models/entities/following.js'; -import { setResponseType } from '../activitypub.js'; - -export default async (ctx: Router.RouterContext) => { - const userId = ctx.params.user; - - const cursor = ctx.request.query.cursor; - if (cursor != null && typeof cursor !== 'string') { - ctx.status = 400; - return; - } - - const page = ctx.request.query.page === 'true'; - - const user = await Users.findOneBy({ - id: userId, - host: IsNull(), - }); - - if (user == null) { - ctx.status = 404; - return; - } - - //#region Check ff visibility - const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - - if (profile.ffVisibility === 'private') { - ctx.status = 403; - ctx.set('Cache-Control', 'public, max-age=30'); - return; - } else if (profile.ffVisibility === 'followers') { - ctx.status = 403; - ctx.set('Cache-Control', 'public, max-age=30'); - return; - } - //#endregion - - const limit = 10; - const partOf = `${config.url}/users/${userId}/followers`; - - if (page) { - const query = { - followeeId: user.id, - } as FindOptionsWhere; - - // カーソルが指定されている場合 - if (cursor) { - query.id = LessThan(cursor); - } - - // Get followers - const followings = await Followings.find({ - where: query, - take: limit + 1, - order: { id: -1 }, - }); - - // 「次のページ」があるかどうか - const inStock = followings.length === limit + 1; - if (inStock) followings.pop(); - - const renderedFollowers = await Promise.all(followings.map(following => renderFollowUser(following.followerId))); - const rendered = renderOrderedCollectionPage( - `${partOf}?${url.query({ - page: 'true', - cursor, - })}`, - user.followersCount, renderedFollowers, partOf, - undefined, - inStock ? `${partOf}?${url.query({ - page: 'true', - cursor: followings[followings.length - 1].id, - })}` : undefined, - ); - - ctx.body = renderActivity(rendered); - setResponseType(ctx); - } else { - // index page - const rendered = renderOrderedCollection(partOf, user.followersCount, `${partOf}?page=true`); - ctx.body = renderActivity(rendered); - ctx.set('Cache-Control', 'public, max-age=180'); - setResponseType(ctx); - } -}; diff --git a/packages/backend/src/server/activitypub/following.ts b/packages/backend/src/server/activitypub/following.ts deleted file mode 100644 index 3a25a6316..000000000 --- a/packages/backend/src/server/activitypub/following.ts +++ /dev/null @@ -1,95 +0,0 @@ -import Router from '@koa/router'; -import { LessThan, IsNull, FindOptionsWhere } from 'typeorm'; -import config from '@/config/index.js'; -import * as url from '@/prelude/url.js'; -import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import renderOrderedCollection from '@/remote/activitypub/renderer/ordered-collection.js'; -import renderOrderedCollectionPage from '@/remote/activitypub/renderer/ordered-collection-page.js'; -import renderFollowUser from '@/remote/activitypub/renderer/follow-user.js'; -import { Users, Followings, UserProfiles } from '@/models/index.js'; -import { Following } from '@/models/entities/following.js'; -import { setResponseType } from '../activitypub.js'; - -export default async (ctx: Router.RouterContext) => { - const userId = ctx.params.user; - - const cursor = ctx.request.query.cursor; - if (cursor != null && typeof cursor !== 'string') { - ctx.status = 400; - return; - } - - const page = ctx.request.query.page === 'true'; - - const user = await Users.findOneBy({ - id: userId, - host: IsNull(), - }); - - if (user == null) { - ctx.status = 404; - return; - } - - //#region Check ff visibility - const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - - if (profile.ffVisibility === 'private') { - ctx.status = 403; - ctx.set('Cache-Control', 'public, max-age=30'); - return; - } else if (profile.ffVisibility === 'followers') { - ctx.status = 403; - ctx.set('Cache-Control', 'public, max-age=30'); - return; - } - //#endregion - - const limit = 10; - const partOf = `${config.url}/users/${userId}/following`; - - if (page) { - const query = { - followerId: user.id, - } as FindOptionsWhere; - - // カーソルが指定されている場合 - if (cursor) { - query.id = LessThan(cursor); - } - - // Get followings - const followings = await Followings.find({ - where: query, - take: limit + 1, - order: { id: -1 }, - }); - - // 「次のページ」があるかどうか - const inStock = followings.length === limit + 1; - if (inStock) followings.pop(); - - const renderedFollowees = await Promise.all(followings.map(following => renderFollowUser(following.followeeId))); - const rendered = renderOrderedCollectionPage( - `${partOf}?${url.query({ - page: 'true', - cursor, - })}`, - user.followingCount, renderedFollowees, partOf, - undefined, - inStock ? `${partOf}?${url.query({ - page: 'true', - cursor: followings[followings.length - 1].id, - })}` : undefined, - ); - - ctx.body = renderActivity(rendered); - setResponseType(ctx); - } else { - // index page - const rendered = renderOrderedCollection(partOf, user.followingCount, `${partOf}?page=true`); - ctx.body = renderActivity(rendered); - ctx.set('Cache-Control', 'public, max-age=180'); - setResponseType(ctx); - } -}; diff --git a/packages/backend/src/server/activitypub/outbox.ts b/packages/backend/src/server/activitypub/outbox.ts deleted file mode 100644 index 7a2586998..000000000 --- a/packages/backend/src/server/activitypub/outbox.ts +++ /dev/null @@ -1,108 +0,0 @@ -import Router from '@koa/router'; -import { Brackets, IsNull } from 'typeorm'; -import config from '@/config/index.js'; -import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import renderOrderedCollection from '@/remote/activitypub/renderer/ordered-collection.js'; -import renderOrderedCollectionPage from '@/remote/activitypub/renderer/ordered-collection-page.js'; -import renderNote from '@/remote/activitypub/renderer/note.js'; -import renderCreate from '@/remote/activitypub/renderer/create.js'; -import renderAnnounce from '@/remote/activitypub/renderer/announce.js'; -import { countIf } from '@/prelude/array.js'; -import * as url from '@/prelude/url.js'; -import { Users, Notes } from '@/models/index.js'; -import { Note } from '@/models/entities/note.js'; -import { makePaginationQuery } from '../api/common/make-pagination-query.js'; -import { setResponseType } from '../activitypub.js'; - -export default async (ctx: Router.RouterContext) => { - const userId = ctx.params.user; - - const sinceId = ctx.request.query.since_id; - if (sinceId != null && typeof sinceId !== 'string') { - ctx.status = 400; - return; - } - - const untilId = ctx.request.query.until_id; - if (untilId != null && typeof untilId !== 'string') { - ctx.status = 400; - return; - } - - const page = ctx.request.query.page === 'true'; - - if (countIf(x => x != null, [sinceId, untilId]) > 1) { - ctx.status = 400; - return; - } - - const user = await Users.findOneBy({ - id: userId, - host: IsNull(), - }); - - if (user == null) { - ctx.status = 404; - return; - } - - const limit = 20; - const partOf = `${config.url}/users/${userId}/outbox`; - - if (page) { - const query = makePaginationQuery(Notes.createQueryBuilder('note'), sinceId, untilId) - .andWhere('note.userId = :userId', { userId: user.id }) - .andWhere(new Brackets(qb => { qb - .where('note.visibility = \'public\'') - .orWhere('note.visibility = \'home\''); - })) - .andWhere('note.localOnly = FALSE'); - - const notes = await query.take(limit).getMany(); - - if (sinceId) notes.reverse(); - - const activities = await Promise.all(notes.map(note => packActivity(note))); - const rendered = renderOrderedCollectionPage( - `${partOf}?${url.query({ - page: 'true', - since_id: sinceId, - until_id: untilId, - })}`, - user.notesCount, activities, partOf, - notes.length ? `${partOf}?${url.query({ - page: 'true', - since_id: notes[0].id, - })}` : undefined, - notes.length ? `${partOf}?${url.query({ - page: 'true', - until_id: notes[notes.length - 1].id, - })}` : undefined, - ); - - ctx.body = renderActivity(rendered); - setResponseType(ctx); - } else { - // index page - const rendered = renderOrderedCollection(partOf, user.notesCount, - `${partOf}?page=true`, - `${partOf}?page=true&since_id=000000000000000000000000`, - ); - ctx.body = renderActivity(rendered); - ctx.set('Cache-Control', 'public, max-age=180'); - setResponseType(ctx); - } -}; - -/** - * Pack Create or Announce Activity - * @param note Note - */ -export async function packActivity(note: Note): Promise { - if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) { - const renote = await Notes.findOneByOrFail({ id: note.renoteId }); - return renderAnnounce(renote.uri ? renote.uri : `${config.url}/notes/${renote.id}`, note); - } - - return renderCreate(await renderNote(note, false), note); -} diff --git a/packages/backend/src/server/api/2fa.ts b/packages/backend/src/server/api/2fa.ts deleted file mode 100644 index 96b9316e4..000000000 --- a/packages/backend/src/server/api/2fa.ts +++ /dev/null @@ -1,422 +0,0 @@ -import * as crypto from 'node:crypto'; -import * as jsrsasign from 'jsrsasign'; -import config from '@/config/index.js'; - -const ECC_PRELUDE = Buffer.from([0x04]); -const NULL_BYTE = Buffer.from([0]); -const PEM_PRELUDE = Buffer.from( - '3059301306072a8648ce3d020106082a8648ce3d030107034200', - 'hex', -); - -// Android Safetynet attestations are signed with this cert: -const GSR2 = `-----BEGIN CERTIFICATE----- -MIIDujCCAqKgAwIBAgILBAAAAAABD4Ym5g0wDQYJKoZIhvcNAQEFBQAwTDEgMB4G -A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjIxEzARBgNVBAoTCkdsb2JhbFNp -Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDYxMjE1MDgwMDAwWhcNMjExMjE1 -MDgwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMjETMBEG -A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI -hvcNAQEBBQADggEPADCCAQoCggEBAKbPJA6+Lm8omUVCxKs+IVSbC9N/hHD6ErPL -v4dfxn+G07IwXNb9rfF73OX4YJYJkhD10FPe+3t+c4isUoh7SqbKSaZeqKeMWhG8 -eoLrvozps6yWJQeXSpkqBy+0Hne/ig+1AnwblrjFuTosvNYSuetZfeLQBoZfXklq -tTleiDTsvHgMCJiEbKjNS7SgfQx5TfC4LcshytVsW33hoCmEofnTlEnLJGKRILzd -C9XZzPnqJworc5HGnRusyMvo4KD0L5CLTfuwNhv2GXqF4G3yYROIXJ/gkwpRl4pa -zq+r1feqCapgvdzZX99yqWATXgAByUr6P6TqBwMhAo6CygPCm48CAwEAAaOBnDCB -mTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUm+IH -V2ccHsBqBt5ZtJot39wZhi4wNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2NybC5n -bG9iYWxzaWduLm5ldC9yb290LXIyLmNybDAfBgNVHSMEGDAWgBSb4gdXZxwewGoG -3lm0mi3f3BmGLjANBgkqhkiG9w0BAQUFAAOCAQEAmYFThxxol4aR7OBKuEQLq4Gs -J0/WwbgcQ3izDJr86iw8bmEbTUsp9Z8FHSbBuOmDAGJFtqkIk7mpM0sYmsL4h4hO -291xNBrBVNpGP+DTKqttVCL1OmLNIG+6KYnX3ZHu01yiPqFbQfXf5WRDLenVOavS -ot+3i9DAgBkcRcAtjOj4LaR0VknFBbVPFd5uRHg5h6h+u/N5GJG79G+dwfCMNYxd -AfvDbbnvRG15RjF+Cv6pgsH/76tuIMRQyV+dTZsXjAzlAcmgQWpzU/qlULRuJQ/7 -TBj0/VLZjmmx6BEP3ojY+x1J96relc8geMJgEtslQIxq/H5COEBkEveegeGTLg== ------END CERTIFICATE-----\n`; - -function base64URLDecode(source: string) { - return Buffer.from(source.replace(/\-/g, '+').replace(/_/g, '/'), 'base64'); -} - -function getCertSubject(certificate: string) { - const subjectCert = new jsrsasign.X509(); - subjectCert.readCertPEM(certificate); - - const subjectString = subjectCert.getSubjectString(); - const subjectFields = subjectString.slice(1).split('/'); - - const fields = {} as Record; - for (const field of subjectFields) { - const eqIndex = field.indexOf('='); - fields[field.substring(0, eqIndex)] = field.substring(eqIndex + 1); - } - - return fields; -} - -function verifyCertificateChain(certificates: string[]) { - let valid = true; - - for (let i = 0; i < certificates.length; i++) { - const Cert = certificates[i]; - const certificate = new jsrsasign.X509(); - certificate.readCertPEM(Cert); - - const CACert = i + 1 >= certificates.length ? Cert : certificates[i + 1]; - - const certStruct = jsrsasign.ASN1HEX.getTLVbyList(certificate.hex!, 0, [0]); - const algorithm = certificate.getSignatureAlgorithmField(); - const signatureHex = certificate.getSignatureValueHex(); - - // Verify against CA - const Signature = new jsrsasign.KJUR.crypto.Signature({ alg: algorithm }); - Signature.init(CACert); - Signature.updateHex(certStruct); - valid = valid && !!Signature.verify(signatureHex); // true if CA signed the certificate - } - - return valid; -} - -function PEMString(pemBuffer: Buffer, type = 'CERTIFICATE') { - if (pemBuffer.length === 65 && pemBuffer[0] === 0x04) { - pemBuffer = Buffer.concat([PEM_PRELUDE, pemBuffer], 91); - type = 'PUBLIC KEY'; - } - const cert = pemBuffer.toString('base64'); - - const keyParts = []; - const max = Math.ceil(cert.length / 64); - let start = 0; - for (let i = 0; i < max; i++) { - keyParts.push(cert.substring(start, start + 64)); - start += 64; - } - - return ( - `-----BEGIN ${type}-----\n` + - keyParts.join('\n') + - `\n-----END ${type}-----\n` - ); -} - -export function hash(data: Buffer) { - return crypto - .createHash('sha256') - .update(data) - .digest(); -} - -export function verifyLogin({ - publicKey, - authenticatorData, - clientDataJSON, - clientData, - signature, - challenge, -}: { - publicKey: Buffer, - authenticatorData: Buffer, - clientDataJSON: Buffer, - clientData: any, - signature: Buffer, - challenge: string -}) { - if (clientData.type !== 'webauthn.get') { - throw new Error('type is not webauthn.get'); - } - - if (hash(clientData.challenge).toString('hex') !== challenge) { - throw new Error('challenge mismatch'); - } - if (clientData.origin !== config.scheme + '://' + config.host) { - throw new Error('origin mismatch'); - } - - const verificationData = Buffer.concat( - [authenticatorData, hash(clientDataJSON)], - 32 + authenticatorData.length, - ); - - return crypto - .createVerify('SHA256') - .update(verificationData) - .verify(PEMString(publicKey), signature); -} - -export const procedures = { - none: { - verify({ publicKey }: { publicKey: Map }) { - const negTwo = publicKey.get(-2); - - if (!negTwo || negTwo.length !== 32) { - throw new Error('invalid or no -2 key given'); - } - const negThree = publicKey.get(-3); - if (!negThree || negThree.length !== 32) { - throw new Error('invalid or no -3 key given'); - } - - const publicKeyU2F = Buffer.concat( - [ECC_PRELUDE, negTwo, negThree], - 1 + 32 + 32, - ); - - return { - publicKey: publicKeyU2F, - valid: true, - }; - }, - }, - 'android-key': { - verify({ - attStmt, - authenticatorData, - clientDataHash, - publicKey, - rpIdHash, - credentialId, - }: { - attStmt: any, - authenticatorData: Buffer, - clientDataHash: Buffer, - publicKey: Map; - rpIdHash: Buffer, - credentialId: Buffer, - }) { - if (attStmt.alg !== -7) { - throw new Error('alg mismatch'); - } - - const verificationData = Buffer.concat([ - authenticatorData, - clientDataHash, - ]); - - const attCert: Buffer = attStmt.x5c[0]; - - const negTwo = publicKey.get(-2); - - if (!negTwo || negTwo.length !== 32) { - throw new Error('invalid or no -2 key given'); - } - const negThree = publicKey.get(-3); - if (!negThree || negThree.length !== 32) { - throw new Error('invalid or no -3 key given'); - } - - const publicKeyData = Buffer.concat( - [ECC_PRELUDE, negTwo, negThree], - 1 + 32 + 32, - ); - - if (!attCert.equals(publicKeyData)) { - throw new Error('public key mismatch'); - } - - const isValid = crypto - .createVerify('SHA256') - .update(verificationData) - .verify(PEMString(attCert), attStmt.sig); - - // TODO: Check 'attestationChallenge' field in extension of cert matches hash(clientDataJSON) - - return { - valid: isValid, - publicKey: publicKeyData, - }; - }, - }, - // what a stupid attestation - 'android-safetynet': { - verify({ - attStmt, - authenticatorData, - clientDataHash, - publicKey, - rpIdHash, - credentialId, - }: { - attStmt: any, - authenticatorData: Buffer, - clientDataHash: Buffer, - publicKey: Map; - rpIdHash: Buffer, - credentialId: Buffer, - }) { - const verificationData = hash( - Buffer.concat([authenticatorData, clientDataHash]), - ); - - const jwsParts = attStmt.response.toString('utf-8').split('.'); - - const header = JSON.parse(base64URLDecode(jwsParts[0]).toString('utf-8')); - const response = JSON.parse( - base64URLDecode(jwsParts[1]).toString('utf-8'), - ); - const signature = jwsParts[2]; - - if (!verificationData.equals(Buffer.from(response.nonce, 'base64'))) { - throw new Error('invalid nonce'); - } - - const certificateChain = header.x5c - .map((key: any) => PEMString(key)) - .concat([GSR2]); - - if (getCertSubject(certificateChain[0]).CN !== 'attest.android.com') { - throw new Error('invalid common name'); - } - - if (!verifyCertificateChain(certificateChain)) { - throw new Error('Invalid certificate chain!'); - } - - const signatureBase = Buffer.from( - jwsParts[0] + '.' + jwsParts[1], - 'utf-8', - ); - - const valid = crypto - .createVerify('sha256') - .update(signatureBase) - .verify(certificateChain[0], base64URLDecode(signature)); - - const negTwo = publicKey.get(-2); - - if (!negTwo || negTwo.length !== 32) { - throw new Error('invalid or no -2 key given'); - } - const negThree = publicKey.get(-3); - if (!negThree || negThree.length !== 32) { - throw new Error('invalid or no -3 key given'); - } - - const publicKeyData = Buffer.concat( - [ECC_PRELUDE, negTwo, negThree], - 1 + 32 + 32, - ); - return { - valid, - publicKey: publicKeyData, - }; - }, - }, - packed: { - verify({ - attStmt, - authenticatorData, - clientDataHash, - publicKey, - rpIdHash, - credentialId, - }: { - attStmt: any, - authenticatorData: Buffer, - clientDataHash: Buffer, - publicKey: Map; - rpIdHash: Buffer, - credentialId: Buffer, - }) { - const verificationData = Buffer.concat([ - authenticatorData, - clientDataHash, - ]); - - if (attStmt.x5c) { - const attCert = attStmt.x5c[0]; - - const validSignature = crypto - .createVerify('SHA256') - .update(verificationData) - .verify(PEMString(attCert), attStmt.sig); - - const negTwo = publicKey.get(-2); - - if (!negTwo || negTwo.length !== 32) { - throw new Error('invalid or no -2 key given'); - } - const negThree = publicKey.get(-3); - if (!negThree || negThree.length !== 32) { - throw new Error('invalid or no -3 key given'); - } - - const publicKeyData = Buffer.concat( - [ECC_PRELUDE, negTwo, negThree], - 1 + 32 + 32, - ); - - return { - valid: validSignature, - publicKey: publicKeyData, - }; - } else if (attStmt.ecdaaKeyId) { - // https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-ecdaa-algorithm-v2.0-id-20180227.html#ecdaa-verify-operation - throw new Error('ECDAA-Verify is not supported'); - } else { - if (attStmt.alg !== -7) throw new Error('alg mismatch'); - - throw new Error('self attestation is not supported'); - } - }, - }, - - 'fido-u2f': { - verify({ - attStmt, - authenticatorData, - clientDataHash, - publicKey, - rpIdHash, - credentialId, - }: { - attStmt: any, - authenticatorData: Buffer, - clientDataHash: Buffer, - publicKey: Map, - rpIdHash: Buffer, - credentialId: Buffer - }) { - const x5c: Buffer[] = attStmt.x5c; - if (x5c.length !== 1) { - throw new Error('x5c length does not match expectation'); - } - - const attCert = x5c[0]; - - // TODO: make sure attCert is an Elliptic Curve (EC) public key over the P-256 curve - - const negTwo: Buffer = publicKey.get(-2); - - if (!negTwo || negTwo.length !== 32) { - throw new Error('invalid or no -2 key given'); - } - const negThree: Buffer = publicKey.get(-3); - if (!negThree || negThree.length !== 32) { - throw new Error('invalid or no -3 key given'); - } - - const publicKeyU2F = Buffer.concat( - [ECC_PRELUDE, negTwo, negThree], - 1 + 32 + 32, - ); - - const verificationData = Buffer.concat([ - NULL_BYTE, - rpIdHash, - clientDataHash, - credentialId, - publicKeyU2F, - ]); - - const validSignature = crypto - .createVerify('SHA256') - .update(verificationData) - .verify(PEMString(attCert), attStmt.sig); - - return { - valid: validSignature, - publicKey: publicKeyU2F, - }; - }, - }, -}; diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts new file mode 100644 index 000000000..d13b8d5ce --- /dev/null +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -0,0 +1,258 @@ +import { performance } from 'perf_hooks'; +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { getIpHash } from '@/misc/get-ip-hash.js'; +import type { CacheableLocalUser, User } from '@/models/entities/User.js'; +import type { AccessToken } from '@/models/entities/AccessToken.js'; +import type Logger from '@/logger.js'; +import { UserIpsRepository } from '@/models/index.js'; +import { MetaService } from '@/core/MetaService.js'; +import { ApiError } from './error.js'; +import { RateLimiterService } from './RateLimiterService.js'; +import { ApiLoggerService } from './ApiLoggerService.js'; +import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; +import type { OnApplicationShutdown } from '@nestjs/common'; +import type { IEndpointMeta, IEndpoint } from './endpoints.js'; +import type Koa from 'koa'; + +const accessDenied = { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: '56f35758-7dd5-468b-8439-5d6fb8ec9b8e', +}; + +@Injectable() +export class ApiCallService implements OnApplicationShutdown { + private logger: Logger; + private userIpHistories: Map>; + private userIpHistoriesClearIntervalId: NodeJS.Timer; + + constructor( + @Inject(DI.userIpsRepository) + private userIpsRepository: UserIpsRepository, + + private metaService: MetaService, + private authenticateService: AuthenticateService, + private rateLimiterService: RateLimiterService, + private apiLoggerService: ApiLoggerService, + ) { + this.logger = this.apiLoggerService.logger; + this.userIpHistories = new Map>(); + + this.userIpHistoriesClearIntervalId = setInterval(() => { + this.userIpHistories.clear(); + }, 1000 * 60 * 60); + } + + public handleRequest(endpoint: IEndpoint, exec: any, ctx: Koa.Context) { + return new Promise((res) => { + const body = ctx.is('multipart/form-data') + ? (ctx.request as any).body + : ctx.method === 'GET' + ? ctx.query + : ctx.request.body; + + const reply = (x?: any, y?: ApiError) => { + if (x == null) { + ctx.status = 204; + } else if (typeof x === 'number' && y) { + ctx.status = x; + ctx.body = { + error: { + message: y!.message, + code: y!.code, + id: y!.id, + kind: y!.kind, + ...(y!.info ? { info: y!.info } : {}), + }, + }; + } else { + // 文字列を返す場合は、JSON.stringify通さないとJSONと認識されない + ctx.body = typeof x === 'string' ? JSON.stringify(x) : x; + } + res(); + }; + + // Authentication + this.authenticateService.authenticate(body['i']).then(([user, app]) => { + // API invoking + this.call(endpoint, exec, user, app, body, ctx).then((res: any) => { + if (ctx.method === 'GET' && endpoint.meta.cacheSec && !body['i'] && !user) { + ctx.set('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`); + } + reply(res); + }).catch((e: ApiError) => { + reply(e.httpStatusCode ? e.httpStatusCode : e.kind === 'client' ? 400 : 500, e); + }); + + // Log IP + if (user) { + this.metaService.fetch().then(meta => { + if (!meta.enableIpLogging) return; + const ip = ctx.ip; + const ips = this.userIpHistories.get(user.id); + if (ips == null || !ips.has(ip)) { + if (ips == null) { + this.userIpHistories.set(user.id, new Set([ip])); + } else { + ips.add(ip); + } + + try { + this.userIpsRepository.createQueryBuilder().insert().values({ + createdAt: new Date(), + userId: user.id, + ip: ip, + }).orIgnore(true).execute(); + } catch { + } + } + }); + } + }).catch(e => { + if (e instanceof AuthenticationError) { + reply(403, new ApiError({ + message: 'Authentication failed. Please ensure your token is correct.', + code: 'AUTHENTICATION_FAILED', + id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14', + })); + } else { + reply(500, new ApiError()); + } + }); + }); + } + + private async call( + ep: IEndpoint, + exec: any, + user: CacheableLocalUser | null | undefined, + token: AccessToken | null | undefined, + data: any, + ctx?: Koa.Context, + ) { + const isSecure = user != null && token == null; + const isModerator = user != null && (user.isModerator || user.isAdmin); + + if (ep.meta.secure && !isSecure) { + throw new ApiError(accessDenied); + } + + if (ep.meta.limit) { + // koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app. + let limitActor: string; + if (user) { + limitActor = user.id; + } else { + limitActor = getIpHash(ctx!.ip); + } + + const limit = Object.assign({}, ep.meta.limit); + + if (!limit.key) { + limit.key = ep.name; + } + + // Rate limit + await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable }, limitActor).catch(e => { + throw new ApiError({ + message: 'Rate limit exceeded. Please try again later.', + code: 'RATE_LIMIT_EXCEEDED', + id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', + httpStatusCode: 429, + }); + }); + } + + if (ep.meta.requireCredential && user == null) { + throw new ApiError({ + message: 'Credential required.', + code: 'CREDENTIAL_REQUIRED', + id: '1384574d-a912-4b81-8601-c7b1c4085df1', + httpStatusCode: 401, + }); + } + + if (ep.meta.requireCredential && user!.isSuspended) { + throw new ApiError({ + message: 'Your account has been suspended.', + code: 'YOUR_ACCOUNT_SUSPENDED', + id: 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370', + httpStatusCode: 403, + }); + } + + if (ep.meta.requireAdmin && !user!.isAdmin) { + throw new ApiError(accessDenied, { reason: 'You are not the admin.' }); + } + + if (ep.meta.requireModerator && !isModerator) { + throw new ApiError(accessDenied, { reason: 'You are not a moderator.' }); + } + + if (token && ep.meta.kind && !token.permission.some(p => p === ep.meta.kind)) { + throw new ApiError({ + message: 'Your app does not have the necessary permissions to use this endpoint.', + code: 'PERMISSION_DENIED', + id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838', + }); + } + + // Cast non JSON input + if ((ep.meta.requireFile || ctx?.method === 'GET') && ep.params.properties) { + for (const k of Object.keys(ep.params.properties)) { + const param = ep.params.properties![k]; + if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') { + try { + data[k] = JSON.parse(data[k]); + } catch (e) { + throw new ApiError({ + message: 'Invalid param.', + code: 'INVALID_PARAM', + id: '0b5f1631-7c1a-41a6-b399-cce335f34d85', + }, { + param: k, + reason: `cannot cast to ${param.type}`, + }); + } + } + } + } + + // API invoking + const before = performance.now(); + return await exec(data, user, token, ctx?.file, ctx?.ip, ctx?.headers).catch((err: Error) => { + if (err instanceof ApiError) { + throw err; + } else { + this.logger.error(`Internal error occurred in ${ep.name}: ${err.message}`, { + ep: ep.name, + ps: data, + e: { + message: err.message, + code: err.name, + stack: err.stack, + }, + }); + console.error(err); + throw new ApiError(null, { + e: { + message: err.message, + code: err.name, + stack: err.stack, + }, + }); + } + }).finally(() => { + const after = performance.now(); + const time = after - before; + if (time > 1000) { + this.logger.warn(`SLOW API CALL DETECTED: ${ep.name} (${time}ms)`); + } + }); + } + + public onApplicationShutdown(signal?: string | undefined) { + clearInterval(this.userIpHistoriesClearIntervalId); + } +} diff --git a/packages/backend/src/server/api/ApiLoggerService.ts b/packages/backend/src/server/api/ApiLoggerService.ts new file mode 100644 index 000000000..c4fb25036 --- /dev/null +++ b/packages/backend/src/server/api/ApiLoggerService.ts @@ -0,0 +1,14 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type Logger from '@/logger.js'; +import { LoggerService } from '@/core/LoggerService.js'; + +@Injectable() +export class ApiLoggerService { + public logger: Logger; + + constructor( + private loggerService: LoggerService, + ) { + this.logger = this.loggerService.getLogger('api'); + } +} diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts new file mode 100644 index 000000000..cfe39238d --- /dev/null +++ b/packages/backend/src/server/api/ApiServerService.ts @@ -0,0 +1,160 @@ +import { Inject, Injectable } from '@nestjs/common'; +import Koa from 'koa'; +import Router from '@koa/router'; +import multer from '@koa/multer'; +import bodyParser from 'koa-bodyparser'; +import cors from '@koa/cors'; +import { ModuleRef } from '@nestjs/core'; +import { Config } from '@/config.js'; +import { UsersRepository, InstancesRepository, AccessTokensRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import endpoints from './endpoints.js'; +import { ApiCallService } from './ApiCallService.js'; +import { SignupApiService } from './SignupApiService.js'; +import { SigninApiService } from './SigninApiService.js'; +import { GithubServerService } from './integration/GithubServerService.js'; +import { DiscordServerService } from './integration/DiscordServerService.js'; +import { TwitterServerService } from './integration/TwitterServerService.js'; + +@Injectable() +export class ApiServerService { + constructor( + private moduleRef: ModuleRef, + + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + @Inject(DI.accessTokensRepository) + private accessTokensRepository: AccessTokensRepository, + + private userEntityService: UserEntityService, + private apiCallService: ApiCallService, + private signupApiServiceService: SignupApiService, + private signinApiServiceService: SigninApiService, + private githubServerService: GithubServerService, + private discordServerService: DiscordServerService, + private twitterServerService: TwitterServerService, + ) { + } + + public createApiServer() { + const handlers: Record = {}; + + for (const endpoint of endpoints) { + handlers[endpoint.name] = this.moduleRef.get('ep:' + endpoint.name, { strict: false }).exec; + } + + // Init app + const apiServer = new Koa(); + + apiServer.use(cors({ + origin: '*', + })); + + // No caching + apiServer.use(async (ctx, next) => { + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); + await next(); + }); + + apiServer.use(bodyParser({ + // リクエストが multipart/form-data でない限りはJSONだと見なす + detectJSON: ctx => !ctx.is('multipart/form-data'), + })); + + // Init multer instance + const upload = multer({ + storage: multer.diskStorage({}), + limits: { + fileSize: this.config.maxFileSize ?? 262144000, + files: 1, + }, + }); + + // Init router + const router = new Router(); + + /** + * Register endpoint handlers + */ + for (const endpoint of endpoints) { + if (endpoint.meta.requireFile) { + router.post(`/${endpoint.name}`, upload.single('file'), this.apiCallService.handleRequest.bind(this.apiCallService, endpoint, handlers[endpoint.name])); + } else { + // 後方互換性のため + if (endpoint.name.includes('-')) { + router.post(`/${endpoint.name.replace(/-/g, '_')}`, this.apiCallService.handleRequest.bind(this.apiCallService, endpoint, handlers[endpoint.name])); + + if (endpoint.meta.allowGet) { + router.get(`/${endpoint.name.replace(/-/g, '_')}`, this.apiCallService.handleRequest.bind(this.apiCallService, endpoint, handlers[endpoint.name])); + } else { + router.get(`/${endpoint.name.replace(/-/g, '_')}`, async ctx => { ctx.status = 405; }); + } + } + + router.post(`/${endpoint.name}`, this.apiCallService.handleRequest.bind(this.apiCallService, endpoint, handlers[endpoint.name])); + + if (endpoint.meta.allowGet) { + router.get(`/${endpoint.name}`, this.apiCallService.handleRequest.bind(this.apiCallService, endpoint, handlers[endpoint.name])); + } else { + router.get(`/${endpoint.name}`, async ctx => { ctx.status = 405; }); + } + } + } + + router.post('/signup', ctx => this.signupApiServiceService.signup(ctx)); + router.post('/signin', ctx => this.signinApiServiceService.signin(ctx)); + router.post('/signup-pending', ctx => this.signupApiServiceService.signupPending(ctx)); + + router.use(this.discordServerService.create().routes()); + router.use(this.githubServerService.create().routes()); + router.use(this.twitterServerService.create().routes()); + + router.get('/v1/instance/peers', async ctx => { + const instances = await this.instancesRepository.find({ + select: ['host'], + }); + + ctx.body = instances.map(instance => instance.host); + }); + + router.post('/miauth/:session/check', async ctx => { + const token = await this.accessTokensRepository.findOneBy({ + session: ctx.params.session, + }); + + if (token && token.session != null && !token.fetched) { + this.accessTokensRepository.update(token.id, { + fetched: true, + }); + + ctx.body = { + ok: true, + token: token.token, + user: await this.userEntityService.pack(token.userId, null, { detail: true }), + }; + } else { + ctx.body = { + ok: false, + }; + } + }); + + // Return 404 for unknown API + router.all('(.*)', async ctx => { + ctx.status = 404; + }); + + // Register router + apiServer.use(router.routes()); + + return apiServer; + } +} diff --git a/packages/backend/src/server/api/AuthenticateService.ts b/packages/backend/src/server/api/AuthenticateService.ts new file mode 100644 index 000000000..29d6ba78f --- /dev/null +++ b/packages/backend/src/server/api/AuthenticateService.ts @@ -0,0 +1,86 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { AccessTokensRepository, AppsRepository, UsersRepository } from '@/models/index.js'; +import type { CacheableLocalUser, ILocalUser } from '@/models/entities/User.js'; +import type { AccessToken } from '@/models/entities/AccessToken.js'; +import { Cache } from '@/misc/cache.js'; +import type { App } from '@/models/entities/App.js'; +import { UserCacheService } from '@/core/UserCacheService.js'; +import isNativeToken from '@/misc/is-native-token.js'; + +export class AuthenticationError extends Error { + constructor(message: string) { + super(message); + this.name = 'AuthenticationError'; + } +} + +@Injectable() +export class AuthenticateService { + private appCache: Cache; + + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.accessTokensRepository) + private accessTokensRepository: AccessTokensRepository, + + @Inject(DI.appsRepository) + private appsRepository: AppsRepository, + + private userCacheService: UserCacheService, + ) { + this.appCache = new Cache(Infinity); + } + + public async authenticate(token: string | null): Promise<[CacheableLocalUser | null | undefined, AccessToken | null | undefined]> { + if (token == null) { + return [null, null]; + } + + if (isNativeToken(token)) { + const user = await this.userCacheService.localUserByNativeTokenCache.fetch(token, + () => this.usersRepository.findOneBy({ token }) as Promise); + + if (user == null) { + throw new AuthenticationError('user not found'); + } + + return [user, null]; + } else { + const accessToken = await this.accessTokensRepository.findOne({ + where: [{ + hash: token.toLowerCase(), // app + }, { + token: token, // miauth + }], + }); + + if (accessToken == null) { + throw new AuthenticationError('invalid signature'); + } + + this.accessTokensRepository.update(accessToken.id, { + lastUsedAt: new Date(), + }); + + const user = await this.userCacheService.localUserByIdCache.fetch(accessToken.userId, + () => this.usersRepository.findOneBy({ + id: accessToken.userId, + }) as Promise); + + if (accessToken.appId) { + const app = await this.appCache.fetch(accessToken.appId, + () => this.appsRepository.findOneByOrFail({ id: accessToken.appId! })); + + return [user, { + id: accessToken.id, + permission: app.permission, + } as AccessToken]; + } else { + return [user, accessToken]; + } + } + } +} diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts new file mode 100644 index 000000000..d2dfd78fd --- /dev/null +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -0,0 +1,1268 @@ +import { Module } from '@nestjs/common'; + +import { CoreModule } from '@/core/CoreModule.js'; +import * as ep___admin_meta from './endpoints/admin/meta.js'; +import * as ep___admin_abuseUserReports from './endpoints/admin/abuse-user-reports.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_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'; +import * as ep___admin_ad_update from './endpoints/admin/ad/update.js'; +import * as ep___admin_announcements_create from './endpoints/admin/announcements/create.js'; +import * as ep___admin_announcements_delete from './endpoints/admin/announcements/delete.js'; +import * as ep___admin_announcements_list from './endpoints/admin/announcements/list.js'; +import * as ep___admin_announcements_update from './endpoints/admin/announcements/update.js'; +import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js'; +import * as ep___admin_drive_cleanRemoteFiles from './endpoints/admin/drive/clean-remote-files.js'; +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_add from './endpoints/admin/emoji/add.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_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_update from './endpoints/admin/emoji/update.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'; +import * as ep___admin_federation_updateInstance from './endpoints/admin/federation/update-instance.js'; +import * as ep___admin_getIndexStats from './endpoints/admin/get-index-stats.js'; +import * as ep___admin_getTableStats from './endpoints/admin/get-table-stats.js'; +import * as ep___admin_getUserIps from './endpoints/admin/get-user-ips.js'; +import * as ep___admin_invite from './endpoints/admin/invite.js'; +import * as ep___admin_moderators_add from './endpoints/admin/moderators/add.js'; +import * as ep___admin_moderators_remove from './endpoints/admin/moderators/remove.js'; +import * as ep___admin_promo_create from './endpoints/admin/promo/create.js'; +import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js'; +import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js'; +import * as ep___admin_queue_inboxDelayed from './endpoints/admin/queue/inbox-delayed.js'; +import * as ep___admin_queue_stats from './endpoints/admin/queue/stats.js'; +import * as ep___admin_relays_add from './endpoints/admin/relays/add.js'; +import * as ep___admin_relays_list from './endpoints/admin/relays/list.js'; +import * as ep___admin_relays_remove from './endpoints/admin/relays/remove.js'; +import * as ep___admin_resetPassword from './endpoints/admin/reset-password.js'; +import * as ep___admin_resolveAbuseUserReport from './endpoints/admin/resolve-abuse-user-report.js'; +import * as ep___admin_sendEmail from './endpoints/admin/send-email.js'; +import * as ep___admin_serverInfo from './endpoints/admin/server-info.js'; +import * as ep___admin_showModerationLogs from './endpoints/admin/show-moderation-logs.js'; +import * as ep___admin_showUser from './endpoints/admin/show-user.js'; +import * as ep___admin_showUsers from './endpoints/admin/show-users.js'; +import * as ep___admin_silenceUser from './endpoints/admin/silence-user.js'; +import * as ep___admin_suspendUser from './endpoints/admin/suspend-user.js'; +import * as ep___admin_unsilenceUser from './endpoints/admin/unsilence-user.js'; +import * as ep___admin_unsuspendUser from './endpoints/admin/unsuspend-user.js'; +import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js'; +import * as ep___admin_deleteAccount from './endpoints/admin/delete-account.js'; +import * as ep___admin_updateUserNote from './endpoints/admin/update-user-note.js'; +import * as ep___announcements from './endpoints/announcements.js'; +import * as ep___antennas_create from './endpoints/antennas/create.js'; +import * as ep___antennas_delete from './endpoints/antennas/delete.js'; +import * as ep___antennas_list from './endpoints/antennas/list.js'; +import * as ep___antennas_notes from './endpoints/antennas/notes.js'; +import * as ep___antennas_show from './endpoints/antennas/show.js'; +import * as ep___antennas_update from './endpoints/antennas/update.js'; +import * as ep___ap_get from './endpoints/ap/get.js'; +import * as ep___ap_show from './endpoints/ap/show.js'; +import * as ep___app_create from './endpoints/app/create.js'; +import * as ep___app_show from './endpoints/app/show.js'; +import * as ep___auth_accept from './endpoints/auth/accept.js'; +import * as ep___auth_session_generate from './endpoints/auth/session/generate.js'; +import * as ep___auth_session_show from './endpoints/auth/session/show.js'; +import * as ep___auth_session_userkey from './endpoints/auth/session/userkey.js'; +import * as ep___blocking_create from './endpoints/blocking/create.js'; +import * as ep___blocking_delete from './endpoints/blocking/delete.js'; +import * as ep___blocking_list from './endpoints/blocking/list.js'; +import * as ep___channels_create from './endpoints/channels/create.js'; +import * as ep___channels_featured from './endpoints/channels/featured.js'; +import * as ep___channels_follow from './endpoints/channels/follow.js'; +import * as ep___channels_followed from './endpoints/channels/followed.js'; +import * as ep___channels_owned from './endpoints/channels/owned.js'; +import * as ep___channels_show from './endpoints/channels/show.js'; +import * as ep___channels_timeline from './endpoints/channels/timeline.js'; +import * as ep___channels_unfollow from './endpoints/channels/unfollow.js'; +import * as ep___channels_update from './endpoints/channels/update.js'; +import * as ep___charts_activeUsers from './endpoints/charts/active-users.js'; +import * as ep___charts_apRequest from './endpoints/charts/ap-request.js'; +import * as ep___charts_drive from './endpoints/charts/drive.js'; +import * as ep___charts_federation from './endpoints/charts/federation.js'; +import * as ep___charts_hashtag from './endpoints/charts/hashtag.js'; +import * as ep___charts_instance from './endpoints/charts/instance.js'; +import * as ep___charts_notes from './endpoints/charts/notes.js'; +import * as ep___charts_user_drive from './endpoints/charts/user/drive.js'; +import * as ep___charts_user_following from './endpoints/charts/user/following.js'; +import * as ep___charts_user_notes from './endpoints/charts/user/notes.js'; +import * as ep___charts_user_reactions from './endpoints/charts/user/reactions.js'; +import * as ep___charts_users from './endpoints/charts/users.js'; +import * as ep___clips_addNote from './endpoints/clips/add-note.js'; +import * as ep___clips_removeNote from './endpoints/clips/remove-note.js'; +import * as ep___clips_create from './endpoints/clips/create.js'; +import * as ep___clips_delete from './endpoints/clips/delete.js'; +import * as ep___clips_list from './endpoints/clips/list.js'; +import * as ep___clips_notes from './endpoints/clips/notes.js'; +import * as ep___clips_show from './endpoints/clips/show.js'; +import * as ep___clips_update from './endpoints/clips/update.js'; +import * as ep___drive from './endpoints/drive.js'; +import * as ep___drive_files from './endpoints/drive/files.js'; +import * as ep___drive_files_attachedNotes from './endpoints/drive/files/attached-notes.js'; +import * as ep___drive_files_checkExistence from './endpoints/drive/files/check-existence.js'; +import * as ep___drive_files_create from './endpoints/drive/files/create.js'; +import * as ep___drive_files_delete from './endpoints/drive/files/delete.js'; +import * as ep___drive_files_findByHash from './endpoints/drive/files/find-by-hash.js'; +import * as ep___drive_files_find from './endpoints/drive/files/find.js'; +import * as ep___drive_files_show from './endpoints/drive/files/show.js'; +import * as ep___drive_files_update from './endpoints/drive/files/update.js'; +import * as ep___drive_files_uploadFromUrl from './endpoints/drive/files/upload-from-url.js'; +import * as ep___drive_folders from './endpoints/drive/folders.js'; +import * as ep___drive_folders_create from './endpoints/drive/folders/create.js'; +import * as ep___drive_folders_delete from './endpoints/drive/folders/delete.js'; +import * as ep___drive_folders_find from './endpoints/drive/folders/find.js'; +import * as ep___drive_folders_show from './endpoints/drive/folders/show.js'; +import * as ep___drive_folders_update from './endpoints/drive/folders/update.js'; +import * as ep___drive_stream from './endpoints/drive/stream.js'; +import * as ep___emailAddress_available from './endpoints/email-address/available.js'; +import * as ep___endpoint from './endpoints/endpoint.js'; +import * as ep___endpoints from './endpoints/endpoints.js'; +import * as ep___exportCustomEmojis from './endpoints/export-custom-emojis.js'; +import * as ep___federation_followers from './endpoints/federation/followers.js'; +import * as ep___federation_following from './endpoints/federation/following.js'; +import * as ep___federation_instances from './endpoints/federation/instances.js'; +import * as ep___federation_showInstance from './endpoints/federation/show-instance.js'; +import * as ep___federation_updateRemoteUser from './endpoints/federation/update-remote-user.js'; +import * as ep___federation_users from './endpoints/federation/users.js'; +import * as ep___federation_stats from './endpoints/federation/stats.js'; +import * as ep___following_create from './endpoints/following/create.js'; +import * as ep___following_delete from './endpoints/following/delete.js'; +import * as ep___following_invalidate from './endpoints/following/invalidate.js'; +import * as ep___following_requests_accept from './endpoints/following/requests/accept.js'; +import * as ep___following_requests_cancel from './endpoints/following/requests/cancel.js'; +import * as ep___following_requests_list from './endpoints/following/requests/list.js'; +import * as ep___following_requests_reject from './endpoints/following/requests/reject.js'; +import * as ep___gallery_featured from './endpoints/gallery/featured.js'; +import * as ep___gallery_popular from './endpoints/gallery/popular.js'; +import * as ep___gallery_posts from './endpoints/gallery/posts.js'; +import * as ep___gallery_posts_create from './endpoints/gallery/posts/create.js'; +import * as ep___gallery_posts_delete from './endpoints/gallery/posts/delete.js'; +import * as ep___gallery_posts_like from './endpoints/gallery/posts/like.js'; +import * as ep___gallery_posts_show from './endpoints/gallery/posts/show.js'; +import * as ep___gallery_posts_unlike from './endpoints/gallery/posts/unlike.js'; +import * as ep___gallery_posts_update from './endpoints/gallery/posts/update.js'; +import * as ep___getOnlineUsersCount from './endpoints/get-online-users-count.js'; +import * as ep___hashtags_list from './endpoints/hashtags/list.js'; +import * as ep___hashtags_search from './endpoints/hashtags/search.js'; +import * as ep___hashtags_show from './endpoints/hashtags/show.js'; +import * as ep___hashtags_trend from './endpoints/hashtags/trend.js'; +import * as ep___hashtags_users from './endpoints/hashtags/users.js'; +import * as ep___i from './endpoints/i.js'; +import * as ep___i_2fa_done from './endpoints/i/2fa/done.js'; +import * as ep___i_2fa_keyDone from './endpoints/i/2fa/key-done.js'; +import * as ep___i_2fa_passwordLess from './endpoints/i/2fa/password-less.js'; +import * as ep___i_2fa_registerKey from './endpoints/i/2fa/register-key.js'; +import * as ep___i_2fa_register from './endpoints/i/2fa/register.js'; +import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js'; +import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js'; +import * as ep___i_apps from './endpoints/i/apps.js'; +import * as ep___i_authorizedApps from './endpoints/i/authorized-apps.js'; +import * as ep___i_changePassword from './endpoints/i/change-password.js'; +import * as ep___i_deleteAccount from './endpoints/i/delete-account.js'; +import * as ep___i_exportBlocking from './endpoints/i/export-blocking.js'; +import * as ep___i_exportFollowing from './endpoints/i/export-following.js'; +import * as ep___i_exportMute from './endpoints/i/export-mute.js'; +import * as ep___i_exportNotes from './endpoints/i/export-notes.js'; +import * as ep___i_exportUserLists from './endpoints/i/export-user-lists.js'; +import * as ep___i_favorites from './endpoints/i/favorites.js'; +import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js'; +import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js'; +import * as ep___i_getWordMutedNotesCount from './endpoints/i/get-word-muted-notes-count.js'; +import * as ep___i_importBlocking from './endpoints/i/import-blocking.js'; +import * as ep___i_importFollowing from './endpoints/i/import-following.js'; +import * as ep___i_importMuting from './endpoints/i/import-muting.js'; +import * as ep___i_importUserLists from './endpoints/i/import-user-lists.js'; +import * as ep___i_notifications from './endpoints/i/notifications.js'; +import * as ep___i_pageLikes from './endpoints/i/page-likes.js'; +import * as ep___i_pages from './endpoints/i/pages.js'; +import * as ep___i_pin from './endpoints/i/pin.js'; +import * as ep___i_readAllMessagingMessages from './endpoints/i/read-all-messaging-messages.js'; +import * as ep___i_readAllUnreadNotes from './endpoints/i/read-all-unread-notes.js'; +import * as ep___i_readAnnouncement from './endpoints/i/read-announcement.js'; +import * as ep___i_regenerateToken from './endpoints/i/regenerate-token.js'; +import * as ep___i_registry_getAll from './endpoints/i/registry/get-all.js'; +import * as ep___i_registry_getDetail from './endpoints/i/registry/get-detail.js'; +import * as ep___i_registry_get from './endpoints/i/registry/get.js'; +import * as ep___i_registry_keysWithType from './endpoints/i/registry/keys-with-type.js'; +import * as ep___i_registry_keys from './endpoints/i/registry/keys.js'; +import * as ep___i_registry_remove from './endpoints/i/registry/remove.js'; +import * as ep___i_registry_scopes from './endpoints/i/registry/scopes.js'; +import * as ep___i_registry_set from './endpoints/i/registry/set.js'; +import * as ep___i_revokeToken from './endpoints/i/revoke-token.js'; +import * as ep___i_signinHistory from './endpoints/i/signin-history.js'; +import * as ep___i_unpin from './endpoints/i/unpin.js'; +import * as ep___i_updateEmail from './endpoints/i/update-email.js'; +import * as ep___i_update from './endpoints/i/update.js'; +import * as ep___i_userGroupInvites from './endpoints/i/user-group-invites.js'; +import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js'; +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___messaging_history from './endpoints/messaging/history.js'; +import * as ep___messaging_messages from './endpoints/messaging/messages.js'; +import * as ep___messaging_messages_create from './endpoints/messaging/messages/create.js'; +import * as ep___messaging_messages_delete from './endpoints/messaging/messages/delete.js'; +import * as ep___messaging_messages_read from './endpoints/messaging/messages/read.js'; +import * as ep___meta from './endpoints/meta.js'; +import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js'; +import * as ep___mute_create from './endpoints/mute/create.js'; +import * as ep___mute_delete from './endpoints/mute/delete.js'; +import * as ep___mute_list from './endpoints/mute/list.js'; +import * as ep___my_apps from './endpoints/my/apps.js'; +import * as ep___notes from './endpoints/notes.js'; +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_delete from './endpoints/notes/delete.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_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'; +import * as ep___notes_reactions from './endpoints/notes/reactions.js'; +import * as ep___notes_reactions_create from './endpoints/notes/reactions/create.js'; +import * as ep___notes_reactions_delete from './endpoints/notes/reactions/delete.js'; +import * as ep___notes_renotes from './endpoints/notes/renotes.js'; +import * as ep___notes_replies from './endpoints/notes/replies.js'; +import * as ep___notes_searchByTag from './endpoints/notes/search-by-tag.js'; +import * as ep___notes_search from './endpoints/notes/search.js'; +import * as ep___notes_show from './endpoints/notes/show.js'; +import * as ep___notes_state from './endpoints/notes/state.js'; +import * as ep___notes_threadMuting_create from './endpoints/notes/thread-muting/create.js'; +import * as ep___notes_threadMuting_delete from './endpoints/notes/thread-muting/delete.js'; +import * as ep___notes_timeline from './endpoints/notes/timeline.js'; +import * as ep___notes_translate from './endpoints/notes/translate.js'; +import * as ep___notes_unrenote from './endpoints/notes/unrenote.js'; +import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js'; +import * as ep___notifications_create from './endpoints/notifications/create.js'; +import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js'; +import * as ep___notifications_read from './endpoints/notifications/read.js'; +import * as ep___pagePush from './endpoints/page-push.js'; +import * as ep___pages_create from './endpoints/pages/create.js'; +import * as ep___pages_delete from './endpoints/pages/delete.js'; +import * as ep___pages_featured from './endpoints/pages/featured.js'; +import * as ep___pages_like from './endpoints/pages/like.js'; +import * as ep___pages_show from './endpoints/pages/show.js'; +import * as ep___pages_unlike from './endpoints/pages/unlike.js'; +import * as ep___pages_update from './endpoints/pages/update.js'; +import * as ep___ping from './endpoints/ping.js'; +import * as ep___pinnedUsers from './endpoints/pinned-users.js'; +import * as ep___promo_read from './endpoints/promo/read.js'; +import * as ep___requestResetPassword from './endpoints/request-reset-password.js'; +import * as ep___resetDb from './endpoints/reset-db.js'; +import * as ep___resetPassword from './endpoints/reset-password.js'; +import * as ep___serverInfo from './endpoints/server-info.js'; +import * as ep___stats from './endpoints/stats.js'; +import * as ep___sw_register from './endpoints/sw/register.js'; +import * as ep___sw_unregister from './endpoints/sw/unregister.js'; +import * as ep___test from './endpoints/test.js'; +import * as ep___username_available from './endpoints/username/available.js'; +import * as ep___users from './endpoints/users.js'; +import * as ep___users_clips from './endpoints/users/clips.js'; +import * as ep___users_followers from './endpoints/users/followers.js'; +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_groups_create from './endpoints/users/groups/create.js'; +import * as ep___users_groups_delete from './endpoints/users/groups/delete.js'; +import * as ep___users_groups_invitations_accept from './endpoints/users/groups/invitations/accept.js'; +import * as ep___users_groups_invitations_reject from './endpoints/users/groups/invitations/reject.js'; +import * as ep___users_groups_invite from './endpoints/users/groups/invite.js'; +import * as ep___users_groups_joined from './endpoints/users/groups/joined.js'; +import * as ep___users_groups_leave from './endpoints/users/groups/leave.js'; +import * as ep___users_groups_owned from './endpoints/users/groups/owned.js'; +import * as ep___users_groups_pull from './endpoints/users/groups/pull.js'; +import * as ep___users_groups_show from './endpoints/users/groups/show.js'; +import * as ep___users_groups_transfer from './endpoints/users/groups/transfer.js'; +import * as ep___users_groups_update from './endpoints/users/groups/update.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_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'; +import * as ep___users_lists_update from './endpoints/users/lists/update.js'; +import * as ep___users_notes from './endpoints/users/notes.js'; +import * as ep___users_pages from './endpoints/users/pages.js'; +import * as ep___users_reactions from './endpoints/users/reactions.js'; +import * as ep___users_recommendation from './endpoints/users/recommendation.js'; +import * as ep___users_relation from './endpoints/users/relation.js'; +import * as ep___users_reportAbuse from './endpoints/users/report-abuse.js'; +import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by-username-and-host.js'; +import * as ep___users_search from './endpoints/users/search.js'; +import * as ep___users_show from './endpoints/users/show.js'; +import * as ep___users_stats from './endpoints/users/stats.js'; +import * as ep___fetchRss from './endpoints/fetch-rss.js'; +import * as ep___admin_driveCapOverride from './endpoints/admin/drive-capacity-override.js'; +import { GetterService } from './common/GetterService.js'; +import { ApiLoggerService } from './ApiLoggerService.js'; +import type { Provider } from '@nestjs/common'; + +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_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_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 }; +const $admin_ad_update: Provider = { provide: 'ep:admin/ad/update', useClass: ep___admin_ad_update.default }; +const $admin_announcements_create: Provider = { provide: 'ep:admin/announcements/create', useClass: ep___admin_announcements_create.default }; +const $admin_announcements_delete: Provider = { provide: 'ep:admin/announcements/delete', useClass: ep___admin_announcements_delete.default }; +const $admin_announcements_list: Provider = { provide: 'ep:admin/announcements/list', useClass: ep___admin_announcements_list.default }; +const $admin_announcements_update: Provider = { provide: 'ep:admin/announcements/update', useClass: ep___admin_announcements_update.default }; +const $admin_deleteAllFilesOfAUser: Provider = { provide: 'ep:admin/delete-all-files-of-a-user', useClass: ep___admin_deleteAllFilesOfAUser.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_add: Provider = { provide: 'ep:admin/emoji/add', useClass: ep___admin_emoji_add.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_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_update: Provider = { provide: 'ep:admin/emoji/update', useClass: ep___admin_emoji_update.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 }; +const $admin_federation_updateInstance: Provider = { provide: 'ep:admin/federation/update-instance', useClass: ep___admin_federation_updateInstance.default }; +const $admin_getIndexStats: Provider = { provide: 'ep:admin/get-index-stats', useClass: ep___admin_getIndexStats.default }; +const $admin_getTableStats: Provider = { provide: 'ep:admin/get-table-stats', useClass: ep___admin_getTableStats.default }; +const $admin_getUserIps: Provider = { provide: 'ep:admin/get-user-ips', useClass: ep___admin_getUserIps.default }; +const $admin_invite: Provider = { provide: 'ep:admin/invite', useClass: ep___admin_invite.default }; +const $admin_moderators_add: Provider = { provide: 'ep:admin/moderators/add', useClass: ep___admin_moderators_add.default }; +const $admin_moderators_remove: Provider = { provide: 'ep:admin/moderators/remove', useClass: ep___admin_moderators_remove.default }; +const $admin_promo_create: Provider = { provide: 'ep:admin/promo/create', useClass: ep___admin_promo_create.default }; +const $admin_queue_clear: Provider = { provide: 'ep:admin/queue/clear', useClass: ep___admin_queue_clear.default }; +const $admin_queue_deliverDelayed: Provider = { provide: 'ep:admin/queue/deliver-delayed', useClass: ep___admin_queue_deliverDelayed.default }; +const $admin_queue_inboxDelayed: Provider = { provide: 'ep:admin/queue/inbox-delayed', useClass: ep___admin_queue_inboxDelayed.default }; +const $admin_queue_stats: Provider = { provide: 'ep:admin/queue/stats', useClass: ep___admin_queue_stats.default }; +const $admin_relays_add: Provider = { provide: 'ep:admin/relays/add', useClass: ep___admin_relays_add.default }; +const $admin_relays_list: Provider = { provide: 'ep:admin/relays/list', useClass: ep___admin_relays_list.default }; +const $admin_relays_remove: Provider = { provide: 'ep:admin/relays/remove', useClass: ep___admin_relays_remove.default }; +const $admin_resetPassword: Provider = { provide: 'ep:admin/reset-password', useClass: ep___admin_resetPassword.default }; +const $admin_resolveAbuseUserReport: Provider = { provide: 'ep:admin/resolve-abuse-user-report', useClass: ep___admin_resolveAbuseUserReport.default }; +const $admin_sendEmail: Provider = { provide: 'ep:admin/send-email', useClass: ep___admin_sendEmail.default }; +const $admin_serverInfo: Provider = { provide: 'ep:admin/server-info', useClass: ep___admin_serverInfo.default }; +const $admin_showModerationLogs: Provider = { provide: 'ep:admin/show-moderation-logs', useClass: ep___admin_showModerationLogs.default }; +const $admin_showUser: Provider = { provide: 'ep:admin/show-user', useClass: ep___admin_showUser.default }; +const $admin_showUsers: Provider = { provide: 'ep:admin/show-users', useClass: ep___admin_showUsers.default }; +const $admin_silenceUser: Provider = { provide: 'ep:admin/silence-user', useClass: ep___admin_silenceUser.default }; +const $admin_suspendUser: Provider = { provide: 'ep:admin/suspend-user', useClass: ep___admin_suspendUser.default }; +const $admin_unsilenceUser: Provider = { provide: 'ep:admin/unsilence-user', useClass: ep___admin_unsilenceUser.default }; +const $admin_unsuspendUser: Provider = { provide: 'ep:admin/unsuspend-user', useClass: ep___admin_unsuspendUser.default }; +const $admin_updateMeta: Provider = { provide: 'ep:admin/update-meta', useClass: ep___admin_updateMeta.default }; +const $admin_deleteAccount: Provider = { provide: 'ep:admin/delete-account', useClass: ep___admin_deleteAccount.default }; +const $admin_updateUserNote: Provider = { provide: 'ep:admin/update-user-note', useClass: ep___admin_updateUserNote.default }; +const $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default }; +const $antennas_create: Provider = { provide: 'ep:antennas/create', useClass: ep___antennas_create.default }; +const $antennas_delete: Provider = { provide: 'ep:antennas/delete', useClass: ep___antennas_delete.default }; +const $antennas_list: Provider = { provide: 'ep:antennas/list', useClass: ep___antennas_list.default }; +const $antennas_notes: Provider = { provide: 'ep:antennas/notes', useClass: ep___antennas_notes.default }; +const $antennas_show: Provider = { provide: 'ep:antennas/show', useClass: ep___antennas_show.default }; +const $antennas_update: Provider = { provide: 'ep:antennas/update', useClass: ep___antennas_update.default }; +const $ap_get: Provider = { provide: 'ep:ap/get', useClass: ep___ap_get.default }; +const $ap_show: Provider = { provide: 'ep:ap/show', useClass: ep___ap_show.default }; +const $app_create: Provider = { provide: 'ep:app/create', useClass: ep___app_create.default }; +const $app_show: Provider = { provide: 'ep:app/show', useClass: ep___app_show.default }; +const $auth_accept: Provider = { provide: 'ep:auth/accept', useClass: ep___auth_accept.default }; +const $auth_session_generate: Provider = { provide: 'ep:auth/session/generate', useClass: ep___auth_session_generate.default }; +const $auth_session_show: Provider = { provide: 'ep:auth/session/show', useClass: ep___auth_session_show.default }; +const $auth_session_userkey: Provider = { provide: 'ep:auth/session/userkey', useClass: ep___auth_session_userkey.default }; +const $blocking_create: Provider = { provide: 'ep:blocking/create', useClass: ep___blocking_create.default }; +const $blocking_delete: Provider = { provide: 'ep:blocking/delete', useClass: ep___blocking_delete.default }; +const $blocking_list: Provider = { provide: 'ep:blocking/list', useClass: ep___blocking_list.default }; +const $channels_create: Provider = { provide: 'ep:channels/create', useClass: ep___channels_create.default }; +const $channels_featured: Provider = { provide: 'ep:channels/featured', useClass: ep___channels_featured.default }; +const $channels_follow: Provider = { provide: 'ep:channels/follow', useClass: ep___channels_follow.default }; +const $channels_followed: Provider = { provide: 'ep:channels/followed', useClass: ep___channels_followed.default }; +const $channels_owned: Provider = { provide: 'ep:channels/owned', useClass: ep___channels_owned.default }; +const $channels_show: Provider = { provide: 'ep:channels/show', useClass: ep___channels_show.default }; +const $channels_timeline: Provider = { provide: 'ep:channels/timeline', useClass: ep___channels_timeline.default }; +const $channels_unfollow: Provider = { provide: 'ep:channels/unfollow', useClass: ep___channels_unfollow.default }; +const $channels_update: Provider = { provide: 'ep:channels/update', useClass: ep___channels_update.default }; +const $charts_activeUsers: Provider = { provide: 'ep:charts/active-users', useClass: ep___charts_activeUsers.default }; +const $charts_apRequest: Provider = { provide: 'ep:charts/ap-request', useClass: ep___charts_apRequest.default }; +const $charts_drive: Provider = { provide: 'ep:charts/drive', useClass: ep___charts_drive.default }; +const $charts_federation: Provider = { provide: 'ep:charts/federation', useClass: ep___charts_federation.default }; +const $charts_hashtag: Provider = { provide: 'ep:charts/hashtag', useClass: ep___charts_hashtag.default }; +const $charts_instance: Provider = { provide: 'ep:charts/instance', useClass: ep___charts_instance.default }; +const $charts_notes: Provider = { provide: 'ep:charts/notes', useClass: ep___charts_notes.default }; +const $charts_user_drive: Provider = { provide: 'ep:charts/user/drive', useClass: ep___charts_user_drive.default }; +const $charts_user_following: Provider = { provide: 'ep:charts/user/following', useClass: ep___charts_user_following.default }; +const $charts_user_notes: Provider = { provide: 'ep:charts/user/notes', useClass: ep___charts_user_notes.default }; +const $charts_user_reactions: Provider = { provide: 'ep:charts/user/reactions', useClass: ep___charts_user_reactions.default }; +const $charts_users: Provider = { provide: 'ep:charts/users', useClass: ep___charts_users.default }; +const $clips_addNote: Provider = { provide: 'ep:clips/add-note', useClass: ep___clips_addNote.default }; +const $clips_removeNote: Provider = { provide: 'ep:clips/remove-note', useClass: ep___clips_removeNote.default }; +const $clips_create: Provider = { provide: 'ep:clips/create', useClass: ep___clips_create.default }; +const $clips_delete: Provider = { provide: 'ep:clips/delete', useClass: ep___clips_delete.default }; +const $clips_list: Provider = { provide: 'ep:clips/list', useClass: ep___clips_list.default }; +const $clips_notes: Provider = { provide: 'ep:clips/notes', useClass: ep___clips_notes.default }; +const $clips_show: Provider = { provide: 'ep:clips/show', useClass: ep___clips_show.default }; +const $clips_update: Provider = { provide: 'ep:clips/update', useClass: ep___clips_update.default }; +const $drive: Provider = { provide: 'ep:drive', useClass: ep___drive.default }; +const $drive_files: Provider = { provide: 'ep:drive/files', useClass: ep___drive_files.default }; +const $drive_files_attachedNotes: Provider = { provide: 'ep:drive/files/attached-notes', useClass: ep___drive_files_attachedNotes.default }; +const $drive_files_checkExistence: Provider = { provide: 'ep:drive/files/check-existence', useClass: ep___drive_files_checkExistence.default }; +const $drive_files_create: Provider = { provide: 'ep:drive/files/create', useClass: ep___drive_files_create.default }; +const $drive_files_delete: Provider = { provide: 'ep:drive/files/delete', useClass: ep___drive_files_delete.default }; +const $drive_files_findByHash: Provider = { provide: 'ep:drive/files/find-by-hash', useClass: ep___drive_files_findByHash.default }; +const $drive_files_find: Provider = { provide: 'ep:drive/files/find', useClass: ep___drive_files_find.default }; +const $drive_files_show: Provider = { provide: 'ep:drive/files/show', useClass: ep___drive_files_show.default }; +const $drive_files_update: Provider = { provide: 'ep:drive/files/update', useClass: ep___drive_files_update.default }; +const $drive_files_uploadFromUrl: Provider = { provide: 'ep:drive/files/upload-from-url', useClass: ep___drive_files_uploadFromUrl.default }; +const $drive_folders: Provider = { provide: 'ep:drive/folders', useClass: ep___drive_folders.default }; +const $drive_folders_create: Provider = { provide: 'ep:drive/folders/create', useClass: ep___drive_folders_create.default }; +const $drive_folders_delete: Provider = { provide: 'ep:drive/folders/delete', useClass: ep___drive_folders_delete.default }; +const $drive_folders_find: Provider = { provide: 'ep:drive/folders/find', useClass: ep___drive_folders_find.default }; +const $drive_folders_show: Provider = { provide: 'ep:drive/folders/show', useClass: ep___drive_folders_show.default }; +const $drive_folders_update: Provider = { provide: 'ep:drive/folders/update', useClass: ep___drive_folders_update.default }; +const $drive_stream: Provider = { provide: 'ep:drive/stream', useClass: ep___drive_stream.default }; +const $emailAddress_available: Provider = { provide: 'ep:email-address/available', useClass: ep___emailAddress_available.default }; +const $endpoint: Provider = { provide: 'ep:endpoint', useClass: ep___endpoint.default }; +const $endpoints: Provider = { provide: 'ep:endpoints', useClass: ep___endpoints.default }; +const $exportCustomEmojis: Provider = { provide: 'ep:export-custom-emojis', useClass: ep___exportCustomEmojis.default }; +const $federation_followers: Provider = { provide: 'ep:federation/followers', useClass: ep___federation_followers.default }; +const $federation_following: Provider = { provide: 'ep:federation/following', useClass: ep___federation_following.default }; +const $federation_instances: Provider = { provide: 'ep:federation/instances', useClass: ep___federation_instances.default }; +const $federation_showInstance: Provider = { provide: 'ep:federation/show-instance', useClass: ep___federation_showInstance.default }; +const $federation_updateRemoteUser: Provider = { provide: 'ep:federation/update-remote-user', useClass: ep___federation_updateRemoteUser.default }; +const $federation_users: Provider = { provide: 'ep:federation/users', useClass: ep___federation_users.default }; +const $federation_stats: Provider = { provide: 'ep:federation/stats', useClass: ep___federation_stats.default }; +const $following_create: Provider = { provide: 'ep:following/create', useClass: ep___following_create.default }; +const $following_delete: Provider = { provide: 'ep:following/delete', useClass: ep___following_delete.default }; +const $following_invalidate: Provider = { provide: 'ep:following/invalidate', useClass: ep___following_invalidate.default }; +const $following_requests_accept: Provider = { provide: 'ep:following/requests/accept', useClass: ep___following_requests_accept.default }; +const $following_requests_cancel: Provider = { provide: 'ep:following/requests/cancel', useClass: ep___following_requests_cancel.default }; +const $following_requests_list: Provider = { provide: 'ep:following/requests/list', useClass: ep___following_requests_list.default }; +const $following_requests_reject: Provider = { provide: 'ep:following/requests/reject', useClass: ep___following_requests_reject.default }; +const $gallery_featured: Provider = { provide: 'ep:gallery/featured', useClass: ep___gallery_featured.default }; +const $gallery_popular: Provider = { provide: 'ep:gallery/popular', useClass: ep___gallery_popular.default }; +const $gallery_posts: Provider = { provide: 'ep:gallery/posts', useClass: ep___gallery_posts.default }; +const $gallery_posts_create: Provider = { provide: 'ep:gallery/posts/create', useClass: ep___gallery_posts_create.default }; +const $gallery_posts_delete: Provider = { provide: 'ep:gallery/posts/delete', useClass: ep___gallery_posts_delete.default }; +const $gallery_posts_like: Provider = { provide: 'ep:gallery/posts/like', useClass: ep___gallery_posts_like.default }; +const $gallery_posts_show: Provider = { provide: 'ep:gallery/posts/show', useClass: ep___gallery_posts_show.default }; +const $gallery_posts_unlike: Provider = { provide: 'ep:gallery/posts/unlike', useClass: ep___gallery_posts_unlike.default }; +const $gallery_posts_update: Provider = { provide: 'ep:gallery/posts/update', useClass: ep___gallery_posts_update.default }; +const $getOnlineUsersCount: Provider = { provide: 'ep:get-online-users-count', useClass: ep___getOnlineUsersCount.default }; +const $hashtags_list: Provider = { provide: 'ep:hashtags/list', useClass: ep___hashtags_list.default }; +const $hashtags_search: Provider = { provide: 'ep:hashtags/search', useClass: ep___hashtags_search.default }; +const $hashtags_show: Provider = { provide: 'ep:hashtags/show', useClass: ep___hashtags_show.default }; +const $hashtags_trend: Provider = { provide: 'ep:hashtags/trend', useClass: ep___hashtags_trend.default }; +const $hashtags_users: Provider = { provide: 'ep:hashtags/users', useClass: ep___hashtags_users.default }; +const $i: Provider = { provide: 'ep:i', useClass: ep___i.default }; +const $i_2fa_done: Provider = { provide: 'ep:i/2fa/done', useClass: ep___i_2fa_done.default }; +const $i_2fa_keyDone: Provider = { provide: 'ep:i/2fa/key-done', useClass: ep___i_2fa_keyDone.default }; +const $i_2fa_passwordLess: Provider = { provide: 'ep:i/2fa/password-less', useClass: ep___i_2fa_passwordLess.default }; +const $i_2fa_registerKey: Provider = { provide: 'ep:i/2fa/register-key', useClass: ep___i_2fa_registerKey.default }; +const $i_2fa_register: Provider = { provide: 'ep:i/2fa/register', useClass: ep___i_2fa_register.default }; +const $i_2fa_removeKey: Provider = { provide: 'ep:i/2fa/remove-key', useClass: ep___i_2fa_removeKey.default }; +const $i_2fa_unregister: Provider = { provide: 'ep:i/2fa/unregister', useClass: ep___i_2fa_unregister.default }; +const $i_apps: Provider = { provide: 'ep:i/apps', useClass: ep___i_apps.default }; +const $i_authorizedApps: Provider = { provide: 'ep:i/authorized-apps', useClass: ep___i_authorizedApps.default }; +const $i_changePassword: Provider = { provide: 'ep:i/change-password', useClass: ep___i_changePassword.default }; +const $i_deleteAccount: Provider = { provide: 'ep:i/delete-account', useClass: ep___i_deleteAccount.default }; +const $i_exportBlocking: Provider = { provide: 'ep:i/export-blocking', useClass: ep___i_exportBlocking.default }; +const $i_exportFollowing: Provider = { provide: 'ep:i/export-following', useClass: ep___i_exportFollowing.default }; +const $i_exportMute: Provider = { provide: 'ep:i/export-mute', useClass: ep___i_exportMute.default }; +const $i_exportNotes: Provider = { provide: 'ep:i/export-notes', useClass: ep___i_exportNotes.default }; +const $i_exportUserLists: Provider = { provide: 'ep:i/export-user-lists', useClass: ep___i_exportUserLists.default }; +const $i_favorites: Provider = { provide: 'ep:i/favorites', useClass: ep___i_favorites.default }; +const $i_gallery_likes: Provider = { provide: 'ep:i/gallery/likes', useClass: ep___i_gallery_likes.default }; +const $i_gallery_posts: Provider = { provide: 'ep:i/gallery/posts', useClass: ep___i_gallery_posts.default }; +const $i_getWordMutedNotesCount: Provider = { provide: 'ep:i/get-word-muted-notes-count', useClass: ep___i_getWordMutedNotesCount.default }; +const $i_importBlocking: Provider = { provide: 'ep:i/import-blocking', useClass: ep___i_importBlocking.default }; +const $i_importFollowing: Provider = { provide: 'ep:i/import-following', useClass: ep___i_importFollowing.default }; +const $i_importMuting: Provider = { provide: 'ep:i/import-muting', useClass: ep___i_importMuting.default }; +const $i_importUserLists: Provider = { provide: 'ep:i/import-user-lists', useClass: ep___i_importUserLists.default }; +const $i_notifications: Provider = { provide: 'ep:i/notifications', useClass: ep___i_notifications.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 }; +const $i_pin: Provider = { provide: 'ep:i/pin', useClass: ep___i_pin.default }; +const $i_readAllMessagingMessages: Provider = { provide: 'ep:i/read-all-messaging-messages', useClass: ep___i_readAllMessagingMessages.default }; +const $i_readAllUnreadNotes: Provider = { provide: 'ep:i/read-all-unread-notes', useClass: ep___i_readAllUnreadNotes.default }; +const $i_readAnnouncement: Provider = { provide: 'ep:i/read-announcement', useClass: ep___i_readAnnouncement.default }; +const $i_regenerateToken: Provider = { provide: 'ep:i/regenerate-token', useClass: ep___i_regenerateToken.default }; +const $i_registry_getAll: Provider = { provide: 'ep:i/registry/get-all', useClass: ep___i_registry_getAll.default }; +const $i_registry_getDetail: Provider = { provide: 'ep:i/registry/get-detail', useClass: ep___i_registry_getDetail.default }; +const $i_registry_get: Provider = { provide: 'ep:i/registry/get', useClass: ep___i_registry_get.default }; +const $i_registry_keysWithType: Provider = { provide: 'ep:i/registry/keys-with-type', useClass: ep___i_registry_keysWithType.default }; +const $i_registry_keys: Provider = { provide: 'ep:i/registry/keys', useClass: ep___i_registry_keys.default }; +const $i_registry_remove: Provider = { provide: 'ep:i/registry/remove', useClass: ep___i_registry_remove.default }; +const $i_registry_scopes: Provider = { provide: 'ep:i/registry/scopes', useClass: ep___i_registry_scopes.default }; +const $i_registry_set: Provider = { provide: 'ep:i/registry/set', useClass: ep___i_registry_set.default }; +const $i_revokeToken: Provider = { provide: 'ep:i/revoke-token', useClass: ep___i_revokeToken.default }; +const $i_signinHistory: Provider = { provide: 'ep:i/signin-history', useClass: ep___i_signinHistory.default }; +const $i_unpin: Provider = { provide: 'ep:i/unpin', useClass: ep___i_unpin.default }; +const $i_updateEmail: Provider = { provide: 'ep:i/update-email', useClass: ep___i_updateEmail.default }; +const $i_update: Provider = { provide: 'ep:i/update', useClass: ep___i_update.default }; +const $i_userGroupInvites: Provider = { provide: 'ep:i/user-group-invites', useClass: ep___i_userGroupInvites.default }; +const $i_webhooks_create: Provider = { provide: 'ep:i/webhooks/create', useClass: ep___i_webhooks_create.default }; +const $i_webhooks_list: Provider = { provide: 'ep:i/webhooks/list', useClass: ep___i_webhooks_list.default }; +const $i_webhooks_show: Provider = { provide: 'ep:i/webhooks/show', useClass: ep___i_webhooks_show.default }; +const $i_webhooks_update: Provider = { provide: 'ep:i/webhooks/update', useClass: ep___i_webhooks_update.default }; +const $i_webhooks_delete: Provider = { provide: 'ep:i/webhooks/delete', useClass: ep___i_webhooks_delete.default }; +const $messaging_history: Provider = { provide: 'ep:messaging/history', useClass: ep___messaging_history.default }; +const $messaging_messages: Provider = { provide: 'ep:messaging/messages', useClass: ep___messaging_messages.default }; +const $messaging_messages_create: Provider = { provide: 'ep:messaging/messages/create', useClass: ep___messaging_messages_create.default }; +const $messaging_messages_delete: Provider = { provide: 'ep:messaging/messages/delete', useClass: ep___messaging_messages_delete.default }; +const $messaging_messages_read: Provider = { provide: 'ep:messaging/messages/read', useClass: ep___messaging_messages_read.default }; +const $meta: Provider = { provide: 'ep:meta', useClass: ep___meta.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 }; +const $mute_delete: Provider = { provide: 'ep:mute/delete', useClass: ep___mute_delete.default }; +const $mute_list: Provider = { provide: 'ep:mute/list', useClass: ep___mute_list.default }; +const $my_apps: Provider = { provide: 'ep:my/apps', useClass: ep___my_apps.default }; +const $notes: Provider = { provide: 'ep:notes', useClass: ep___notes.default }; +const $notes_children: Provider = { provide: 'ep:notes/children', useClass: ep___notes_children.default }; +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_delete: Provider = { provide: 'ep:notes/delete', useClass: ep___notes_delete.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_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 }; +const $notes_reactions: Provider = { provide: 'ep:notes/reactions', useClass: ep___notes_reactions.default }; +const $notes_reactions_create: Provider = { provide: 'ep:notes/reactions/create', useClass: ep___notes_reactions_create.default }; +const $notes_reactions_delete: Provider = { provide: 'ep:notes/reactions/delete', useClass: ep___notes_reactions_delete.default }; +const $notes_renotes: Provider = { provide: 'ep:notes/renotes', useClass: ep___notes_renotes.default }; +const $notes_replies: Provider = { provide: 'ep:notes/replies', useClass: ep___notes_replies.default }; +const $notes_searchByTag: Provider = { provide: 'ep:notes/search-by-tag', useClass: ep___notes_searchByTag.default }; +const $notes_search: Provider = { provide: 'ep:notes/search', useClass: ep___notes_search.default }; +const $notes_show: Provider = { provide: 'ep:notes/show', useClass: ep___notes_show.default }; +const $notes_state: Provider = { provide: 'ep:notes/state', useClass: ep___notes_state.default }; +const $notes_threadMuting_create: Provider = { provide: 'ep:notes/thread-muting/create', useClass: ep___notes_threadMuting_create.default }; +const $notes_threadMuting_delete: Provider = { provide: 'ep:notes/thread-muting/delete', useClass: ep___notes_threadMuting_delete.default }; +const $notes_timeline: Provider = { provide: 'ep:notes/timeline', useClass: ep___notes_timeline.default }; +const $notes_translate: Provider = { provide: 'ep:notes/translate', useClass: ep___notes_translate.default }; +const $notes_unrenote: Provider = { provide: 'ep:notes/unrenote', useClass: ep___notes_unrenote.default }; +const $notes_userListTimeline: Provider = { provide: 'ep:notes/user-list-timeline', useClass: ep___notes_userListTimeline.default }; +const $notifications_create: Provider = { provide: 'ep:notifications/create', useClass: ep___notifications_create.default }; +const $notifications_markAllAsRead: Provider = { provide: 'ep:notifications/mark-all-as-read', useClass: ep___notifications_markAllAsRead.default }; +const $notifications_read: Provider = { provide: 'ep:notifications/read', useClass: ep___notifications_read.default }; +const $pagePush: Provider = { provide: 'ep:page-push', useClass: ep___pagePush.default }; +const $pages_create: Provider = { provide: 'ep:pages/create', useClass: ep___pages_create.default }; +const $pages_delete: Provider = { provide: 'ep:pages/delete', useClass: ep___pages_delete.default }; +const $pages_featured: Provider = { provide: 'ep:pages/featured', useClass: ep___pages_featured.default }; +const $pages_like: Provider = { provide: 'ep:pages/like', useClass: ep___pages_like.default }; +const $pages_show: Provider = { provide: 'ep:pages/show', useClass: ep___pages_show.default }; +const $pages_unlike: Provider = { provide: 'ep:pages/unlike', useClass: ep___pages_unlike.default }; +const $pages_update: Provider = { provide: 'ep:pages/update', useClass: ep___pages_update.default }; +const $ping: Provider = { provide: 'ep:ping', useClass: ep___ping.default }; +const $pinnedUsers: Provider = { provide: 'ep:pinned-users', useClass: ep___pinnedUsers.default }; +const $promo_read: Provider = { provide: 'ep:promo/read', useClass: ep___promo_read.default }; +const $requestResetPassword: Provider = { provide: 'ep:request-reset-password', useClass: ep___requestResetPassword.default }; +const $resetDb: Provider = { provide: 'ep:reset-db', useClass: ep___resetDb.default }; +const $resetPassword: Provider = { provide: 'ep:reset-password', useClass: ep___resetPassword.default }; +const $serverInfo: Provider = { provide: 'ep:server-info', useClass: ep___serverInfo.default }; +const $stats: Provider = { provide: 'ep:stats', useClass: ep___stats.default }; +const $sw_register: Provider = { provide: 'ep:sw/register', useClass: ep___sw_register.default }; +const $sw_unregister: Provider = { provide: 'ep:sw/unregister', useClass: ep___sw_unregister.default }; +const $test: Provider = { provide: 'ep:test', useClass: ep___test.default }; +const $username_available: Provider = { provide: 'ep:username/available', useClass: ep___username_available.default }; +const $users: Provider = { provide: 'ep:users', useClass: ep___users.default }; +const $users_clips: Provider = { provide: 'ep:users/clips', useClass: ep___users_clips.default }; +const $users_followers: Provider = { provide: 'ep:users/followers', useClass: ep___users_followers.default }; +const $users_following: Provider = { provide: 'ep:users/following', useClass: ep___users_following.default }; +const $users_gallery_posts: Provider = { provide: 'ep:users/gallery/posts', useClass: ep___users_gallery_posts.default }; +const $users_getFrequentlyRepliedUsers: Provider = { provide: 'ep:users/get-frequently-replied-users', useClass: ep___users_getFrequentlyRepliedUsers.default }; +const $users_groups_create: Provider = { provide: 'ep:users/groups/create', useClass: ep___users_groups_create.default }; +const $users_groups_delete: Provider = { provide: 'ep:users/groups/delete', useClass: ep___users_groups_delete.default }; +const $users_groups_invitations_accept: Provider = { provide: 'ep:users/groups/invitations/accept', useClass: ep___users_groups_invitations_accept.default }; +const $users_groups_invitations_reject: Provider = { provide: 'ep:users/groups/invitations/reject', useClass: ep___users_groups_invitations_reject.default }; +const $users_groups_invite: Provider = { provide: 'ep:users/groups/invite', useClass: ep___users_groups_invite.default }; +const $users_groups_joined: Provider = { provide: 'ep:users/groups/joined', useClass: ep___users_groups_joined.default }; +const $users_groups_leave: Provider = { provide: 'ep:users/groups/leave', useClass: ep___users_groups_leave.default }; +const $users_groups_owned: Provider = { provide: 'ep:users/groups/owned', useClass: ep___users_groups_owned.default }; +const $users_groups_pull: Provider = { provide: 'ep:users/groups/pull', useClass: ep___users_groups_pull.default }; +const $users_groups_show: Provider = { provide: 'ep:users/groups/show', useClass: ep___users_groups_show.default }; +const $users_groups_transfer: Provider = { provide: 'ep:users/groups/transfer', useClass: ep___users_groups_transfer.default }; +const $users_groups_update: Provider = { provide: 'ep:users/groups/update', useClass: ep___users_groups_update.default }; +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_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 }; +const $users_lists_update: Provider = { provide: 'ep:users/lists/update', useClass: ep___users_lists_update.default }; +const $users_notes: Provider = { provide: 'ep:users/notes', useClass: ep___users_notes.default }; +const $users_pages: Provider = { provide: 'ep:users/pages', useClass: ep___users_pages.default }; +const $users_reactions: Provider = { provide: 'ep:users/reactions', useClass: ep___users_reactions.default }; +const $users_recommendation: Provider = { provide: 'ep:users/recommendation', useClass: ep___users_recommendation.default }; +const $users_relation: Provider = { provide: 'ep:users/relation', useClass: ep___users_relation.default }; +const $users_reportAbuse: Provider = { provide: 'ep:users/report-abuse', useClass: ep___users_reportAbuse.default }; +const $users_searchByUsernameAndHost: Provider = { provide: 'ep:users/search-by-username-and-host', useClass: ep___users_searchByUsernameAndHost.default }; +const $users_search: Provider = { provide: 'ep:users/search', useClass: ep___users_search.default }; +const $users_show: Provider = { provide: 'ep:users/show', useClass: ep___users_show.default }; +const $users_stats: Provider = { provide: 'ep:users/stats', useClass: ep___users_stats.default }; +const $admin_driveCapOverride: Provider = { provide: 'ep:admin/drive-capacity-override', useClass: ep___admin_driveCapOverride.default }; +const $fetchRss: Provider = { provide: 'ep:fetch-rss', useClass: ep___fetchRss.default }; + +@Module({ + imports: [ + CoreModule, + ], + providers: [ + GetterService, + ApiLoggerService, + $admin_meta, + $admin_abuseUserReports, + $admin_accounts_create, + $admin_accounts_delete, + $admin_ad_create, + $admin_ad_delete, + $admin_ad_list, + $admin_ad_update, + $admin_announcements_create, + $admin_announcements_delete, + $admin_announcements_list, + $admin_announcements_update, + $admin_deleteAllFilesOfAUser, + $admin_drive_cleanRemoteFiles, + $admin_drive_cleanup, + $admin_drive_files, + $admin_drive_showFile, + $admin_emoji_addAliasesBulk, + $admin_emoji_add, + $admin_emoji_copy, + $admin_emoji_deleteBulk, + $admin_emoji_delete, + $admin_emoji_importZip, + $admin_emoji_listRemote, + $admin_emoji_list, + $admin_emoji_removeAliasesBulk, + $admin_emoji_setAliasesBulk, + $admin_emoji_setCategoryBulk, + $admin_emoji_update, + $admin_federation_deleteAllFiles, + $admin_federation_refreshRemoteInstanceMetadata, + $admin_federation_removeAllFollowing, + $admin_federation_updateInstance, + $admin_getIndexStats, + $admin_getTableStats, + $admin_getUserIps, + $admin_invite, + $admin_moderators_add, + $admin_moderators_remove, + $admin_promo_create, + $admin_queue_clear, + $admin_queue_deliverDelayed, + $admin_queue_inboxDelayed, + $admin_queue_stats, + $admin_relays_add, + $admin_relays_list, + $admin_relays_remove, + $admin_resetPassword, + $admin_resolveAbuseUserReport, + $admin_sendEmail, + $admin_serverInfo, + $admin_showModerationLogs, + $admin_showUser, + $admin_showUsers, + $admin_silenceUser, + $admin_suspendUser, + $admin_unsilenceUser, + $admin_unsuspendUser, + $admin_updateMeta, + $admin_deleteAccount, + $admin_updateUserNote, + $announcements, + $antennas_create, + $antennas_delete, + $antennas_list, + $antennas_notes, + $antennas_show, + $antennas_update, + $ap_get, + $ap_show, + $app_create, + $app_show, + $auth_accept, + $auth_session_generate, + $auth_session_show, + $auth_session_userkey, + $blocking_create, + $blocking_delete, + $blocking_list, + $channels_create, + $channels_featured, + $channels_follow, + $channels_followed, + $channels_owned, + $channels_show, + $channels_timeline, + $channels_unfollow, + $channels_update, + $charts_activeUsers, + $charts_apRequest, + $charts_drive, + $charts_federation, + $charts_hashtag, + $charts_instance, + $charts_notes, + $charts_user_drive, + $charts_user_following, + $charts_user_notes, + $charts_user_reactions, + $charts_users, + $clips_addNote, + $clips_removeNote, + $clips_create, + $clips_delete, + $clips_list, + $clips_notes, + $clips_show, + $clips_update, + $drive, + $drive_files, + $drive_files_attachedNotes, + $drive_files_checkExistence, + $drive_files_create, + $drive_files_delete, + $drive_files_findByHash, + $drive_files_find, + $drive_files_show, + $drive_files_update, + $drive_files_uploadFromUrl, + $drive_folders, + $drive_folders_create, + $drive_folders_delete, + $drive_folders_find, + $drive_folders_show, + $drive_folders_update, + $drive_stream, + $emailAddress_available, + $endpoint, + $endpoints, + $exportCustomEmojis, + $federation_followers, + $federation_following, + $federation_instances, + $federation_showInstance, + $federation_updateRemoteUser, + $federation_users, + $federation_stats, + $following_create, + $following_delete, + $following_invalidate, + $following_requests_accept, + $following_requests_cancel, + $following_requests_list, + $following_requests_reject, + $gallery_featured, + $gallery_popular, + $gallery_posts, + $gallery_posts_create, + $gallery_posts_delete, + $gallery_posts_like, + $gallery_posts_show, + $gallery_posts_unlike, + $gallery_posts_update, + $getOnlineUsersCount, + $hashtags_list, + $hashtags_search, + $hashtags_show, + $hashtags_trend, + $hashtags_users, + $i, + $i_2fa_done, + $i_2fa_keyDone, + $i_2fa_passwordLess, + $i_2fa_registerKey, + $i_2fa_register, + $i_2fa_removeKey, + $i_2fa_unregister, + $i_apps, + $i_authorizedApps, + $i_changePassword, + $i_deleteAccount, + $i_exportBlocking, + $i_exportFollowing, + $i_exportMute, + $i_exportNotes, + $i_exportUserLists, + $i_favorites, + $i_gallery_likes, + $i_gallery_posts, + $i_getWordMutedNotesCount, + $i_importBlocking, + $i_importFollowing, + $i_importMuting, + $i_importUserLists, + $i_notifications, + $i_pageLikes, + $i_pages, + $i_pin, + $i_readAllMessagingMessages, + $i_readAllUnreadNotes, + $i_readAnnouncement, + $i_regenerateToken, + $i_registry_getAll, + $i_registry_getDetail, + $i_registry_get, + $i_registry_keysWithType, + $i_registry_keys, + $i_registry_remove, + $i_registry_scopes, + $i_registry_set, + $i_revokeToken, + $i_signinHistory, + $i_unpin, + $i_updateEmail, + $i_update, + $i_userGroupInvites, + $i_webhooks_create, + $i_webhooks_list, + $i_webhooks_show, + $i_webhooks_update, + $i_webhooks_delete, + $messaging_history, + $messaging_messages, + $messaging_messages_create, + $messaging_messages_delete, + $messaging_messages_read, + $meta, + $miauth_genToken, + $mute_create, + $mute_delete, + $mute_list, + $my_apps, + $notes, + $notes_children, + $notes_clips, + $notes_conversation, + $notes_create, + $notes_delete, + $notes_favorites_create, + $notes_favorites_delete, + $notes_featured, + $notes_globalTimeline, + $notes_hybridTimeline, + $notes_localTimeline, + $notes_mentions, + $notes_polls_recommendation, + $notes_polls_vote, + $notes_reactions, + $notes_reactions_create, + $notes_reactions_delete, + $notes_renotes, + $notes_replies, + $notes_searchByTag, + $notes_search, + $notes_show, + $notes_state, + $notes_threadMuting_create, + $notes_threadMuting_delete, + $notes_timeline, + $notes_translate, + $notes_unrenote, + $notes_userListTimeline, + $notifications_create, + $notifications_markAllAsRead, + $notifications_read, + $pagePush, + $pages_create, + $pages_delete, + $pages_featured, + $pages_like, + $pages_show, + $pages_unlike, + $pages_update, + $ping, + $pinnedUsers, + $promo_read, + $requestResetPassword, + $resetDb, + $resetPassword, + $serverInfo, + $stats, + $sw_register, + $sw_unregister, + $test, + $username_available, + $users, + $users_clips, + $users_followers, + $users_following, + $users_gallery_posts, + $users_getFrequentlyRepliedUsers, + $users_groups_create, + $users_groups_delete, + $users_groups_invitations_accept, + $users_groups_invitations_reject, + $users_groups_invite, + $users_groups_joined, + $users_groups_leave, + $users_groups_owned, + $users_groups_pull, + $users_groups_show, + $users_groups_transfer, + $users_groups_update, + $users_lists_create, + $users_lists_delete, + $users_lists_list, + $users_lists_pull, + $users_lists_push, + $users_lists_show, + $users_lists_update, + $users_notes, + $users_pages, + $users_reactions, + $users_recommendation, + $users_relation, + $users_reportAbuse, + $users_searchByUsernameAndHost, + $users_search, + $users_show, + $users_stats, + $admin_driveCapOverride, + $fetchRss, + ], + exports: [ + $admin_meta, + $admin_abuseUserReports, + $admin_accounts_create, + $admin_accounts_delete, + $admin_ad_create, + $admin_ad_delete, + $admin_ad_list, + $admin_ad_update, + $admin_announcements_create, + $admin_announcements_delete, + $admin_announcements_list, + $admin_announcements_update, + $admin_deleteAllFilesOfAUser, + $admin_drive_cleanRemoteFiles, + $admin_drive_cleanup, + $admin_drive_files, + $admin_drive_showFile, + $admin_emoji_addAliasesBulk, + $admin_emoji_add, + $admin_emoji_copy, + $admin_emoji_deleteBulk, + $admin_emoji_delete, + $admin_emoji_importZip, + $admin_emoji_listRemote, + $admin_emoji_list, + $admin_emoji_removeAliasesBulk, + $admin_emoji_setAliasesBulk, + $admin_emoji_setCategoryBulk, + $admin_emoji_update, + $admin_federation_deleteAllFiles, + $admin_federation_refreshRemoteInstanceMetadata, + $admin_federation_removeAllFollowing, + $admin_federation_updateInstance, + $admin_getIndexStats, + $admin_getTableStats, + $admin_getUserIps, + $admin_invite, + $admin_moderators_add, + $admin_moderators_remove, + $admin_promo_create, + $admin_queue_clear, + $admin_queue_deliverDelayed, + $admin_queue_inboxDelayed, + $admin_queue_stats, + $admin_relays_add, + $admin_relays_list, + $admin_relays_remove, + $admin_resetPassword, + $admin_resolveAbuseUserReport, + $admin_sendEmail, + $admin_serverInfo, + $admin_showModerationLogs, + $admin_showUser, + $admin_showUsers, + $admin_silenceUser, + $admin_suspendUser, + $admin_unsilenceUser, + $admin_unsuspendUser, + $admin_updateMeta, + $admin_deleteAccount, + $admin_updateUserNote, + $announcements, + $antennas_create, + $antennas_delete, + $antennas_list, + $antennas_notes, + $antennas_show, + $antennas_update, + $ap_get, + $ap_show, + $app_create, + $app_show, + $auth_accept, + $auth_session_generate, + $auth_session_show, + $auth_session_userkey, + $blocking_create, + $blocking_delete, + $blocking_list, + $channels_create, + $channels_featured, + $channels_follow, + $channels_followed, + $channels_owned, + $channels_show, + $channels_timeline, + $channels_unfollow, + $channels_update, + $charts_activeUsers, + $charts_apRequest, + $charts_drive, + $charts_federation, + $charts_hashtag, + $charts_instance, + $charts_notes, + $charts_user_drive, + $charts_user_following, + $charts_user_notes, + $charts_user_reactions, + $charts_users, + $clips_addNote, + $clips_removeNote, + $clips_create, + $clips_delete, + $clips_list, + $clips_notes, + $clips_show, + $clips_update, + $drive, + $drive_files, + $drive_files_attachedNotes, + $drive_files_checkExistence, + $drive_files_create, + $drive_files_delete, + $drive_files_findByHash, + $drive_files_find, + $drive_files_show, + $drive_files_update, + $drive_files_uploadFromUrl, + $drive_folders, + $drive_folders_create, + $drive_folders_delete, + $drive_folders_find, + $drive_folders_show, + $drive_folders_update, + $drive_stream, + $emailAddress_available, + $endpoint, + $endpoints, + $exportCustomEmojis, + $federation_followers, + $federation_following, + $federation_instances, + $federation_showInstance, + $federation_updateRemoteUser, + $federation_users, + $federation_stats, + $following_create, + $following_delete, + $following_invalidate, + $following_requests_accept, + $following_requests_cancel, + $following_requests_list, + $following_requests_reject, + $gallery_featured, + $gallery_popular, + $gallery_posts, + $gallery_posts_create, + $gallery_posts_delete, + $gallery_posts_like, + $gallery_posts_show, + $gallery_posts_unlike, + $gallery_posts_update, + $getOnlineUsersCount, + $hashtags_list, + $hashtags_search, + $hashtags_show, + $hashtags_trend, + $hashtags_users, + $i, + $i_2fa_done, + $i_2fa_keyDone, + $i_2fa_passwordLess, + $i_2fa_registerKey, + $i_2fa_register, + $i_2fa_removeKey, + $i_2fa_unregister, + $i_apps, + $i_authorizedApps, + $i_changePassword, + $i_deleteAccount, + $i_exportBlocking, + $i_exportFollowing, + $i_exportMute, + $i_exportNotes, + $i_exportUserLists, + $i_favorites, + $i_gallery_likes, + $i_gallery_posts, + $i_getWordMutedNotesCount, + $i_importBlocking, + $i_importFollowing, + $i_importMuting, + $i_importUserLists, + $i_notifications, + $i_pageLikes, + $i_pages, + $i_pin, + $i_readAllMessagingMessages, + $i_readAllUnreadNotes, + $i_readAnnouncement, + $i_regenerateToken, + $i_registry_getAll, + $i_registry_getDetail, + $i_registry_get, + $i_registry_keysWithType, + $i_registry_keys, + $i_registry_remove, + $i_registry_scopes, + $i_registry_set, + $i_revokeToken, + $i_signinHistory, + $i_unpin, + $i_updateEmail, + $i_update, + $i_userGroupInvites, + $i_webhooks_create, + $i_webhooks_list, + $i_webhooks_show, + $i_webhooks_update, + $i_webhooks_delete, + $messaging_history, + $messaging_messages, + $messaging_messages_create, + $messaging_messages_delete, + $messaging_messages_read, + $meta, + $miauth_genToken, + $mute_create, + $mute_delete, + $mute_list, + $my_apps, + $notes, + $notes_children, + $notes_clips, + $notes_conversation, + $notes_create, + $notes_delete, + $notes_favorites_create, + $notes_favorites_delete, + $notes_featured, + $notes_globalTimeline, + $notes_hybridTimeline, + $notes_localTimeline, + $notes_mentions, + $notes_polls_recommendation, + $notes_polls_vote, + $notes_reactions, + $notes_reactions_create, + $notes_reactions_delete, + $notes_renotes, + $notes_replies, + $notes_searchByTag, + $notes_search, + $notes_show, + $notes_state, + $notes_threadMuting_create, + $notes_threadMuting_delete, + $notes_timeline, + $notes_translate, + $notes_unrenote, + $notes_userListTimeline, + $notifications_create, + $notifications_markAllAsRead, + $notifications_read, + $pagePush, + $pages_create, + $pages_delete, + $pages_featured, + $pages_like, + $pages_show, + $pages_unlike, + $pages_update, + $ping, + $pinnedUsers, + $promo_read, + $requestResetPassword, + $resetDb, + $resetPassword, + $serverInfo, + $stats, + $sw_register, + $sw_unregister, + $test, + $username_available, + $users, + $users_clips, + $users_followers, + $users_following, + $users_gallery_posts, + $users_getFrequentlyRepliedUsers, + $users_groups_create, + $users_groups_delete, + $users_groups_invitations_accept, + $users_groups_invitations_reject, + $users_groups_invite, + $users_groups_joined, + $users_groups_leave, + $users_groups_owned, + $users_groups_pull, + $users_groups_show, + $users_groups_transfer, + $users_groups_update, + $users_lists_create, + $users_lists_delete, + $users_lists_list, + $users_lists_pull, + $users_lists_push, + $users_lists_show, + $users_lists_update, + $users_notes, + $users_pages, + $users_reactions, + $users_recommendation, + $users_relation, + $users_reportAbuse, + $users_searchByUsernameAndHost, + $users_search, + $users_show, + $users_stats, + $admin_driveCapOverride, + $fetchRss, + ], +}) +export class EndpointsModule {} diff --git a/packages/backend/src/server/api/RateLimiterService.ts b/packages/backend/src/server/api/RateLimiterService.ts new file mode 100644 index 000000000..35f28bfd6 --- /dev/null +++ b/packages/backend/src/server/api/RateLimiterService.ts @@ -0,0 +1,93 @@ +import { Inject, Injectable } from '@nestjs/common'; +import Limiter from 'ratelimiter'; +import Redis from 'ioredis'; +import { DI } from '@/di-symbols.js'; +import type Logger from '@/logger.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import type { IEndpointMeta } from './endpoints.js'; + +@Injectable() +export class RateLimiterService { + private logger: Logger; + + constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + + private loggerService: LoggerService, + ) { + this.logger = this.loggerService.getLogger('limiter'); + } + + public limit(limitation: IEndpointMeta['limit'] & { key: NonNullable }, actor: string) { + return new Promise((ok, reject) => { + if (process.env.NODE_ENV === 'test') ok(); + + // Short-term limit + const min = (): void => { + const minIntervalLimiter = new Limiter({ + id: `${actor}:${limitation.key}:min`, + duration: limitation.minInterval, + max: 1, + db: this.redisClient, + }); + + minIntervalLimiter.get((err, info) => { + if (err) { + return reject('ERR'); + } + + this.logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`); + + if (info.remaining === 0) { + reject('BRIEF_REQUEST_INTERVAL'); + } else { + if (hasLongTermLimit) { + max(); + } else { + ok(); + } + } + }); + }; + + // Long term limit + const max = (): void => { + const limiter = new Limiter({ + id: `${actor}:${limitation.key}`, + duration: limitation.duration, + max: limitation.max, + db: this.redisClient, + }); + + limiter.get((err, info) => { + if (err) { + return reject('ERR'); + } + + this.logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`); + + if (info.remaining === 0) { + reject('RATE_LIMIT_EXCEEDED'); + } else { + ok(); + } + }); + }; + + const hasShortTermLimit = typeof limitation.minInterval === 'number'; + + const hasLongTermLimit = + typeof limitation.duration === 'number' && + typeof limitation.max === 'number'; + + if (hasShortTermLimit) { + min(); + } else if (hasLongTermLimit) { + max(); + } else { + ok(); + } + }); + } +} diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts new file mode 100644 index 000000000..5cda3c620 --- /dev/null +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -0,0 +1,282 @@ +import { randomBytes } from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import bcrypt from 'bcryptjs'; +import * as speakeasy from 'speakeasy'; +import { IsNull } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { UserSecurityKeysRepository, SigninsRepository, UserProfilesRepository, AttestationChallengesRepository, UsersRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import { getIpHash } from '@/misc/get-ip-hash.js'; +import type { ILocalUser } from '@/models/entities/User.js'; +import { IdService } from '@/core/IdService.js'; +import { TwoFactorAuthenticationService } from '@/core/TwoFactorAuthenticationService.js'; +import { RateLimiterService } from './RateLimiterService.js'; +import { SigninService } from './SigninService.js'; +import type Koa from 'koa'; + +@Injectable() +export class SigninApiService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userSecurityKeysRepository) + private userSecurityKeysRepository: UserSecurityKeysRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.attestationChallengesRepository) + private attestationChallengesRepository: AttestationChallengesRepository, + + @Inject(DI.signinsRepository) + private signinsRepository: SigninsRepository, + + private idService: IdService, + private rateLimiterService: RateLimiterService, + private signinService: SigninService, + private twoFactorAuthenticationService: TwoFactorAuthenticationService, + ) { + } + + public async signin(ctx: Koa.Context) { + ctx.set('Access-Control-Allow-Origin', this.config.url); + ctx.set('Access-Control-Allow-Credentials', 'true'); + + const body = ctx.request.body as any; + const username = body['username']; + const password = body['password']; + const token = body['token']; + + function error(status: number, error: { id: string }) { + ctx.status = status; + ctx.body = { error }; + } + + try { + // not more than 1 attempt per second and not more than 10 attempts per hour + await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(ctx.ip)); + } catch (err) { + ctx.status = 429; + ctx.body = { + error: { + message: 'Too many failed attempts to sign in. Try again later.', + code: 'TOO_MANY_AUTHENTICATION_FAILURES', + id: '22d05606-fbcf-421a-a2db-b32610dcfd1b', + }, + }; + return; + } + + if (typeof username !== 'string') { + ctx.status = 400; + return; + } + + if (typeof password !== 'string') { + ctx.status = 400; + return; + } + + if (token != null && typeof token !== 'string') { + ctx.status = 400; + return; + } + + // Fetch user + const user = await this.usersRepository.findOneBy({ + usernameLower: username.toLowerCase(), + host: IsNull(), + }) as ILocalUser; + + if (user == null) { + error(404, { + id: '6cc579cc-885d-43d8-95c2-b8c7fc963280', + }); + return; + } + + if (user.isSuspended) { + error(403, { + id: 'e03a5f46-d309-4865-9b69-56282d94e1eb', + }); + return; + } + + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + + // Compare password + const same = await bcrypt.compare(password, profile.password!); + + const fail = async (status?: number, failure?: { id: string }) => { + // Append signin history + await this.signinsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: user.id, + ip: ctx.ip, + headers: ctx.headers, + success: false, + }); + + error(status ?? 500, failure ?? { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' }); + }; + + if (!profile.twoFactorEnabled) { + if (same) { + this.signinService.signin(ctx, user); + return; + } else { + await fail(403, { + id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', + }); + return; + } + } + + if (token) { + if (!same) { + await fail(403, { + id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', + }); + return; + } + + const verified = (speakeasy as any).totp.verify({ + secret: profile.twoFactorSecret, + encoding: 'base32', + token: token, + window: 2, + }); + + if (verified) { + this.signinService.signin(ctx, user); + return; + } else { + await fail(403, { + id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f', + }); + return; + } + } else if (body.credentialId) { + if (!same && !profile.usePasswordLessLogin) { + await fail(403, { + id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', + }); + return; + } + + const clientDataJSON = Buffer.from(body.clientDataJSON, 'hex'); + const clientData = JSON.parse(clientDataJSON.toString('utf-8')); + const challenge = await this.attestationChallengesRepository.findOneBy({ + userId: user.id, + id: body.challengeId, + registrationChallenge: false, + challenge: this.twoFactorAuthenticationService.hash(clientData.challenge).toString('hex'), + }); + + if (!challenge) { + await fail(403, { + id: '2715a88a-2125-4013-932f-aa6fe72792da', + }); + return; + } + + await this.attestationChallengesRepository.delete({ + userId: user.id, + id: body.challengeId, + }); + + if (new Date().getTime() - challenge.createdAt.getTime() >= 5 * 60 * 1000) { + await fail(403, { + id: '2715a88a-2125-4013-932f-aa6fe72792da', + }); + return; + } + + const securityKey = await this.userSecurityKeysRepository.findOneBy({ + id: Buffer.from( + body.credentialId + .replace(/-/g, '+') + .replace(/_/g, '/'), + 'base64', + ).toString('hex'), + }); + + if (!securityKey) { + await fail(403, { + id: '66269679-aeaf-4474-862b-eb761197e046', + }); + return; + } + + const isValid = this.twoFactorAuthenticationService.verifySignin({ + publicKey: Buffer.from(securityKey.publicKey, 'hex'), + authenticatorData: Buffer.from(body.authenticatorData, 'hex'), + clientDataJSON, + clientData, + signature: Buffer.from(body.signature, 'hex'), + challenge: challenge.challenge, + }); + + if (isValid) { + this.signinService.signin(ctx, user); + return; + } else { + await fail(403, { + id: '93b86c4b-72f9-40eb-9815-798928603d1e', + }); + return; + } + } else { + if (!same && !profile.usePasswordLessLogin) { + await fail(403, { + id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', + }); + return; + } + + const keys = await this.userSecurityKeysRepository.findBy({ + userId: user.id, + }); + + if (keys.length === 0) { + await fail(403, { + id: 'f27fd449-9af4-4841-9249-1f989b9fa4a4', + }); + return; + } + + // 32 byte challenge + const challenge = randomBytes(32).toString('base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + + const challengeId = this.idService.genId(); + + await this.attestationChallengesRepository.insert({ + userId: user.id, + id: challengeId, + challenge: this.twoFactorAuthenticationService.hash(Buffer.from(challenge, 'utf-8')).toString('hex'), + createdAt: new Date(), + registrationChallenge: false, + }); + + ctx.body = { + challenge, + challengeId, + securityKeys: keys.map(key => ({ + id: key.id, + })), + }; + ctx.status = 200; + return; + } + // never get here + } +} + diff --git a/packages/backend/src/server/api/SigninService.ts b/packages/backend/src/server/api/SigninService.ts new file mode 100644 index 000000000..19d14bad6 --- /dev/null +++ b/packages/backend/src/server/api/SigninService.ts @@ -0,0 +1,64 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { SigninsRepository } from '@/models/index.js'; +import type { UsersRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import { IdService } from '@/core/IdService.js'; +import type { ILocalUser } from '@/models/entities/User.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { SigninEntityService } from '@/core/entities/SigninEntityService.js'; +import type Koa from 'koa'; + +@Injectable() +export class SigninService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.signinsRepository) + private signinsRepository: SigninsRepository, + + private signinEntityService: SigninEntityService, + private idService: IdService, + private globalEventService: GlobalEventService, + ) { + } + + public signin(ctx: Koa.Context, user: ILocalUser, redirect = false) { + if (redirect) { + //#region Cookie + ctx.cookies.set('igi', user.token!, { + path: '/', + // SEE: https://github.com/koajs/koa/issues/974 + // When using a SSL proxy it should be configured to add the "X-Forwarded-Proto: https" header + secure: this.config.url.startsWith('https'), + httpOnly: false, + }); + //#endregion + + ctx.redirect(this.config.url); + } else { + ctx.body = { + id: user.id, + i: user.token, + }; + ctx.status = 200; + } + + (async () => { + // Append signin history + const record = await this.signinsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: user.id, + ip: ctx.ip, + headers: ctx.headers, + success: true, + }).then(x => this.signinsRepository.findOneByOrFail(x.identifiers[0])); + + // Publish signin event + this.globalEventService.publishMainStream(user.id, 'signin', await this.signinEntityService.pack(record)); + })(); + } +} + diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts new file mode 100644 index 000000000..df040ddcf --- /dev/null +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -0,0 +1,175 @@ +import { Inject, Injectable } from '@nestjs/common'; +import rndstr from 'rndstr'; +import bcrypt from 'bcryptjs'; +import { DI } from '@/di-symbols.js'; +import { RegistrationTicketsRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import { MetaService } from '@/core/MetaService.js'; +import { CaptchaService } from '@/core/CaptchaService.js'; +import { IdService } from '@/core/IdService.js'; +import { SignupService } from '@/core/SignupService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { EmailService } from '@/core/EmailService.js'; +import { SigninService } from './SigninService.js'; +import type Koa from 'koa'; + +@Injectable() +export class SignupApiService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.userPendingsRepository) + private userPendingsRepository: UserPendingsRepository, + + @Inject(DI.registrationTicketsRepository) + private registrationTicketsRepository: RegistrationTicketsRepository, + + private userEntityService: UserEntityService, + private idService: IdService, + private metaService: MetaService, + private captchaService: CaptchaService, + private signupService: SignupService, + private signinService: SigninService, + private emailService: EmailService, + ) { + } + + public async signup(ctx: Koa.Context) { + const body = ctx.request.body; + + const instance = await this.metaService.fetch(true); + + // Verify *Captcha + // ただしテスト時はこの機構は障害となるため無効にする + if (process.env.NODE_ENV !== 'test') { + if (instance.enableHcaptcha && instance.hcaptchaSecretKey) { + await this.captchaService.verifyHcaptcha(instance.hcaptchaSecretKey, body['hcaptcha-response']).catch(e => { + ctx.throw(400, e); + }); + } + + if (instance.enableRecaptcha && instance.recaptchaSecretKey) { + await this.captchaService.verifyRecaptcha(instance.recaptchaSecretKey, body['g-recaptcha-response']).catch(e => { + ctx.throw(400, e); + }); + } + } + + const username = body['username']; + const password = body['password']; + const host: string | null = process.env.NODE_ENV === 'test' ? (body['host'] ?? null) : null; + const invitationCode = body['invitationCode']; + const emailAddress = body['emailAddress']; + + if (instance.emailRequiredForSignup) { + if (emailAddress == null || typeof emailAddress !== 'string') { + ctx.status = 400; + return; + } + + const available = await this.emailService.validateEmailForAccount(emailAddress); + if (!available) { + ctx.status = 400; + return; + } + } + + if (instance.disableRegistration) { + if (invitationCode == null || typeof invitationCode !== 'string') { + ctx.status = 400; + return; + } + + const ticket = await this.registrationTicketsRepository.findOneBy({ + code: invitationCode, + }); + + if (ticket == null) { + ctx.status = 400; + return; + } + + this.registrationTicketsRepository.delete(ticket.id); + } + + if (instance.emailRequiredForSignup) { + const code = rndstr('a-z0-9', 16); + + // Generate hash of password + const salt = await bcrypt.genSalt(8); + const hash = await bcrypt.hash(password, salt); + + await this.userPendingsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + code, + email: emailAddress, + username: username, + password: hash, + }); + + const link = `${this.config.url}/signup-complete/${code}`; + + sendEmail(emailAddress, 'Signup', + `To complete signup, please click this link:
${link}`, + `To complete signup, please click this link: ${link}`); + + ctx.status = 204; + } else { + try { + const { account, secret } = await this.signupService.signup({ + username, password, host, + }); + + const res = await this.userEntityService.pack(account, account, { + detail: true, + includeSecrets: true, + }); + + (res as any).token = secret; + + ctx.body = res; + } catch (e) { + ctx.throw(400, e); + } + } + } + + public async signupPending(ctx: Koa.Context) { + const body = ctx.request.body; + + const code = body['code']; + + try { + const pendingUser = await this.userPendingsRepository.findOneByOrFail({ code }); + + const { account, secret } = await this.signupService.signup({ + username: pendingUser.username, + passwordHash: pendingUser.password, + }); + + this.userPendingsRepository.delete({ + id: pendingUser.id, + }); + + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: account.id }); + + await this.userProfilesRepository.update({ userId: profile.userId }, { + email: pendingUser.email, + emailVerified: true, + emailVerifyCode: null, + }); + + this.signinService.signin(ctx, account); + } catch (e) { + ctx.throw(400, e); + } + } +} diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts new file mode 100644 index 000000000..b08b01aef --- /dev/null +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -0,0 +1,120 @@ +import { EventEmitter } from 'events'; +import { Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import * as websocket from 'websocket'; +import { DI } from '@/di-symbols.js'; +import { UsersRepository, BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import { NoteReadService } from '@/core/NoteReadService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { NotificationService } from '@/core/NotificationService.js'; +import { AuthenticateService } from './AuthenticateService.js'; +import MainStreamConnection from './stream/index.js'; +import { ChannelsService } from './stream/ChannelsService.js'; +import type { ParsedUrlQuery } from 'querystring'; +import type * as http from 'node:http'; + +@Injectable() +export class StreamingApiServerService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.redisSubscriber) + private redisSubscriber: Redis.Redis, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + private globalEventService: GlobalEventService, + private noteReadService: NoteReadService, + private authenticateService: AuthenticateService, + private channelsService: ChannelsService, + private notificationService: NotificationService, + ) { + } + + public attachStreamingApi(server: http.Server) { + // Init websocket server + const ws = new websocket.server({ + httpServer: server, + }); + + ws.on('request', async (request) => { + const q = request.resourceURL.query as ParsedUrlQuery; + + // TODO: トークンが間違ってるなどしてauthenticateに失敗したら + // コネクション切断するなりエラーメッセージ返すなりする + // (現状はエラーがキャッチされておらずサーバーのログに流れて邪魔なので) + const [user, miapp] = await this.authenticateService.authenticate(q.i as string); + + if (user?.isSuspended) { + request.reject(400); + return; + } + + const connection = request.accept(); + + const ev = new EventEmitter(); + + async function onRedisMessage(_: string, data: string) { + const parsed = JSON.parse(data); + ev.emit(parsed.channel, parsed.message); + } + + this.redisSubscriber.on('message', onRedisMessage); + + const main = new MainStreamConnection( + this.followingsRepository, + this.mutingsRepository, + this.blockingsRepository, + this.channelFollowingsRepository, + this.userProfilesRepository, + this.channelsService, + this.globalEventService, + this.noteReadService, + this.notificationService, + connection, ev, user, miapp, + ); + + const intervalId = user ? setInterval(() => { + this.usersRepository.update(user.id, { + lastActiveDate: new Date(), + }); + }, 1000 * 60 * 5) : null; + if (user) { + this.usersRepository.update(user.id, { + lastActiveDate: new Date(), + }); + } + + connection.once('close', () => { + ev.removeAllListeners(); + main.dispose(); + this.redisSubscriber.off('message', onRedisMessage); + if (intervalId) clearInterval(intervalId); + }); + + connection.on('message', async (data) => { + if (data.type === 'utf8' && data.utf8Data === 'ping') { + connection.send('pong'); + } + }); + }); + } +} diff --git a/packages/backend/src/server/api/api-handler.ts b/packages/backend/src/server/api/api-handler.ts deleted file mode 100644 index ec71ddd2c..000000000 --- a/packages/backend/src/server/api/api-handler.ts +++ /dev/null @@ -1,92 +0,0 @@ -import Koa from 'koa'; - -import { User } from '@/models/entities/user.js'; -import { UserIps } from '@/models/index.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { IEndpoint } from './endpoints.js'; -import authenticate, { AuthenticationError } from './authenticate.js'; -import call from './call.js'; -import { ApiError } from './error.js'; - -const userIpHistories = new Map>(); - -setInterval(() => { - userIpHistories.clear(); -}, 1000 * 60 * 60); - -export default (endpoint: IEndpoint, ctx: Koa.Context) => new Promise((res) => { - const body = ctx.is('multipart/form-data') - ? (ctx.request as any).body - : ctx.method === 'GET' - ? ctx.query - : ctx.request.body; - - const reply = (x?: any, y?: ApiError) => { - if (x == null) { - ctx.status = 204; - } else if (typeof x === 'number' && y) { - ctx.status = x; - ctx.body = { - error: { - message: y!.message, - code: y!.code, - id: y!.id, - kind: y!.kind, - ...(y!.info ? { info: y!.info } : {}), - }, - }; - } else { - // 文字列を返す場合は、JSON.stringify通さないとJSONと認識されない - ctx.body = typeof x === 'string' ? JSON.stringify(x) : x; - } - res(); - }; - - // Authentication - authenticate(body['i']).then(([user, app]) => { - // API invoking - call(endpoint.name, user, app, body, ctx).then((res: any) => { - if (ctx.method === 'GET' && endpoint.meta.cacheSec && !body['i'] && !user) { - ctx.set('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`); - } - reply(res); - }).catch((e: ApiError) => { - reply(e.httpStatusCode ? e.httpStatusCode : e.kind === 'client' ? 400 : 500, e); - }); - - // Log IP - if (user) { - fetchMeta().then(meta => { - if (!meta.enableIpLogging) return; - const ip = ctx.ip; - const ips = userIpHistories.get(user.id); - if (ips == null || !ips.has(ip)) { - if (ips == null) { - userIpHistories.set(user.id, new Set([ip])); - } else { - ips.add(ip); - } - - try { - UserIps.createQueryBuilder().insert().values({ - createdAt: new Date(), - userId: user.id, - ip: ip, - }).orIgnore(true).execute(); - } catch { - } - } - }); - } - }).catch(e => { - if (e instanceof AuthenticationError) { - reply(403, new ApiError({ - message: 'Authentication failed. Please ensure your token is correct.', - code: 'AUTHENTICATION_FAILED', - id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14', - })); - } else { - reply(500, new ApiError()); - } - }); -}); diff --git a/packages/backend/src/server/api/authenticate.ts b/packages/backend/src/server/api/authenticate.ts deleted file mode 100644 index 65ccfcf55..000000000 --- a/packages/backend/src/server/api/authenticate.ts +++ /dev/null @@ -1,66 +0,0 @@ -import isNativeToken from './common/is-native-token.js'; -import { CacheableLocalUser, ILocalUser } from '@/models/entities/user.js'; -import { Users, AccessTokens, Apps } from '@/models/index.js'; -import { AccessToken } from '@/models/entities/access-token.js'; -import { Cache } from '@/misc/cache.js'; -import { App } from '@/models/entities/app.js'; -import { localUserByIdCache, localUserByNativeTokenCache } from '@/services/user-cache.js'; - -const appCache = new Cache(Infinity); - -export class AuthenticationError extends Error { - constructor(message: string) { - super(message); - this.name = 'AuthenticationError'; - } -} - -export default async (token: string | null): Promise<[CacheableLocalUser | null | undefined, AccessToken | null | undefined]> => { - if (token == null) { - return [null, null]; - } - - if (isNativeToken(token)) { - const user = await localUserByNativeTokenCache.fetch(token, - () => Users.findOneBy({ token }) as Promise); - - if (user == null) { - throw new AuthenticationError('user not found'); - } - - return [user, null]; - } else { - const accessToken = await AccessTokens.findOne({ - where: [{ - hash: token.toLowerCase(), // app - }, { - token: token, // miauth - }], - }); - - if (accessToken == null) { - throw new AuthenticationError('invalid signature'); - } - - AccessTokens.update(accessToken.id, { - lastUsedAt: new Date(), - }); - - const user = await localUserByIdCache.fetch(accessToken.userId, - () => Users.findOneBy({ - id: accessToken.userId, - }) as Promise); - - if (accessToken.appId) { - const app = await appCache.fetch(accessToken.appId, - () => Apps.findOneByOrFail({ id: accessToken.appId! })); - - return [user, { - id: accessToken.id, - permission: app.permission, - } as AccessToken]; - } else { - return [user, accessToken]; - } - } -}; diff --git a/packages/backend/src/server/api/call.ts b/packages/backend/src/server/api/call.ts deleted file mode 100644 index aa130459a..000000000 --- a/packages/backend/src/server/api/call.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { performance } from 'perf_hooks'; -import Koa from 'koa'; -import { CacheableLocalUser, User } from '@/models/entities/user.js'; -import { AccessToken } from '@/models/entities/access-token.js'; -import { getIpHash } from '@/misc/get-ip-hash.js'; -import { limiter } from './limiter.js'; -import endpoints, { IEndpointMeta } from './endpoints.js'; -import { ApiError } from './error.js'; -import { apiLogger } from './logger.js'; - -const accessDenied = { - message: 'Access denied.', - code: 'ACCESS_DENIED', - id: '56f35758-7dd5-468b-8439-5d6fb8ec9b8e', -}; - -export default async (endpoint: string, user: CacheableLocalUser | null | undefined, token: AccessToken | null | undefined, data: any, ctx?: Koa.Context) => { - const isSecure = user != null && token == null; - const isModerator = user != null && (user.isModerator || user.isAdmin); - - const ep = endpoints.find(e => e.name === endpoint); - - if (ep == null) { - throw new ApiError({ - message: 'No such endpoint.', - code: 'NO_SUCH_ENDPOINT', - id: 'f8080b67-5f9c-4eb7-8c18-7f1eeae8f709', - httpStatusCode: 404, - }); - } - - if (ep.meta.secure && !isSecure) { - throw new ApiError(accessDenied); - } - - if (ep.meta.limit) { - // koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app. - let limitActor: string; - if (user) { - limitActor = user.id; - } else { - limitActor = getIpHash(ctx!.ip); - } - - const limit = Object.assign({}, ep.meta.limit); - - if (!limit.key) { - limit.key = ep.name; - } - - // Rate limit - await limiter(limit as IEndpointMeta['limit'] & { key: NonNullable }, limitActor).catch(e => { - throw new ApiError({ - message: 'Rate limit exceeded. Please try again later.', - code: 'RATE_LIMIT_EXCEEDED', - id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', - httpStatusCode: 429, - }); - }); - } - - if (ep.meta.requireCredential && user == null) { - throw new ApiError({ - message: 'Credential required.', - code: 'CREDENTIAL_REQUIRED', - id: '1384574d-a912-4b81-8601-c7b1c4085df1', - httpStatusCode: 401, - }); - } - - if (ep.meta.requireCredential && user!.isSuspended) { - throw new ApiError({ - message: 'Your account has been suspended.', - code: 'YOUR_ACCOUNT_SUSPENDED', - id: 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370', - httpStatusCode: 403, - }); - } - - if (ep.meta.requireAdmin && !user!.isAdmin) { - throw new ApiError(accessDenied, { reason: 'You are not the admin.' }); - } - - if (ep.meta.requireModerator && !isModerator) { - throw new ApiError(accessDenied, { reason: 'You are not a moderator.' }); - } - - if (token && ep.meta.kind && !token.permission.some(p => p === ep.meta.kind)) { - throw new ApiError({ - message: 'Your app does not have the necessary permissions to use this endpoint.', - code: 'PERMISSION_DENIED', - id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838', - }); - } - - // Cast non JSON input - if ((ep.meta.requireFile || ctx?.method === 'GET') && ep.params.properties) { - for (const k of Object.keys(ep.params.properties)) { - const param = ep.params.properties![k]; - if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') { - try { - data[k] = JSON.parse(data[k]); - } catch (e) { - throw new ApiError({ - message: 'Invalid param.', - code: 'INVALID_PARAM', - id: '0b5f1631-7c1a-41a6-b399-cce335f34d85', - }, { - param: k, - reason: `cannot cast to ${param.type}`, - }); - } - } - } - } - - // API invoking - const before = performance.now(); - return await ep.exec(data, user, token, ctx?.file, ctx?.ip, ctx?.headers).catch((e: Error) => { - if (e instanceof ApiError) { - throw e; - } else { - apiLogger.error(`Internal error occurred in ${ep.name}: ${e.message}`, { - ep: ep.name, - ps: data, - e: { - message: e.message, - code: e.name, - stack: e.stack, - }, - }); - throw new ApiError(null, { - e: { - message: e.message, - code: e.name, - stack: e.stack, - }, - }); - } - }).finally(() => { - const after = performance.now(); - const time = after - before; - if (time > 1000) { - apiLogger.warn(`SLOW API CALL DETECTED: ${ep.name} (${time}ms)`); - } - }); -}; diff --git a/packages/backend/src/server/api/common/GetterService.ts b/packages/backend/src/server/api/common/GetterService.ts new file mode 100644 index 000000000..5523539b9 --- /dev/null +++ b/packages/backend/src/server/api/common/GetterService.ts @@ -0,0 +1,71 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { NotesRepository, UsersRepository } from '@/models/index.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import type { User } from '@/models/entities/User.js'; +import type { Note } from '@/models/entities/Note.js'; + +@Injectable() +export class GetterService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + ) { + } + + /** + * Get note for API processing + */ + public async getNote(noteId: Note['id']) { + const note = await this.notesRepository.findOneBy({ id: noteId }); + + if (note == null) { + throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.'); + } + + return note; + } + + /** + * Get user for API processing + */ + public async getUser(userId: User['id']) { + const user = await this.usersRepository.findOneBy({ id: userId }); + + if (user == null) { + throw new IdentifiableError('15348ddd-432d-49c2-8a5a-8069753becff', 'No such user.'); + } + + return user; + } + + /** + * Get remote user for API processing + */ + public async getRemoteUser(userId: User['id']) { + const user = await this.getUser(userId); + + if (!this.userEntityService.isRemoteUser(user)) { + throw new Error('user is not a remote user'); + } + + return user; + } + + /** + * Get local user for API processing + */ + public async getLocalUser(userId: User['id']) { + const user = await this.getUser(userId); + + if (!this.userEntityService.isLocalUser(user)) { + throw new Error('user is not a local user'); + } + + return user; + } +} + diff --git a/packages/backend/src/server/api/common/generate-block-query.ts b/packages/backend/src/server/api/common/generate-block-query.ts deleted file mode 100644 index 60db1e731..000000000 --- a/packages/backend/src/server/api/common/generate-block-query.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { User } from '@/models/entities/user.js'; -import { Blockings } from '@/models/index.js'; -import { Brackets, SelectQueryBuilder } from 'typeorm'; - -// ここでいうBlockedは被Blockedの意 -export function generateBlockedUserQuery(q: SelectQueryBuilder, me: { id: User['id'] }) { - const blockingQuery = Blockings.createQueryBuilder('blocking') - .select('blocking.blockerId') - .where('blocking.blockeeId = :blockeeId', { blockeeId: me.id }); - - // 投稿の作者にブロックされていない かつ - // 投稿の返信先の作者にブロックされていない かつ - // 投稿の引用元の作者にブロックされていない - q - .andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`) - .andWhere(new Brackets(qb => { qb - .where(`note.replyUserId IS NULL`) - .orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`); - })) - .andWhere(new Brackets(qb => { qb - .where(`note.renoteUserId IS NULL`) - .orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`); - })); - - q.setParameters(blockingQuery.getParameters()); -} - -export function generateBlockQueryForUsers(q: SelectQueryBuilder, me: { id: User['id'] }) { - const blockingQuery = Blockings.createQueryBuilder('blocking') - .select('blocking.blockeeId') - .where('blocking.blockerId = :blockerId', { blockerId: me.id }); - - const blockedQuery = Blockings.createQueryBuilder('blocking') - .select('blocking.blockerId') - .where('blocking.blockeeId = :blockeeId', { blockeeId: me.id }); - - q.andWhere(`user.id NOT IN (${ blockingQuery.getQuery() })`); - q.setParameters(blockingQuery.getParameters()); - - q.andWhere(`user.id NOT IN (${ blockedQuery.getQuery() })`); - q.setParameters(blockedQuery.getParameters()); -} diff --git a/packages/backend/src/server/api/common/generate-channel-query.ts b/packages/backend/src/server/api/common/generate-channel-query.ts deleted file mode 100644 index 333bb73b8..000000000 --- a/packages/backend/src/server/api/common/generate-channel-query.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { User } from '@/models/entities/user.js'; -import { ChannelFollowings } from '@/models/index.js'; -import { Brackets, SelectQueryBuilder } from 'typeorm'; - -export function generateChannelQuery(q: SelectQueryBuilder, me?: { id: User['id'] } | null) { - if (me == null) { - q.andWhere('note.channelId IS NULL'); - } else { - q.leftJoinAndSelect('note.channel', 'channel'); - - const channelFollowingQuery = ChannelFollowings.createQueryBuilder('channelFollowing') - .select('channelFollowing.followeeId') - .where('channelFollowing.followerId = :followerId', { followerId: me.id }); - - q.andWhere(new Brackets(qb => { qb - // チャンネルのノートではない - .where('note.channelId IS NULL') - // または自分がフォローしているチャンネルのノート - .orWhere(`note.channelId IN (${ channelFollowingQuery.getQuery() })`); - })); - - q.setParameters(channelFollowingQuery.getParameters()); - } -} diff --git a/packages/backend/src/server/api/common/generate-muted-note-query.ts b/packages/backend/src/server/api/common/generate-muted-note-query.ts deleted file mode 100644 index f544e334d..000000000 --- a/packages/backend/src/server/api/common/generate-muted-note-query.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { User } from '@/models/entities/user.js'; -import { MutedNotes } from '@/models/index.js'; -import { SelectQueryBuilder } from 'typeorm'; - -export function generateMutedNoteQuery(q: SelectQueryBuilder, me: { id: User['id'] }) { - const mutedQuery = MutedNotes.createQueryBuilder('muted') - .select('muted.noteId') - .where('muted.userId = :userId', { userId: me.id }); - - q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`); - - q.setParameters(mutedQuery.getParameters()); -} diff --git a/packages/backend/src/server/api/common/generate-muted-note-thread-query.ts b/packages/backend/src/server/api/common/generate-muted-note-thread-query.ts deleted file mode 100644 index 7263ea2e6..000000000 --- a/packages/backend/src/server/api/common/generate-muted-note-thread-query.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { User } from '@/models/entities/user.js'; -import { NoteThreadMutings } from '@/models/index.js'; -import { Brackets, SelectQueryBuilder } from 'typeorm'; - -export function generateMutedNoteThreadQuery(q: SelectQueryBuilder, me: { id: User['id'] }) { - const mutedQuery = NoteThreadMutings.createQueryBuilder('threadMuted') - .select('threadMuted.threadId') - .where('threadMuted.userId = :userId', { userId: me.id }); - - q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`); - q.andWhere(new Brackets(qb => { qb - .where(`note.threadId IS NULL`) - .orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`); - })); - - q.setParameters(mutedQuery.getParameters()); -} diff --git a/packages/backend/src/server/api/common/generate-muted-user-query.ts b/packages/backend/src/server/api/common/generate-muted-user-query.ts deleted file mode 100644 index 470ece1a6..000000000 --- a/packages/backend/src/server/api/common/generate-muted-user-query.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { SelectQueryBuilder, Brackets } from 'typeorm'; -import { User } from '@/models/entities/user.js'; -import { Mutings, UserProfiles } from '@/models/index.js'; - -export function generateMutedUserQuery(q: SelectQueryBuilder, me: { id: User['id'] }, exclude?: User) { - const mutingQuery = Mutings.createQueryBuilder('muting') - .select('muting.muteeId') - .where('muting.muterId = :muterId', { muterId: me.id }); - - if (exclude) { - mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: exclude.id }); - } - - const mutingInstanceQuery = UserProfiles.createQueryBuilder('user_profile') - .select('user_profile.mutedInstances') - .where('user_profile.userId = :muterId', { muterId: me.id }); - - // 投稿の作者をミュートしていない かつ - // 投稿の返信先の作者をミュートしていない かつ - // 投稿の引用元の作者をミュートしていない - q - .andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`) - .andWhere(new Brackets(qb => { qb - .where('note.replyUserId IS NULL') - .orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`); - })) - .andWhere(new Brackets(qb => { qb - .where('note.renoteUserId IS NULL') - .orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`); - })) - // mute instances - .andWhere(new Brackets(qb => { qb - .andWhere('note.userHost IS NULL') - .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`); - })) - .andWhere(new Brackets(qb => { qb - .where('note.replyUserHost IS NULL') - .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`); - })) - .andWhere(new Brackets(qb => { qb - .where('note.renoteUserHost IS NULL') - .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`); - })); - - q.setParameters(mutingQuery.getParameters()); - q.setParameters(mutingInstanceQuery.getParameters()); -} - -export function generateMutedUserQueryForUsers(q: SelectQueryBuilder, me: { id: User['id'] }) { - const mutingQuery = Mutings.createQueryBuilder('muting') - .select('muting.muteeId') - .where('muting.muterId = :muterId', { muterId: me.id }); - - q.andWhere(`user.id NOT IN (${ mutingQuery.getQuery() })`); - - q.setParameters(mutingQuery.getParameters()); -} diff --git a/packages/backend/src/server/api/common/generate-replies-query.ts b/packages/backend/src/server/api/common/generate-replies-query.ts deleted file mode 100644 index 301782eab..000000000 --- a/packages/backend/src/server/api/common/generate-replies-query.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { User } from '@/models/entities/user.js'; -import { Brackets, SelectQueryBuilder } from 'typeorm'; - -export function generateRepliesQuery(q: SelectQueryBuilder, me?: Pick | null) { - if (me == null) { - q.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'); - })); - })); - } else if (!me.showTimelineReplies) { - q.andWhere(new Brackets(qb => { qb - .where(`note.replyId IS NULL`) // 返信ではない - .orWhere('note.replyUserId = :meId', { meId: me.id }) // 返信だけど自分のノートへの返信 - .orWhere(new Brackets(qb => { qb // 返信だけど自分の行った返信 - .where(`note.replyId IS NOT NULL`) - .andWhere('note.userId = :meId', { meId: me.id }); - })) - .orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信 - .where(`note.replyId IS NOT NULL`) - .andWhere('note.replyUserId = note.userId'); - })); - })); - } -} diff --git a/packages/backend/src/server/api/common/generate-visibility-query.ts b/packages/backend/src/server/api/common/generate-visibility-query.ts deleted file mode 100644 index b50b6812f..000000000 --- a/packages/backend/src/server/api/common/generate-visibility-query.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { User } from '@/models/entities/user.js'; -import { Followings } from '@/models/index.js'; -import { Brackets, SelectQueryBuilder } from 'typeorm'; - -export function generateVisibilityQuery(q: SelectQueryBuilder, me?: { id: User['id'] } | null) { - // This code must always be synchronized with the checks in Notes.isVisibleForMe. - if (me == null) { - q.andWhere(new Brackets(qb => { qb - .where(`note.visibility = 'public'`) - .orWhere(`note.visibility = 'home'`); - })); - } else { - const followingQuery = Followings.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :meId'); - - q.andWhere(new Brackets(qb => { qb - // 公開投稿である - .where(new Brackets(qb => { qb - .where(`note.visibility = 'public'`) - .orWhere(`note.visibility = 'home'`); - })) - // または 自分自身 - .orWhere('note.userId = :meId') - // または 自分宛て - .orWhere(':meId = ANY(note.visibleUserIds)') - .orWhere(':meId = ANY(note.mentions)') - .orWhere(new Brackets(qb => { qb - // または フォロワー宛ての投稿であり、 - .where(`note.visibility = 'followers'`) - .andWhere(new Brackets(qb => { qb - // 自分がフォロワーである - .where(`note.userId IN (${ followingQuery.getQuery() })`) - // または 自分の投稿へのリプライ - .orWhere('note.replyUserId = :meId'); - })); - })); - })); - - q.setParameters({ meId: me.id }); - } -} diff --git a/packages/backend/src/server/api/common/getters.ts b/packages/backend/src/server/api/common/getters.ts deleted file mode 100644 index 783ea9ef7..000000000 --- a/packages/backend/src/server/api/common/getters.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { User } from '@/models/entities/user.js'; -import { Note } from '@/models/entities/note.js'; -import { Notes, Users } from '@/models/index.js'; - -/** - * Get note for API processing - */ -export async function getNote(noteId: Note['id']) { - const note = await Notes.findOneBy({ id: noteId }); - - if (note == null) { - throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.'); - } - - return note; -} - -/** - * Get user for API processing - */ -export async function getUser(userId: User['id']) { - const user = await Users.findOneBy({ id: userId }); - - if (user == null) { - throw new IdentifiableError('15348ddd-432d-49c2-8a5a-8069753becff', 'No such user.'); - } - - return user; -} - -/** - * Get remote user for API processing - */ -export async function getRemoteUser(userId: User['id']) { - const user = await getUser(userId); - - if (!Users.isRemoteUser(user)) { - throw new Error('user is not a remote user'); - } - - return user; -} - -/** - * Get local user for API processing - */ -export async function getLocalUser(userId: User['id']) { - const user = await getUser(userId); - - if (!Users.isLocalUser(user)) { - throw new Error('user is not a local user'); - } - - return user; -} diff --git a/packages/backend/src/server/api/common/inject-featured.ts b/packages/backend/src/server/api/common/inject-featured.ts index f7cdd365e..d59bec33a 100644 --- a/packages/backend/src/server/api/common/inject-featured.ts +++ b/packages/backend/src/server/api/common/inject-featured.ts @@ -1,9 +1,6 @@ import rndstr from 'rndstr'; -import { Note } from '@/models/entities/note.js'; -import { User } from '@/models/entities/user.js'; -import { Notes, UserProfiles, NoteReactions } from '@/models/index.js'; -import { generateMutedUserQuery } from './generate-muted-user-query.js'; -import { generateBlockedUserQuery } from './generate-block-query.js'; +import type { Note } from '@/models/entities/Note.js'; +import type { User } from '@/models/entities/User.js'; // TODO: リアクション、Renote、返信などをしたノートは除外する @@ -21,9 +18,9 @@ export async function injectFeatured(timeline: Note[], user?: User | null) { const query = Notes.createQueryBuilder('note') .addSelect('note.score') .where('note.userHost IS NULL') - .andWhere(`note.score > 0`) - .andWhere(`note.createdAt > :date`, { date: new Date(Date.now() - day) }) - .andWhere(`note.visibility = 'public'`) + .andWhere('note.score > 0') + .andWhere('note.createdAt > :date', { date: new Date(Date.now() - day) }) + .andWhere('note.visibility = \'public\'') .innerJoinAndSelect('note.user', 'user'); if (user) { diff --git a/packages/backend/src/server/api/common/inject-promo.ts b/packages/backend/src/server/api/common/inject-promo.ts index b0da8118b..fe0289750 100644 --- a/packages/backend/src/server/api/common/inject-promo.ts +++ b/packages/backend/src/server/api/common/inject-promo.ts @@ -1,7 +1,6 @@ import rndstr from 'rndstr'; -import { Note } from '@/models/entities/note.js'; -import { User } from '@/models/entities/user.js'; -import { PromoReads, PromoNotes, Notes, Users } from '@/models/index.js'; +import type { Note } from '@/models/entities/Note.js'; +import type { User } from '@/models/entities/User.js'; export async function injectPromo(timeline: Note[], user?: User | null) { if (timeline.length < 5) return; diff --git a/packages/backend/src/server/api/common/make-pagination-query.ts b/packages/backend/src/server/api/common/make-pagination-query.ts deleted file mode 100644 index 51c11e5df..000000000 --- a/packages/backend/src/server/api/common/make-pagination-query.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { SelectQueryBuilder } from 'typeorm'; - -export function makePaginationQuery(q: SelectQueryBuilder, sinceId?: string, untilId?: string, sinceDate?: number, untilDate?: number) { - if (sinceId && untilId) { - q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); - q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId }); - q.orderBy(`${q.alias}.id`, 'DESC'); - } else if (sinceId) { - q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); - q.orderBy(`${q.alias}.id`, 'ASC'); - } else if (untilId) { - q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId }); - q.orderBy(`${q.alias}.id`, 'DESC'); - } else if (sinceDate && untilDate) { - q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) }); - q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) }); - q.orderBy(`${q.alias}.createdAt`, 'DESC'); - } else if (sinceDate) { - q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) }); - q.orderBy(`${q.alias}.createdAt`, 'ASC'); - } else if (untilDate) { - q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) }); - q.orderBy(`${q.alias}.createdAt`, 'DESC'); - } else { - q.orderBy(`${q.alias}.id`, 'DESC'); - } - return q; -} diff --git a/packages/backend/src/server/api/common/read-messaging-message.ts b/packages/backend/src/server/api/common/read-messaging-message.ts deleted file mode 100644 index c4c18ffa0..000000000 --- a/packages/backend/src/server/api/common/read-messaging-message.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { publishMainStream, publishGroupMessagingStream } from '@/services/stream.js'; -import { publishMessagingStream } from '@/services/stream.js'; -import { publishMessagingIndexStream } from '@/services/stream.js'; -import { pushNotification } from '@/services/push-notification.js'; -import { User, IRemoteUser } from '@/models/entities/user.js'; -import { MessagingMessage } from '@/models/entities/messaging-message.js'; -import { MessagingMessages, UserGroupJoinings, Users } from '@/models/index.js'; -import { In } from 'typeorm'; -import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { UserGroup } from '@/models/entities/user-group.js'; -import { toArray } from '@/prelude/array.js'; -import { renderReadActivity } from '@/remote/activitypub/renderer/read.js'; -import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import { deliver } from '@/queue/index.js'; -import orderedCollection from '@/remote/activitypub/renderer/ordered-collection.js'; - -/** - * Mark messages as read - */ -export async function readUserMessagingMessage( - userId: User['id'], - otherpartyId: User['id'], - messageIds: MessagingMessage['id'][] -) { - if (messageIds.length === 0) return; - - const messages = await MessagingMessages.findBy({ - id: In(messageIds), - }); - - for (const message of messages) { - if (message.recipientId !== userId) { - throw new IdentifiableError('e140a4bf-49ce-4fb6-b67c-b78dadf6b52f', 'Access denied (user).'); - } - } - - // Update documents - await MessagingMessages.update({ - id: In(messageIds), - userId: otherpartyId, - recipientId: userId, - isRead: false, - }, { - isRead: true, - }); - - // Publish event - publishMessagingStream(otherpartyId, userId, 'read', messageIds); - publishMessagingIndexStream(userId, 'read', messageIds); - - if (!await Users.getHasUnreadMessagingMessage(userId)) { - // 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行 - publishMainStream(userId, 'readAllMessagingMessages'); - pushNotification(userId, 'readAllMessagingMessages', undefined); - } else { - // そのユーザーとのメッセージで未読がなければイベント発行 - const count = await MessagingMessages.count({ - where: { - userId: otherpartyId, - recipientId: userId, - isRead: false, - }, - take: 1 - }); - - if (!count) { - pushNotification(userId, 'readAllMessagingMessagesOfARoom', { userId: otherpartyId }); - } - } -} - -/** - * Mark messages as read - */ -export async function readGroupMessagingMessage( - userId: User['id'], - groupId: UserGroup['id'], - messageIds: MessagingMessage['id'][] -) { - if (messageIds.length === 0) return; - - // check joined - const joining = await UserGroupJoinings.findOneBy({ - userId: userId, - userGroupId: groupId, - }); - - if (joining == null) { - throw new IdentifiableError('930a270c-714a-46b2-b776-ad27276dc569', 'Access denied (group).'); - } - - const messages = await MessagingMessages.findBy({ - id: In(messageIds), - }); - - const reads: MessagingMessage['id'][] = []; - - for (const message of messages) { - if (message.userId === userId) continue; - if (message.reads.includes(userId)) continue; - - // Update document - await MessagingMessages.createQueryBuilder().update() - .set({ - reads: (() => `array_append("reads", '${joining.userId}')`) as any, - }) - .where('id = :id', { id: message.id }) - .execute(); - - reads.push(message.id); - } - - // Publish event - publishGroupMessagingStream(groupId, 'read', { - ids: reads, - userId: userId, - }); - publishMessagingIndexStream(userId, 'read', reads); - - if (!await Users.getHasUnreadMessagingMessage(userId)) { - // 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行 - publishMainStream(userId, 'readAllMessagingMessages'); - pushNotification(userId, 'readAllMessagingMessages', undefined); - } else { - // そのグループにおいて未読がなければイベント発行 - const unreadExist = await MessagingMessages.createQueryBuilder('message') - .where(`message.groupId = :groupId`, { groupId: groupId }) - .andWhere('message.userId != :userId', { userId: userId }) - .andWhere('NOT (:userId = ANY(message.reads))', { userId: userId }) - .andWhere('message.createdAt > :joinedAt', { joinedAt: joining.createdAt }) // 自分が加入する前の会話については、未読扱いしない - .getOne().then(x => x != null); - - if (!unreadExist) { - pushNotification(userId, 'readAllMessagingMessagesOfARoom', { groupId }); - } - } -} - -export async function deliverReadActivity(user: { id: User['id']; host: null; }, recipient: IRemoteUser, messages: MessagingMessage | MessagingMessage[]) { - messages = toArray(messages).filter(x => x.uri); - const contents = messages.map(x => renderReadActivity(user, x)); - - if (contents.length > 1) { - const collection = orderedCollection(null, contents.length, undefined, undefined, contents); - deliver(user, renderActivity(collection), recipient.inbox); - } else { - for (const content of contents) { - deliver(user, renderActivity(content), recipient.inbox); - } - } -} diff --git a/packages/backend/src/server/api/common/read-notification.ts b/packages/backend/src/server/api/common/read-notification.ts deleted file mode 100644 index cf993ade3..000000000 --- a/packages/backend/src/server/api/common/read-notification.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { publishMainStream } from '@/services/stream.js'; -import { pushNotification } from '@/services/push-notification.js'; -import { User } from '@/models/entities/user.js'; -import { Notification } from '@/models/entities/notification.js'; -import { Notifications, Users } from '@/models/index.js'; -import { In } from 'typeorm'; - -export async function readNotification( - userId: User['id'], - notificationIds: Notification['id'][] -) { - if (notificationIds.length === 0) return; - - // Update documents - const result = await Notifications.update({ - id: In(notificationIds), - isRead: false, - }, { - isRead: true, - }); - - if (result.affected === 0) return; - - if (!await Users.getHasUnreadNotification(userId)) return postReadAllNotifications(userId); - else return postReadNotifications(userId, notificationIds); -} - -export async function readNotificationByQuery( - userId: User['id'], - query: Record -) { - const notificationIds = await Notifications.findBy({ - ...query, - notifieeId: userId, - isRead: false, - }).then(notifications => notifications.map(notification => notification.id)); - - return readNotification(userId, notificationIds); -} - -function postReadAllNotifications(userId: User['id']) { - publishMainStream(userId, 'readAllNotifications'); - return pushNotification(userId, 'readAllNotifications', undefined); -} - -function postReadNotifications(userId: User['id'], notificationIds: Notification['id'][]) { - publishMainStream(userId, 'readNotifications', notificationIds); - return pushNotification(userId, 'readNotifications', { notificationIds }); -} diff --git a/packages/backend/src/server/api/common/signin.ts b/packages/backend/src/server/api/common/signin.ts deleted file mode 100644 index 038fd8d96..000000000 --- a/packages/backend/src/server/api/common/signin.ts +++ /dev/null @@ -1,44 +0,0 @@ -import Koa from 'koa'; - -import config from '@/config/index.js'; -import { ILocalUser } from '@/models/entities/user.js'; -import { Signins } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; -import { publishMainStream } from '@/services/stream.js'; - -export default function(ctx: Koa.Context, user: ILocalUser, redirect = false) { - if (redirect) { - //#region Cookie - ctx.cookies.set('igi', user.token!, { - path: '/', - // SEE: https://github.com/koajs/koa/issues/974 - // When using a SSL proxy it should be configured to add the "X-Forwarded-Proto: https" header - secure: config.url.startsWith('https'), - httpOnly: false, - }); - //#endregion - - ctx.redirect(config.url); - } else { - ctx.body = { - id: user.id, - i: user.token, - }; - ctx.status = 200; - } - - (async () => { - // Append signin history - const record = await Signins.insert({ - id: genId(), - createdAt: new Date(), - userId: user.id, - ip: ctx.ip, - headers: ctx.headers, - success: true, - }).then(x => Signins.findOneByOrFail(x.identifiers[0])); - - // Publish signin event - publishMainStream(user.id, 'signin', await Signins.pack(record)); - })(); -} diff --git a/packages/backend/src/server/api/common/signup.ts b/packages/backend/src/server/api/common/signup.ts deleted file mode 100644 index abc142472..000000000 --- a/packages/backend/src/server/api/common/signup.ts +++ /dev/null @@ -1,114 +0,0 @@ -import bcrypt from 'bcryptjs'; -import { generateKeyPair } from 'node:crypto'; -import generateUserToken from './generate-native-user-token.js'; -import { User } from '@/models/entities/user.js'; -import { Users, UsedUsernames } from '@/models/index.js'; -import { UserProfile } from '@/models/entities/user-profile.js'; -import { IsNull } from 'typeorm'; -import { genId } from '@/misc/gen-id.js'; -import { toPunyNullable } from '@/misc/convert-host.js'; -import { UserKeypair } from '@/models/entities/user-keypair.js'; -import { usersChart } from '@/services/chart/index.js'; -import { UsedUsername } from '@/models/entities/used-username.js'; -import { db } from '@/db/postgre.js'; - -export async function signup(opts: { - username: User['username']; - password?: string | null; - passwordHash?: UserProfile['password'] | null; - host?: string | null; -}) { - const { username, password, passwordHash, host } = opts; - let hash = passwordHash; - - // Validate username - if (!Users.validateLocalUsername(username)) { - throw new Error('INVALID_USERNAME'); - } - - if (password != null && passwordHash == null) { - // Validate password - if (!Users.validatePassword(password)) { - throw new Error('INVALID_PASSWORD'); - } - - // Generate hash of password - const salt = await bcrypt.genSalt(8); - hash = await bcrypt.hash(password, salt); - } - - // Generate secret - const secret = generateUserToken(); - - // Check username duplication - if (await Users.findOneBy({ usernameLower: username.toLowerCase(), host: IsNull() })) { - throw new Error('DUPLICATED_USERNAME'); - } - - // Check deleted username duplication - if (await UsedUsernames.findOneBy({ username: username.toLowerCase() })) { - throw new Error('USED_USERNAME'); - } - - const keyPair = await new Promise((res, rej) => - generateKeyPair('rsa', { - modulusLength: 4096, - publicKeyEncoding: { - type: 'spki', - format: 'pem', - }, - privateKeyEncoding: { - type: 'pkcs8', - format: 'pem', - cipher: undefined, - passphrase: undefined, - }, - } as any, (err, publicKey, privateKey) => - err ? rej(err) : res([publicKey, privateKey]) - )); - - let account!: User; - - // Start transaction - await db.transaction(async transactionalEntityManager => { - const exist = await transactionalEntityManager.findOneBy(User, { - usernameLower: username.toLowerCase(), - host: IsNull(), - }); - - if (exist) throw new Error(' the username is already used'); - - account = await transactionalEntityManager.save(new User({ - id: genId(), - createdAt: new Date(), - username: username, - usernameLower: username.toLowerCase(), - host: toPunyNullable(host), - token: secret, - isAdmin: (await Users.countBy({ - host: IsNull(), - })) === 0, - })); - - await transactionalEntityManager.save(new UserKeypair({ - publicKey: keyPair[0], - privateKey: keyPair[1], - userId: account.id, - })); - - await transactionalEntityManager.save(new UserProfile({ - userId: account.id, - autoAcceptFollowed: true, - password: hash, - })); - - await transactionalEntityManager.save(new UsedUsername({ - createdAt: new Date(), - username: username.toLowerCase(), - })); - }); - - usersChart.update(account, true); - - return { account, secret }; -} diff --git a/packages/backend/src/server/api/define.ts b/packages/backend/src/server/api/define.ts deleted file mode 100644 index c1b56b8a8..000000000 --- a/packages/backend/src/server/api/define.ts +++ /dev/null @@ -1,59 +0,0 @@ -import * as fs from 'node:fs'; -import Ajv from 'ajv'; -import { CacheableLocalUser, ILocalUser } from '@/models/entities/user.js'; -import { Schema, SchemaType } from '@/misc/schema.js'; -import { AccessToken } from '@/models/entities/access-token.js'; -import { IEndpointMeta } from './endpoints.js'; -import { ApiError } from './error.js'; - -export type Response = Record | void; - -// TODO: paramsの型をT['params']のスキーマ定義から推論する -type executor = - (params: SchemaType, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, cleanup?: () => any, ip?: string | null, headers?: Record | null) => - Promise>>; - -const ajv = new Ajv({ - useDefaults: true, -}); - -ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/); - -export default function (meta: T, paramDef: Ps, cb: executor) - : (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, ip?: string | null, headers?: Record | null) => Promise { - const validate = ajv.compile(paramDef); - - return (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, ip?: string | null, headers?: Record | null) => { - let cleanup: undefined | (() => void) = undefined; - - if (meta.requireFile) { - cleanup = () => { - fs.unlink(file.path, () => {}); - }; - - if (file == null) return Promise.reject(new ApiError({ - message: 'File required.', - code: 'FILE_REQUIRED', - id: '4267801e-70d1-416a-b011-4ee502885d8b', - })); - } - - const valid = validate(params); - if (!valid) { - if (file) cleanup!(); - - const errors = validate.errors!; - const err = new ApiError({ - message: 'Invalid param.', - code: 'INVALID_PARAM', - id: '3d81ceae-475f-4600-b2a8-2bc116157532', - }, { - param: errors[0].schemaPath, - reason: errors[0].message, - }); - return Promise.reject(err); - } - - return cb(params as SchemaType, user, token, file, cleanup, ip, headers); - }; -} diff --git a/packages/backend/src/server/api/endpoint-base.ts b/packages/backend/src/server/api/endpoint-base.ts new file mode 100644 index 000000000..0a7f9b300 --- /dev/null +++ b/packages/backend/src/server/api/endpoint-base.ts @@ -0,0 +1,62 @@ +import * as fs from 'node:fs'; +import Ajv from 'ajv'; +import type { Schema, SchemaType } from '@/misc/schema.js'; +import type { CacheableLocalUser } from '@/models/entities/User.js'; +import type { AccessToken } from '@/models/entities/AccessToken.js'; +import { ApiError } from './error.js'; +import type { IEndpointMeta } from './endpoints.js'; + +const ajv = new Ajv({ + useDefaults: true, +}); + +ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/); + +export type Response = Record | void; + +// TODO: paramsの型をT['params']のスキーマ定義から推論する +type executor = + (params: SchemaType, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, cleanup?: () => any, ip?: string | null, headers?: Record | null) => + Promise>>; + +export abstract class Endpoint { + public exec: (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, ip?: string | null, headers?: Record | null) => Promise; + + constructor(meta: T, paramDef: Ps, cb: executor) { + const validate = ajv.compile(paramDef); + + this.exec = (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, ip?: string | null, headers?: Record | null) => { + let cleanup: undefined | (() => void) = undefined; + + if (meta.requireFile) { + cleanup = () => { + fs.unlink(file.path, () => {}); + }; + + if (file == null) return Promise.reject(new ApiError({ + message: 'File required.', + code: 'FILE_REQUIRED', + id: '4267801e-70d1-416a-b011-4ee502885d8b', + })); + } + + const valid = validate(params); + if (!valid) { + if (file) cleanup!(); + + const errors = validate.errors!; + const err = new ApiError({ + message: 'Invalid param.', + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + }, { + param: errors[0].schemaPath, + reason: errors[0].message, + }); + return Promise.reject(err); + } + + return cb(params as SchemaType, user, token, file, cleanup, ip, headers); + }; + } +} diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 4644f34d9..a05bb5a7e 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -1,4 +1,4 @@ -import { Schema } from '@/misc/schema.js'; +import type { Schema } from '@/misc/schema.js'; import * as ep___admin_meta from './endpoints/admin/meta.js'; import * as ep___admin_abuseUserReports from './endpoints/admin/abuse-user-reports.js'; @@ -59,7 +59,6 @@ import * as ep___admin_suspendUser from './endpoints/admin/suspend-user.js'; import * as ep___admin_unsilenceUser from './endpoints/admin/unsilence-user.js'; import * as ep___admin_unsuspendUser from './endpoints/admin/unsuspend-user.js'; import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js'; -import * as ep___admin_vacuum from './endpoints/admin/vacuum.js'; import * as ep___admin_deleteAccount from './endpoints/admin/delete-account.js'; import * as ep___admin_updateUserNote from './endpoints/admin/update-user-note.js'; import * as ep___announcements from './endpoints/announcements.js'; @@ -253,8 +252,6 @@ import * as ep___notes_timeline from './endpoints/notes/timeline.js'; import * as ep___notes_translate from './endpoints/notes/translate.js'; import * as ep___notes_unrenote from './endpoints/notes/unrenote.js'; import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js'; -import * as ep___notes_watching_create from './endpoints/notes/watching/create.js'; -import * as ep___notes_watching_delete from './endpoints/notes/watching/delete.js'; import * as ep___notifications_create from './endpoints/notifications/create.js'; import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js'; import * as ep___notifications_read from './endpoints/notifications/read.js'; @@ -376,7 +373,6 @@ const eps = [ ['admin/unsilence-user', ep___admin_unsilenceUser], ['admin/unsuspend-user', ep___admin_unsuspendUser], ['admin/update-meta', ep___admin_updateMeta], - ['admin/vacuum', ep___admin_vacuum], ['admin/delete-account', ep___admin_deleteAccount], ['admin/update-user-note', ep___admin_updateUserNote], ['announcements', ep___announcements], @@ -570,8 +566,6 @@ const eps = [ ['notes/translate', ep___notes_translate], ['notes/unrenote', ep___notes_unrenote], ['notes/user-list-timeline', ep___notes_userListTimeline], - ['notes/watching/create', ep___notes_watching_create], - ['notes/watching/delete', ep___notes_watching_delete], ['notifications/create', ep___notifications_create], ['notifications/mark-all-as-read', ep___notifications_markAllAsRead], ['notifications/read', ep___notifications_read], @@ -727,7 +721,6 @@ export interface IEndpointMeta { export interface IEndpoint { name: string; - exec: any; meta: IEndpointMeta; params: Schema; } @@ -735,7 +728,6 @@ export interface IEndpoint { const endpoints: IEndpoint[] = eps.map(([name, ep]) => { return { name: name, - exec: ep.default, meta: ep.meta || {}, params: ep.paramDef, }; diff --git a/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts b/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts index 333746f42..480ae7166 100644 --- a/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts +++ b/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts @@ -1,6 +1,8 @@ -import define from '../../define.js'; -import { AbuseUserReports } from '@/models/index.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AbuseUserReportsRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -77,33 +79,43 @@ export const paramDef = { sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, state: { type: 'string', nullable: true, default: null }, - reporterOrigin: { type: 'string', enum: ['combined', 'local', 'remote'], default: "combined" }, - targetUserOrigin: { type: 'string', enum: ['combined', 'local', 'remote'], default: "combined" }, + reporterOrigin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'combined' }, + targetUserOrigin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'combined' }, forwarded: { type: 'boolean', default: false }, }, required: [], } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - const query = makePaginationQuery(AbuseUserReports.createQueryBuilder('report'), ps.sinceId, ps.untilId); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.abuseUserReportsRepository) + private abuseUserReportsRepository: AbuseUserReportsRepository, - switch (ps.state) { - case 'resolved': query.andWhere('report.resolved = TRUE'); break; - case 'unresolved': query.andWhere('report.resolved = FALSE'); break; + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.abuseUserReportsRepository.createQueryBuilder('report'), ps.sinceId, ps.untilId); + + switch (ps.state) { + case 'resolved': query.andWhere('report.resolved = TRUE'); break; + case 'unresolved': query.andWhere('report.resolved = FALSE'); break; + } + + switch (ps.reporterOrigin) { + case 'local': query.andWhere('report.reporterHost IS NULL'); break; + case 'remote': query.andWhere('report.reporterHost IS NOT NULL'); break; + } + + switch (ps.targetUserOrigin) { + case 'local': query.andWhere('report.targetUserHost IS NULL'); break; + case 'remote': query.andWhere('report.targetUserHost IS NOT NULL'); break; + } + + const reports = await query.take(ps.limit).getMany(); + + return await this.abuseUserReportEntityService.packMany(reports); + }); } - - switch (ps.reporterOrigin) { - case 'local': query.andWhere('report.reporterHost IS NULL'); break; - case 'remote': query.andWhere('report.reporterHost IS NOT NULL'); break; - } - - switch (ps.targetUserOrigin) { - case 'local': query.andWhere('report.targetUserHost IS NULL'); break; - case 'remote': query.andWhere('report.targetUserHost IS NOT NULL'); break; - } - - const reports = await query.take(ps.limit).getMany(); - - return await AbuseUserReports.packMany(reports); -}); +} diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts index 5f8921999..1b173379a 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts @@ -1,7 +1,11 @@ -import define from '../../../define.js'; -import { Users } from '@/models/index.js'; -import { signup } from '../../../common/signup.js'; +import { Inject, Injectable } from '@nestjs/common'; import { IsNull } from 'typeorm'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UsersRepository } from '@/models/index.js'; +import { SignupService } from '@/core/SignupService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { localUsernameSchema, passwordSchema } from '@/models/entities/User.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -22,31 +26,42 @@ export const meta = { export const paramDef = { type: 'object', properties: { - username: Users.localUsernameSchema, - password: Users.passwordSchema, + username: localUsernameSchema, + password: passwordSchema, }, required: ['username', 'password'], } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, _me) => { - const me = _me ? await Users.findOneByOrFail({ id: _me.id }) : null; - const noUsers = (await Users.countBy({ - host: IsNull(), - })) === 0; - if (!noUsers && !me?.isAdmin) throw new Error('access denied'); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - const { account, secret } = await signup({ - username: ps.username, - password: ps.password, - }); + private userEntityService: UserEntityService, + private signupService: SignupService, + ) { + super(meta, paramDef, async (ps, _me) => { + const me = _me ? await this.usersRepository.findOneByOrFail({ id: _me.id }) : null; + const noUsers = (await this.usersRepository.countBy({ + host: IsNull(), + })) === 0; + if (!noUsers && !me?.isAdmin) throw new Error('access denied'); - const res = await Users.pack(account, account, { - detail: true, - includeSecrets: true, - }); + const { account, secret } = await this.signupService.signup({ + username: ps.username, + password: ps.password, + }); - (res as any).token = secret; + const res = await this.userEntityService.pack(account, account, { + detail: true, + includeSecrets: true, + }); - return res; -}); + (res as any).token = secret; + + return res; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts index 629d70058..2e0222f0c 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts @@ -1,8 +1,10 @@ -import define from '../../../define.js'; -import { Users } from '@/models/index.js'; -import { doPostSuspend } from '@/services/suspend-user.js'; -import { publishUserEvent } from '@/services/stream.js'; -import { createDeleteAccountJob } from '@/queue/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UsersRepository } from '@/models/index.js'; +import { QueueService } from '@/core/QueueService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { UserSuspendService } from '@/core/UserSuspendService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -20,40 +22,52 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const user = await Users.findOneBy({ id: ps.userId }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - if (user == null) { - throw new Error('user not found'); - } + private queueService: QueueService, + private globalEventService: GlobalEventService, + private userSuspendService: UserSuspendService, + ) { + super(meta, paramDef, async (ps, me) => { + const user = await this.usersRepository.findOneBy({ id: ps.userId }); - if (user.isAdmin) { - throw new Error('cannot suspend admin'); - } + if (user == null) { + throw new Error('user not found'); + } - if (user.isModerator) { - throw new Error('cannot suspend moderator'); - } + if (user.isAdmin) { + throw new Error('cannot suspend admin'); + } - if (Users.isLocalUser(user)) { - // 物理削除する前にDelete activityを送信する - await doPostSuspend(user).catch(e => {}); + if (user.isModerator) { + throw new Error('cannot suspend moderator'); + } - createDeleteAccountJob(user, { - soft: false, - }); - } else { - createDeleteAccountJob(user, { - soft: true, // リモートユーザーの削除は、完全にDBから物理削除してしまうと再度連合してきてアカウントが復活する可能性があるため、soft指定する + if (this.userEntityService.isLocalUser(user)) { + // 物理削除する前にDelete activityを送信する + await this.userSuspendService.doPostSuspend(user).catch(err => {}); + + this.queueService.createDeleteAccountJob(user, { + soft: false, + }); + } else { + this.queueService.createDeleteAccountJob(user, { + soft: true, // リモートユーザーの削除は、完全にDBから物理削除してしまうと再度連合してきてアカウントが復活する可能性があるため、soft指定する + }); + } + + await this.usersRepository.update(user.id, { + isDeleted: true, + }); + + if (this.userEntityService.isLocalUser(user)) { + // Terminate streaming + this.globalEventService.publishUserEvent(user.id, 'terminate', {}); + } }); } - - await Users.update(user.id, { - isDeleted: true, - }); - - if (Users.isLocalUser(user)) { - // Terminate streaming - publishUserEvent(user.id, 'terminate', {}); - } -}); +} diff --git a/packages/backend/src/server/api/endpoints/admin/ad/create.ts b/packages/backend/src/server/api/endpoints/admin/ad/create.ts index ab2c50b50..6b32391e8 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/create.ts @@ -1,6 +1,8 @@ -import define from '../../../define.js'; -import { Ads } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AdsRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -24,16 +26,26 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - await Ads.insert({ - id: genId(), - createdAt: new Date(), - expiresAt: new Date(ps.expiresAt), - url: ps.url, - imageUrl: ps.imageUrl, - priority: ps.priority, - ratio: ps.ratio, - place: ps.place, - memo: ps.memo, - }); -}); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.adsRepository) + private adsRepository: AdsRepository, + + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.adsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + expiresAt: new Date(ps.expiresAt), + url: ps.url, + imageUrl: ps.imageUrl, + priority: ps.priority, + ratio: ps.ratio, + place: ps.place, + memo: ps.memo, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/ad/delete.ts b/packages/backend/src/server/api/endpoints/admin/ad/delete.ts index 0ead2be00..7abefe156 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/delete.ts @@ -1,5 +1,7 @@ -import define from '../../../define.js'; -import { Ads } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AdsRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -26,10 +28,18 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const ad = await Ads.findOneBy({ id: ps.id }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.adsRepository) + private adsRepository: AdsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const ad = await this.adsRepository.findOneBy({ id: ps.id }); - if (ad == null) throw new ApiError(meta.errors.noSuchAd); + if (ad == null) throw new ApiError(meta.errors.noSuchAd); - await Ads.delete(ad.id); -}); + await this.adsRepository.delete(ad.id); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/ad/list.ts b/packages/backend/src/server/api/endpoints/admin/ad/list.ts index 74f154f27..efece31bb 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/list.ts @@ -1,6 +1,8 @@ -import define from '../../../define.js'; -import { Ads } from '@/models/index.js'; -import { makePaginationQuery } from '../../../common/make-pagination-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AdsRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -20,11 +22,21 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - const query = makePaginationQuery(Ads.createQueryBuilder('ad'), ps.sinceId, ps.untilId) - .andWhere('ad.expiresAt > :now', { now: new Date() }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.adsRepository) + private adsRepository: AdsRepository, - const ads = await query.take(ps.limit).getMany(); + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.adsRepository.createQueryBuilder('ad'), ps.sinceId, ps.untilId) + .andWhere('ad.expiresAt > :now', { now: new Date() }); - return ads; -}); + const ads = await query.take(ps.limit).getMany(); + + return ads; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/ad/update.ts b/packages/backend/src/server/api/endpoints/admin/ad/update.ts index 650f8670e..098a59337 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/update.ts @@ -1,5 +1,7 @@ -import define from '../../../define.js'; -import { Ads } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AdsRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -33,18 +35,26 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const ad = await Ads.findOneBy({ id: ps.id }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private adsRepository: AdsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const ad = await this.adsRepository.findOneBy({ id: ps.id }); - if (ad == null) throw new ApiError(meta.errors.noSuchAd); + if (ad == null) throw new ApiError(meta.errors.noSuchAd); - await Ads.update(ad.id, { - url: ps.url, - place: ps.place, - priority: ps.priority, - ratio: ps.ratio, - memo: ps.memo, - imageUrl: ps.imageUrl, - expiresAt: new Date(ps.expiresAt), - }); -}); + await this.adsRepository.update(ad.id, { + url: ps.url, + place: ps.place, + priority: ps.priority, + ratio: ps.ratio, + memo: ps.memo, + imageUrl: ps.imageUrl, + expiresAt: new Date(ps.expiresAt), + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts index 33076b6d3..ee07170d6 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts @@ -1,6 +1,8 @@ -import define from '../../../define.js'; -import { Announcements } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AnnouncementsRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -55,15 +57,25 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - const announcement = await Announcements.insert({ - id: genId(), - createdAt: new Date(), - updatedAt: null, - title: ps.title, - text: ps.text, - imageUrl: ps.imageUrl, - }).then(x => Announcements.findOneByOrFail(x.identifiers[0])); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.announcementsRepository) + private announcementsRepository: AnnouncementsRepository, - return Object.assign({}, announcement, { createdAt: announcement.createdAt.toISOString(), updatedAt: null }); -}); + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const announcement = await this.announcementsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + updatedAt: null, + title: ps.title, + text: ps.text, + imageUrl: ps.imageUrl, + }).then(x => this.announcementsRepository.findOneByOrFail(x.identifiers[0])); + + return Object.assign({}, announcement, { createdAt: announcement.createdAt.toISOString(), updatedAt: null }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/delete.ts b/packages/backend/src/server/api/endpoints/admin/announcements/delete.ts index c17765f4f..9a67bdb1a 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/delete.ts @@ -1,5 +1,7 @@ -import define from '../../../define.js'; -import { Announcements } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AnnouncementsRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -26,10 +28,18 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const announcement = await Announcements.findOneBy({ id: ps.id }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.announcementsRepository) + private announcementsRepository: AnnouncementsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const announcement = await this.announcementsRepository.findOneBy({ id: ps.id }); - if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement); + if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement); - await Announcements.delete(announcement.id); -}); + await this.announcementsRepository.delete(announcement.id); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts index 7a5758d75..35c14abda 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts @@ -1,7 +1,9 @@ -import { Announcements, AnnouncementReads } from '@/models/index.js'; -import { Announcement } from '@/models/entities/announcement.js'; -import define from '../../../define.js'; -import { makePaginationQuery } from '../../../common/make-pagination-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { AnnouncementsRepository, AnnouncementReadsRepository } from '@/models/index.js'; +import type { Announcement } from '@/models/entities/Announcement.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -64,26 +66,39 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - const query = makePaginationQuery(Announcements.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.announcementsRepository) + private announcementsRepository: AnnouncementsRepository, - const announcements = await query.take(ps.limit).getMany(); + @Inject(DI.announcementReadsRepository) + private announcementReadsRepository: AnnouncementReadsRepository, - const reads = new Map(); + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); - for (const announcement of announcements) { - reads.set(announcement, await AnnouncementReads.countBy({ - announcementId: announcement.id, - })); + const announcements = await query.take(ps.limit).getMany(); + + const reads = new Map(); + + for (const announcement of announcements) { + reads.set(announcement, await this.announcementReadsRepository.countBy({ + announcementId: announcement.id, + })); + } + + return announcements.map(announcement => ({ + id: announcement.id, + createdAt: announcement.createdAt.toISOString(), + updatedAt: announcement.updatedAt?.toISOString() ?? null, + title: announcement.title, + text: announcement.text, + imageUrl: announcement.imageUrl, + reads: reads.get(announcement)!, + })); + }); } - - return announcements.map(announcement => ({ - id: announcement.id, - createdAt: announcement.createdAt.toISOString(), - updatedAt: announcement.updatedAt?.toISOString() ?? null, - title: announcement.title, - text: announcement.text, - imageUrl: announcement.imageUrl, - reads: reads.get(announcement)!, - })); -}); +} diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts index 61ce106d8..38358dff1 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts @@ -1,5 +1,7 @@ -import define from '../../../define.js'; -import { Announcements } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AnnouncementsRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -29,15 +31,23 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const announcement = await Announcements.findOneBy({ id: ps.id }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.announcementsRepository) + private announcementsRepository: AnnouncementsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const announcement = await this.announcementsRepository.findOneBy({ id: ps.id }); - if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement); + if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement); - await Announcements.update(announcement.id, { - updatedAt: new Date(), - title: ps.title, - text: ps.text, - imageUrl: ps.imageUrl, - }); -}); + await this.announcementsRepository.update(announcement.id, { + updatedAt: new Date(), + title: ps.title, + text: ps.text, + imageUrl: ps.imageUrl, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/delete-account.ts b/packages/backend/src/server/api/endpoints/admin/delete-account.ts index 2d7ef2f23..c8b67fe1c 100644 --- a/packages/backend/src/server/api/endpoints/admin/delete-account.ts +++ b/packages/backend/src/server/api/endpoints/admin/delete-account.ts @@ -1,6 +1,8 @@ -import { Users } from '@/models/index.js'; -import { deleteAccount } from '@/services/delete-account.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UsersRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DeleteAccountService } from '@/core/DeleteAccountService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -21,11 +23,21 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - const user = await Users.findOneByOrFail({ id: ps.userId }); - if (user.isDeleted) { - return; - } +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - await deleteAccount(user); -}); + private deleteAccountService: DeleteAccountService, + ) { + super(meta, paramDef, async (ps) => { + const user = await this.usersRepository.findOneByOrFail({ id: ps.userId }); + if (user.isDeleted) { + return; + } + + await this.deleteAccountService.deleteAccount(user); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts b/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts index dc1976624..051e4c60f 100644 --- a/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts @@ -1,6 +1,8 @@ -import define from '../../define.js'; -import { deleteFile } from '@/services/drive/delete-file.js'; -import { DriveFiles } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFilesRepository } from '@/models/index.js'; +import { DriveService } from '@/core/DriveService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -18,12 +20,22 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const files = await DriveFiles.findBy({ - userId: ps.userId, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, - for (const file of files) { - deleteFile(file); + private driveService: DriveService, + ) { + super(meta, paramDef, async (ps, me) => { + const files = await this.driveFilesRepository.findBy({ + userId: ps.userId, + }); + + for (const file of files) { + this.driveService.deleteFile(file); + } + }); } -}); +} diff --git a/packages/backend/src/server/api/endpoints/admin/drive-capacity-override.ts b/packages/backend/src/server/api/endpoints/admin/drive-capacity-override.ts index a4b29770e..770bade06 100644 --- a/packages/backend/src/server/api/endpoints/admin/drive-capacity-override.ts +++ b/packages/backend/src/server/api/endpoints/admin/drive-capacity-override.ts @@ -1,7 +1,9 @@ -import define from '../../define.js'; -import { Users } from '@/models/index.js'; -import { User } from '@/models/entities/user.js'; -import { insertModerationLog } from '@/services/insert-moderation-log.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UsersRepository } from '@/models/index.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { DI } from '@/di-symbols.js'; + export const meta = { tags: ['admin'], @@ -19,29 +21,39 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const user = await Users.findOneBy({ id: ps.userId }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - if (user == null) { - throw new Error('user not found'); - } + private moderationLogService: ModerationLogService, + ) { + super(meta, paramDef, async (ps, me) => { + const user = await this.usersRepository.findOneBy({ id: ps.userId }); - if (!Users.isLocalUser(user)) { - throw new Error('user is not local user'); - } + if (user == null) { + throw new Error('user not found'); + } - /*if (user.isAdmin) { + if (!this.userEntityService.isLocalUser(user)) { + throw new Error('user is not local user'); + } + + /*if (user.isAdmin) { throw new Error('cannot suspend admin'); } if (user.isModerator) { throw new Error('cannot suspend moderator'); }*/ - await Users.update(user.id, { - driveCapacityOverrideMb: ps.overrideMb, - }); + await this.usersRepository.update(user.id, { + driveCapacityOverrideMb: ps.overrideMb, + }); - insertModerationLog(me, 'change-drive-capacity-override', { - targetId: user.id, - }); -}); + this.moderationLogService.insertModerationLog(me, 'change-drive-capacity-override', { + targetId: user.id, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts b/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts index bab149532..2cc4e70e5 100644 --- a/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts +++ b/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts @@ -1,5 +1,6 @@ -import define from '../../../define.js'; -import { createCleanRemoteFilesJob } from '@/queue/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueueService } from '@/core/QueueService.js'; export const meta = { tags: ['admin'], @@ -15,6 +16,13 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - createCleanRemoteFilesJob(); -}); +@Injectable() +export default class extends Endpoint { + constructor( + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + this.queueService.createCleanRemoteFilesJob(); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/drive/cleanup.ts b/packages/backend/src/server/api/endpoints/admin/drive/cleanup.ts index 3db942e6c..3927a89f9 100644 --- a/packages/backend/src/server/api/endpoints/admin/drive/cleanup.ts +++ b/packages/backend/src/server/api/endpoints/admin/drive/cleanup.ts @@ -1,7 +1,9 @@ import { IsNull } from 'typeorm'; -import define from '../../../define.js'; -import { deleteFile } from '@/services/drive/delete-file.js'; -import { DriveFiles } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFilesRepository } from '@/models/index.js'; +import { DriveService } from '@/core/DriveService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -17,12 +19,22 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const files = await DriveFiles.findBy({ - userId: IsNull(), - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, - for (const file of files) { - deleteFile(file); + private driveService: DriveService, + ) { + super(meta, paramDef, async (ps, me) => { + const files = await this.driveFilesRepository.findBy({ + userId: IsNull(), + }); + + for (const file of files) { + this.driveService.deleteFile(file); + } + }); } -}); +} diff --git a/packages/backend/src/server/api/endpoints/admin/drive/files.ts b/packages/backend/src/server/api/endpoints/admin/drive/files.ts index ba32aac43..88529ab0a 100644 --- a/packages/backend/src/server/api/endpoints/admin/drive/files.ts +++ b/packages/backend/src/server/api/endpoints/admin/drive/files.ts @@ -1,6 +1,8 @@ -import { DriveFiles } from '@/models/index.js'; -import define from '../../../define.js'; -import { makePaginationQuery } from '../../../common/make-pagination-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { DriveFilesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -39,32 +41,42 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const query = makePaginationQuery(DriveFiles.createQueryBuilder('file'), ps.sinceId, ps.untilId); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, - if (ps.userId) { - query.andWhere('file.userId = :userId', { userId: ps.userId }); - } else { - if (ps.origin === 'local') { - query.andWhere('file.userHost IS NULL'); - } else if (ps.origin === 'remote') { - query.andWhere('file.userHost IS NOT NULL'); - } + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.driveFilesRepository.createQueryBuilder('file'), ps.sinceId, ps.untilId); - if (ps.hostname) { - query.andWhere('file.userHost = :hostname', { hostname: ps.hostname }); - } + if (ps.userId) { + query.andWhere('file.userId = :userId', { userId: ps.userId }); + } else { + if (ps.origin === 'local') { + query.andWhere('file.userHost IS NULL'); + } else if (ps.origin === 'remote') { + query.andWhere('file.userHost IS NOT NULL'); + } + + if (ps.hostname) { + query.andWhere('file.userHost = :hostname', { hostname: ps.hostname }); + } + } + + if (ps.type) { + if (ps.type.endsWith('/*')) { + query.andWhere('file.type like :type', { type: ps.type.replace('/*', '/') + '%' }); + } else { + query.andWhere('file.type = :type', { type: ps.type }); + } + } + + const files = await query.take(ps.limit).getMany(); + + return await this.driveFileEntityService.packMany(files, { detail: true, withUser: true, self: true }); + }); } - - if (ps.type) { - if (ps.type.endsWith('/*')) { - query.andWhere('file.type like :type', { type: ps.type.replace('/*', '/') + '%' }); - } else { - query.andWhere('file.type = :type', { type: ps.type }); - } - } - - const files = await query.take(ps.limit).getMany(); - - return await DriveFiles.packMany(files, { detail: true, withUser: true, self: true }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts index e9117a23c..45ea9cdb5 100644 --- a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts +++ b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts @@ -1,5 +1,7 @@ -import { DriveFiles } from '@/models/index.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { DriveFilesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -169,25 +171,33 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const file = ps.fileId ? await DriveFiles.findOneBy({ id: ps.fileId }) : await DriveFiles.findOne({ - where: [{ - url: ps.url, - }, { - thumbnailUrl: ps.url, - }, { - webpublicUrl: ps.url, - }], - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const file = ps.fileId ? await this.driveFilesRepository.findOneBy({ id: ps.fileId }) : await this.driveFilesRepository.findOne({ + where: [{ + url: ps.url, + }, { + thumbnailUrl: ps.url, + }, { + webpublicUrl: ps.url, + }], + }); - if (file == null) { - throw new ApiError(meta.errors.noSuchFile); + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + + if (!me.isAdmin) { + delete file.requestIp; + delete file.requestHeaders; + } + + return file; + }); } - - if (!me.isAdmin) { - delete file.requestIp; - delete file.requestHeaders; - } - - return file; -}); +} diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts index 232fbbd57..0b6e744ef 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts @@ -1,8 +1,8 @@ -import define from '../../../define.js'; -import { Emojis } from '@/models/index.js'; -import { In } from 'typeorm'; -import { ApiError } from '../../../error.js'; -import { db } from '@/db/postgre.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { DataSource, In } from 'typeorm'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { EmojisRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -24,18 +24,31 @@ export const paramDef = { required: ['ids', 'aliases'], } as const; -// eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - const emojis = await Emojis.findBy({ - id: In(ps.ids), - }); +// TODO: ロジックをサービスに切り出す - for (const emoji of emojis) { - await Emojis.update(emoji.id, { - updatedAt: new Date(), - aliases: [...new Set(emoji.aliases.concat(ps.aliases))], +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const emojis = await this.emojisRepository.findBy({ + id: In(ps.ids), + }); + + for (const emoji of emojis) { + await this.emojisRepository.update(emoji.id, { + updatedAt: new Date(), + aliases: [...new Set(emoji.aliases.concat(ps.aliases))], + }); + } + + await this.db.queryResultCache!.remove(['meta_emojis']); }); } - - await db.queryResultCache!.remove(['meta_emojis']); -}); +} 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 67349c24e..daa57e8eb 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts @@ -1,11 +1,14 @@ -import define from '../../../define.js'; -import { Emojis, DriveFiles } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; -import { insertModerationLog } from '@/services/insert-moderation-log.js'; -import { ApiError } from '../../../error.js'; +import { Inject, Injectable } from '@nestjs/common'; import rndstr from 'rndstr'; -import { publishBroadcastStream } from '@/services/stream.js'; -import { db } from '@/db/postgre.js'; +import { DataSource } from 'typeorm'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFilesRepository, EmojisRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { DI } from '@/di-symbols.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; +import { ApiError } from '../../../error.js'; export const meta = { tags: ['admin'], @@ -30,37 +33,58 @@ export const paramDef = { required: ['fileId'], } as const; +// TODO: ロジックをサービスに切り出す + // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const file = await DriveFiles.findOneBy({ id: ps.fileId }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.db) + private db: DataSource, - if (file == null) throw new ApiError(meta.errors.noSuchFile); + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, - const name = file.name.split('.')[0].match(/^[a-z0-9_]+$/) ? file.name.split('.')[0] : `_${rndstr('a-z0-9', 8)}_`; + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, - const emoji = await Emojis.insert({ - id: genId(), - updatedAt: new Date(), - name: name, - category: null, - host: null, - aliases: [], - originalUrl: file.url, - publicUrl: file.webpublicUrl ?? file.url, - type: file.webpublicType ?? file.type, - }).then(x => Emojis.findOneByOrFail(x.identifiers[0])); + private emojiEntityService: EmojiEntityService, + private idService: IdService, + private globalEventService: GlobalEventService, + private moderationLogService: ModerationLogService, + ) { + super(meta, paramDef, async (ps, me) => { + const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); - await db.queryResultCache!.remove(['meta_emojis']); + if (file == null) throw new ApiError(meta.errors.noSuchFile); - publishBroadcastStream('emojiAdded', { - emoji: await Emojis.pack(emoji.id), - }); + const name = file.name.split('.')[0].match(/^[a-z0-9_]+$/) ? file.name.split('.')[0] : `_${rndstr('a-z0-9', 8)}_`; - insertModerationLog(me, 'addEmoji', { - emojiId: emoji.id, - }); + const emoji = await this.emojisRepository.insert({ + id: this.idService.genId(), + updatedAt: new Date(), + name: name, + category: null, + host: null, + aliases: [], + originalUrl: file.url, + publicUrl: file.webpublicUrl ?? file.url, + type: file.webpublicType ?? file.type, + }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); - return { - id: emoji.id, - }; -}); + await this.db.queryResultCache!.remove(['meta_emojis']); + + this.globalEventService.publishBroadcastStream('emojiAdded', { + emoji: await this.emojiEntityService.pack(emoji.id), + }); + + this.moderationLogService.insertModerationLog(me, 'addEmoji', { + emojiId: emoji.id, + }); + + return { + id: emoji.id, + }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts index 7010ade0d..08d40834c 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts @@ -1,11 +1,14 @@ -import define from '../../../define.js'; -import { Emojis } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { EmojisRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import { DI } from '@/di-symbols.js'; +import { DriveService } from '@/core/DriveService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; import { ApiError } from '../../../error.js'; -import { DriveFile } from '@/models/entities/drive-file.js'; -import { uploadFromUrl } from '@/services/drive/upload-from-url.js'; -import { publishBroadcastStream } from '@/services/stream.js'; -import { db } from '@/db/postgre.js'; export const meta = { tags: ['admin'], @@ -42,41 +45,59 @@ export const paramDef = { required: ['emojiId'], } as const; +// TODO: ロジックをサービスに切り出す + // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const emoji = await Emojis.findOneBy({ id: ps.emojiId }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.db) + private db: DataSource, - if (emoji == null) { - throw new ApiError(meta.errors.noSuchEmoji); + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, + + private emojiEntityService: EmojiEntityService, + private idService: IdService, + private globalEventService: GlobalEventService, + private driveService: DriveService, + ) { + super(meta, paramDef, async (ps, me) => { + const emoji = await this.emojisRepository.findOneBy({ id: ps.emojiId }); + + if (emoji == null) { + throw new ApiError(meta.errors.noSuchEmoji); + } + + let driveFile: DriveFile; + + try { + // Create file + driveFile = await this.driveService.uploadFromUrl({ url: emoji.originalUrl, user: null, force: true }); + } catch (e) { + throw new ApiError(); + } + + const copied = await this.emojisRepository.insert({ + id: this.idService.genId(), + updatedAt: new Date(), + name: emoji.name, + host: null, + aliases: [], + originalUrl: driveFile.url, + publicUrl: driveFile.webpublicUrl ?? driveFile.url, + type: driveFile.webpublicType ?? driveFile.type, + }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); + + await this.db.queryResultCache!.remove(['meta_emojis']); + + this.globalEventService.publishBroadcastStream('emojiAdded', { + emoji: await this.emojiEntityService.pack(copied.id), + }); + + return { + id: copied.id, + }; + }); } - - let driveFile: DriveFile; - - try { - // Create file - driveFile = await uploadFromUrl({ url: emoji.originalUrl, user: null, force: true }); - } catch (e) { - throw new ApiError(); - } - - const copied = await Emojis.insert({ - id: genId(), - updatedAt: new Date(), - name: emoji.name, - host: null, - aliases: [], - originalUrl: driveFile.url, - publicUrl: driveFile.webpublicUrl ?? driveFile.url, - type: driveFile.webpublicType ?? driveFile.type, - }).then(x => Emojis.findOneByOrFail(x.identifiers[0])); - - await db.queryResultCache!.remove(['meta_emojis']); - - publishBroadcastStream('emojiAdded', { - emoji: await Emojis.pack(copied.id), - }); - - return { - id: copied.id, - }; -}); +} diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts index 93a6c4e4e..81b095cb5 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts @@ -1,9 +1,9 @@ -import define from '../../../define.js'; -import { Emojis } from '@/models/index.js'; -import { In } from 'typeorm'; -import { insertModerationLog } from '@/services/insert-moderation-log.js'; -import { ApiError } from '../../../error.js'; -import { db } from '@/db/postgre.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { DataSource, In } from 'typeorm'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { EmojisRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -22,19 +22,34 @@ export const paramDef = { required: ['ids'], } as const; -// eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const emojis = await Emojis.findBy({ - id: In(ps.ids), - }); +// TODO: ロジックをサービスに切り出す - for (const emoji of emojis) { - await Emojis.delete(emoji.id); +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, + + private moderationLogService: ModerationLogService, + ) { + super(meta, paramDef, async (ps, me) => { + const emojis = await this.emojisRepository.findBy({ + id: In(ps.ids), + }); + + for (const emoji of emojis) { + await this.emojisRepository.delete(emoji.id); - await db.queryResultCache!.remove(['meta_emojis']); + await this.db.queryResultCache!.remove(['meta_emojis']); - insertModerationLog(me, 'deleteEmoji', { - emoji: emoji, + this.moderationLogService.insertModerationLog(me, 'deleteEmoji', { + emoji: emoji, + }); + } }); } -}); +} 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 67dbf28d8..e4278dc33 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts @@ -1,8 +1,10 @@ -import define from '../../../define.js'; -import { Emojis } from '@/models/index.js'; -import { insertModerationLog } from '@/services/insert-moderation-log.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { EmojisRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; import { ApiError } from '../../../error.js'; -import { db } from '@/db/postgre.js'; export const meta = { tags: ['admin'], @@ -27,17 +29,32 @@ export const paramDef = { required: ['id'], } as const; +// TODO: ロジックをサービスに切り出す + // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const emoji = await Emojis.findOneBy({ id: ps.id }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.db) + private db: DataSource, - if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, - await Emojis.delete(emoji.id); + private moderationLogService: ModerationLogService, + ) { + super(meta, paramDef, async (ps, me) => { + const emoji = await this.emojisRepository.findOneBy({ id: ps.id }); - await db.queryResultCache!.remove(['meta_emojis']); + if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); - insertModerationLog(me, 'deleteEmoji', { - emoji: emoji, - }); -}); + await this.emojisRepository.delete(emoji.id); + + await this.db.queryResultCache!.remove(['meta_emojis']); + + this.moderationLogService.insertModerationLog(me, 'deleteEmoji', { + emoji: emoji, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts b/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts index 3f03dc2da..6fe492cb7 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts @@ -1,6 +1,6 @@ -import define from '../../../define.js'; -import { createImportCustomEmojisJob } from '@/queue/index.js'; -import ms from 'ms'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueueService } from '@/core/QueueService.js'; export const meta = { secure: true, @@ -17,6 +17,13 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - createImportCustomEmojisJob(user, ps.fileId); -}); +@Injectable() +export default class extends Endpoint { + constructor( + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + this.queueService.createImportCustomEmojisJob(me, ps.fileId); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts index d16689a28..9d6fa5341 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts @@ -1,7 +1,10 @@ -import define from '../../../define.js'; -import { Emojis } from '@/models/index.js'; -import { toPuny } from '@/misc/convert-host.js'; -import { makePaginationQuery } from '../../../common/make-pagination-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { EmojisRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -69,23 +72,35 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - const q = makePaginationQuery(Emojis.createQueryBuilder('emoji'), ps.sinceId, ps.untilId); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, - if (ps.host == null) { - q.andWhere(`emoji.host IS NOT NULL`); - } else { - q.andWhere(`emoji.host = :host`, { host: toPuny(ps.host) }); + private utilityService: UtilityService, + private queryService: QueryService, + private emojiEntityService: EmojiEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const q = this.queryService.makePaginationQuery(this.emojisRepository.createQueryBuilder('emoji'), ps.sinceId, ps.untilId); + + if (ps.host == null) { + q.andWhere('emoji.host IS NOT NULL'); + } else { + q.andWhere('emoji.host = :host', { host: this.utilityService.toPuny(ps.host) }); + } + + if (ps.query) { + q.andWhere('emoji.name like :query', { query: '%' + ps.query + '%' }); + } + + const emojis = await q + .orderBy('emoji.id', 'DESC') + .take(ps.limit) + .getMany(); + + return this.emojiEntityService.packMany(emojis); + }); } - - if (ps.query) { - q.andWhere('emoji.name like :query', { query: '%' + ps.query + '%' }); - } - - const emojis = await q - .orderBy('emoji.id', 'DESC') - .take(ps.limit) - .getMany(); - - return Emojis.packMany(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 6192978fa..736d664cc 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts @@ -1,7 +1,9 @@ -import define from '../../../define.js'; -import { Emojis } from '@/models/index.js'; -import { makePaginationQuery } from '../../../common/make-pagination-query.js'; -import { Emoji } from '@/models/entities/emoji.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { EmojisRepository } from '@/models/index.js'; +import type { Emoji } from '@/models/entities/Emoji.js'; +import { QueryService } from '@/core/QueryService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -63,27 +65,37 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - const q = makePaginationQuery(Emojis.createQueryBuilder('emoji'), ps.sinceId, ps.untilId) - .andWhere(`emoji.host IS NULL`); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, - let emojis: Emoji[]; + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const q = this.queryService.makePaginationQuery(this.emojisRepository.createQueryBuilder('emoji'), ps.sinceId, ps.untilId) + .andWhere('emoji.host IS NULL'); - if (ps.query) { - //q.andWhere('emoji.name ILIKE :q', { q: `%${ps.query}%` }); - //const emojis = await q.take(ps.limit).getMany(); + let emojis: Emoji[]; - emojis = await q.getMany(); + if (ps.query) { + //q.andWhere('emoji.name ILIKE :q', { q: `%${ps.query}%` }); + //const emojis = await q.take(ps.limit).getMany(); - emojis = emojis.filter(emoji => - emoji.name.includes(ps.query!) || - emoji.aliases.some(a => a.includes(ps.query!)) || - emoji.category?.includes(ps.query!)); + emojis = await q.getMany(); - emojis.splice(ps.limit + 1); - } else { - emojis = await q.take(ps.limit).getMany(); + 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.take(ps.limit).getMany(); + } + + return this.emojiEntityService.packMany(emojis); + }); } - - return Emojis.packMany(emojis); -}); +} diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts index a4da40fff..d6c70eaae 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts @@ -1,8 +1,8 @@ -import define from '../../../define.js'; -import { Emojis } from '@/models/index.js'; -import { In } from 'typeorm'; -import { ApiError } from '../../../error.js'; -import { db } from '@/db/postgre.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { DataSource, In } from 'typeorm'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { EmojisRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -24,18 +24,31 @@ export const paramDef = { required: ['ids', 'aliases'], } as const; -// eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - const emojis = await Emojis.findBy({ - id: In(ps.ids), - }); +// TODO: ロジックをサービスに切り出す - for (const emoji of emojis) { - await Emojis.update(emoji.id, { - updatedAt: new Date(), - aliases: emoji.aliases.filter(x => !ps.aliases.includes(x)), +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const emojis = await this.emojisRepository.findBy({ + id: In(ps.ids), + }); + + for (const emoji of emojis) { + await this.emojisRepository.update(emoji.id, { + updatedAt: new Date(), + aliases: emoji.aliases.filter(x => !ps.aliases.includes(x)), + }); + } + + await this.db.queryResultCache!.remove(['meta_emojis']); }); } - - await db.queryResultCache!.remove(['meta_emojis']); -}); +} diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts index ae3b190f4..c438b7f9b 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts @@ -1,8 +1,8 @@ -import define from '../../../define.js'; -import { Emojis } from '@/models/index.js'; -import { In } from 'typeorm'; -import { ApiError } from '../../../error.js'; -import { db } from '@/db/postgre.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { DataSource, In } from 'typeorm'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { EmojisRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -24,14 +24,27 @@ export const paramDef = { required: ['ids', 'aliases'], } as const; -// eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - await Emojis.update({ - id: In(ps.ids), - }, { - updatedAt: new Date(), - aliases: ps.aliases, - }); +// TODO: ロジックをサービスに切り出す - await db.queryResultCache!.remove(['meta_emojis']); -}); +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, + ) { + super(meta, paramDef, async (ps, me) => { + await this.emojisRepository.update({ + id: In(ps.ids), + }, { + updatedAt: new Date(), + aliases: ps.aliases, + }); + + await this.db.queryResultCache!.remove(['meta_emojis']); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts index cff58d617..4a9b31fd2 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts @@ -1,8 +1,8 @@ -import define from '../../../define.js'; -import { Emojis } from '@/models/index.js'; -import { In } from 'typeorm'; -import { ApiError } from '../../../error.js'; -import { db } from '@/db/postgre.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { DataSource, In } from 'typeorm'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { EmojisRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -26,14 +26,27 @@ export const paramDef = { required: ['ids'], } as const; -// eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - await Emojis.update({ - id: In(ps.ids), - }, { - updatedAt: new Date(), - category: ps.category, - }); +// TODO: ロジックをサービスに切り出す - await db.queryResultCache!.remove(['meta_emojis']); -}); +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, + ) { + super(meta, paramDef, async (ps, me) => { + await this.emojisRepository.update({ + id: In(ps.ids), + }, { + updatedAt: new Date(), + category: ps.category, + }); + + await this.db.queryResultCache!.remove(['meta_emojis']); + }); + } +} 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 5b547b3b7..e6eb9eb9a 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -1,7 +1,9 @@ -import define from '../../../define.js'; -import { Emojis } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { EmojisRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import { db } from '@/db/postgre.js'; export const meta = { tags: ['admin'], @@ -35,18 +37,31 @@ export const paramDef = { required: ['id', 'name', 'aliases'], } as const; +// TODO: ロジックをサービスに切り出す + // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - const emoji = await Emojis.findOneBy({ id: ps.id }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.db) + private db: DataSource, - if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const emoji = await this.emojisRepository.findOneBy({ id: ps.id }); - await Emojis.update(emoji.id, { - updatedAt: new Date(), - name: ps.name, - category: ps.category, - aliases: ps.aliases, - }); + if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); - await db.queryResultCache!.remove(['meta_emojis']); -}); + await this.emojisRepository.update(emoji.id, { + updatedAt: new Date(), + name: ps.name, + category: ps.category, + aliases: ps.aliases, + }); + + await this.db.queryResultCache!.remove(['meta_emojis']); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/federation/delete-all-files.ts b/packages/backend/src/server/api/endpoints/admin/federation/delete-all-files.ts index da5420147..789838661 100644 --- a/packages/backend/src/server/api/endpoints/admin/federation/delete-all-files.ts +++ b/packages/backend/src/server/api/endpoints/admin/federation/delete-all-files.ts @@ -1,6 +1,8 @@ -import define from '../../../define.js'; -import { deleteFile } from '@/services/drive/delete-file.js'; -import { DriveFiles } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFilesRepository } from '@/models/index.js'; +import { DriveService } from '@/core/DriveService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -18,12 +20,22 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const files = await DriveFiles.findBy({ - userHost: ps.host, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, - for (const file of files) { - deleteFile(file); + private driveService: DriveService, + ) { + super(meta, paramDef, async (ps, me) => { + const files = await this.driveFilesRepository.findBy({ + userHost: ps.host, + }); + + for (const file of files) { + this.driveService.deleteFile(file); + } + }); } -}); +} diff --git a/packages/backend/src/server/api/endpoints/admin/federation/refresh-remote-instance-metadata.ts b/packages/backend/src/server/api/endpoints/admin/federation/refresh-remote-instance-metadata.ts index cb2be5ab3..476b82152 100644 --- a/packages/backend/src/server/api/endpoints/admin/federation/refresh-remote-instance-metadata.ts +++ b/packages/backend/src/server/api/endpoints/admin/federation/refresh-remote-instance-metadata.ts @@ -1,7 +1,9 @@ -import define from '../../../define.js'; -import { Instances } from '@/models/index.js'; -import { toPuny } from '@/misc/convert-host.js'; -import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { InstancesRepository } from '@/models/index.js'; +import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -19,12 +21,23 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const instance = await Instances.findOneBy({ host: toPuny(ps.host) }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, - if (instance == null) { - throw new Error('instance not found'); + private utilityService: UtilityService, + private fetchInstanceMetadataService: FetchInstanceMetadataService, + ) { + super(meta, paramDef, async (ps, me) => { + const instance = await this.instancesRepository.findOneBy({ host: this.utilityService.toPuny(ps.host) }); + + if (instance == null) { + throw new Error('instance not found'); + } + + this.fetchInstanceMetadataService.fetchInstanceMetadata(instance, true); + }); } - - fetchInstanceMetadata(instance, true); -}); +} diff --git a/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts b/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts index b7ee27db6..67165dc47 100644 --- a/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts +++ b/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts @@ -1,6 +1,8 @@ -import define from '../../../define.js'; -import deleteFollowing from '@/services/following/delete.js'; -import { Followings, Users } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { FollowingsRepository, UsersRepository } from '@/models/index.js'; +import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -18,17 +20,30 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const followings = await Followings.findBy({ - followerHost: ps.host, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - const pairs = await Promise.all(followings.map(f => Promise.all([ - Users.findOneByOrFail({ id: f.followerId }), - Users.findOneByOrFail({ id: f.followeeId }), - ]))); + @Inject(DI.notesRepository) + private followingsRepository: FollowingsRepository, - for (const pair of pairs) { - deleteFollowing(pair[0], pair[1]); + private userFollowingService: UserFollowingService, + ) { + super(meta, paramDef, async (ps, me) => { + const followings = await this.followingsRepository.findBy({ + followerHost: ps.host, + }); + + const pairs = await Promise.all(followings.map(f => Promise.all([ + this.usersRepository.findOneByOrFail({ id: f.followerId }), + this.usersRepository.findOneByOrFail({ id: f.followeeId }), + ]))); + + for (const pair of pairs) { + this.userFollowingService.unfollow(pair[0], pair[1]); + } + }); } -}); +} diff --git a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts index 278131fb3..b9eade5b4 100644 --- a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts +++ b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts @@ -1,6 +1,8 @@ -import define from '../../../define.js'; -import { Instances } from '@/models/index.js'; -import { toPuny } from '@/misc/convert-host.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { InstancesRepository } from '@/models/index.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -19,14 +21,24 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const instance = await Instances.findOneBy({ host: toPuny(ps.host) }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, - if (instance == null) { - throw new Error('instance not found'); + private utilityService: UtilityService, + ) { + super(meta, paramDef, async (ps, me) => { + const instance = await this.instancesRepository.findOneBy({ host: this.utilityService.toPuny(ps.host) }); + + if (instance == null) { + throw new Error('instance not found'); + } + + this.instancesRepository.update({ host: this.utilityService.toPuny(ps.host) }, { + isSuspended: ps.isSuspended, + }); + }); } - - Instances.update({ host: toPuny(ps.host) }, { - isSuspended: ps.isSuspended, - }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/admin/get-index-stats.ts b/packages/backend/src/server/api/endpoints/admin/get-index-stats.ts index dd16473f3..e53d0bfce 100644 --- a/packages/backend/src/server/api/endpoints/admin/get-index-stats.ts +++ b/packages/backend/src/server/api/endpoints/admin/get-index-stats.ts @@ -1,5 +1,7 @@ -import define from '../../define.js'; -import { db } from '@/db/postgre.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; export const meta = { requireCredential: true, @@ -15,14 +17,22 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async () => { - const stats = await db.query(`SELECT * FROM pg_indexes;`).then(recs => { - const res = [] as { tablename: string; indexname: string; }[]; - for (const rec of recs) { - res.push(rec); - } - return res; - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.db) + private db: DataSource, + ) { + super(meta, paramDef, async () => { + const stats = await this.db.query('SELECT * FROM pg_indexes;').then(recs => { + const res = [] as { tablename: string; indexname: string; }[]; + for (const rec of recs) { + res.push(rec); + } + return res; + }); - return stats; -}); + return stats; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/get-table-stats.ts b/packages/backend/src/server/api/endpoints/admin/get-table-stats.ts index aca2540fd..41014cb16 100644 --- a/packages/backend/src/server/api/endpoints/admin/get-table-stats.ts +++ b/packages/backend/src/server/api/endpoints/admin/get-table-stats.ts @@ -1,5 +1,7 @@ -import { db } from '@/db/postgre.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; export const meta = { requireCredential: true, @@ -26,24 +28,31 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async () => { - const sizes = await - db.query(` +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.db) + private db: DataSource, + ) { + super(meta, paramDef, async () => { + const sizes = await this.db.query(` SELECT relname AS "table", reltuples as "count", pg_total_relation_size(C.oid) AS "size" FROM pg_class C LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace) WHERE nspname NOT IN ('pg_catalog', 'information_schema') AND C.relkind <> 'i' AND nspname !~ '^pg_toast';`) - .then(recs => { - const res = {} as Record; - for (const rec of recs) { - res[rec.table] = { - count: parseInt(rec.count, 10), - size: parseInt(rec.size, 10), - }; - } - return res; - }); + .then(recs => { + const res = {} as Record; + for (const rec of recs) { + res[rec.table] = { + count: parseInt(rec.count, 10), + size: parseInt(rec.size, 10), + }; + } + return res; + }); - return sizes; -}); + return sizes; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/get-user-ips.ts b/packages/backend/src/server/api/endpoints/admin/get-user-ips.ts index e8b9cb3b0..eddaade91 100644 --- a/packages/backend/src/server/api/endpoints/admin/get-user-ips.ts +++ b/packages/backend/src/server/api/endpoints/admin/get-user-ips.ts @@ -1,5 +1,7 @@ -import { UserIps } from '@/models/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UserIpsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -17,15 +19,23 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const ips = await UserIps.find({ - where: { userId: ps.userId }, - order: { createdAt: 'DESC' }, - take: 30, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userIpsRepository) + private userIpsRepository: UserIpsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const ips = await this.userIpsRepository.find({ + where: { userId: ps.userId }, + order: { createdAt: 'DESC' }, + take: 30, + }); - return ips.map(x => ({ - ip: x.ip, - createdAt: x.createdAt.toISOString(), - })); -}); + return ips.map(x => ({ + ip: x.ip, + createdAt: x.createdAt.toISOString(), + })); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/invite.ts b/packages/backend/src/server/api/endpoints/admin/invite.ts index 7e950cf87..5fe341e5c 100644 --- a/packages/backend/src/server/api/endpoints/admin/invite.ts +++ b/packages/backend/src/server/api/endpoints/admin/invite.ts @@ -1,7 +1,9 @@ import rndstr from 'rndstr'; -import define from '../../define.js'; -import { RegistrationTickets } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { RegistrationTicketsRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -31,19 +33,29 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async () => { - const code = rndstr({ - length: 8, - chars: '2-9A-HJ-NP-Z', // [0-9A-Z] w/o [01IO] (32 patterns) - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.registrationTicketsRepository) + private registrationTicketsRepository: RegistrationTicketsRepository, - await RegistrationTickets.insert({ - id: genId(), - createdAt: new Date(), - code, - }); + private idService: IdService, + ) { + super(meta, paramDef, async () => { + const code = rndstr({ + length: 8, + chars: '2-9A-HJ-NP-Z', // [0-9A-Z] w/o [01IO] (32 patterns) + }); - return { - code, - }; -}); + await this.registrationTicketsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + code, + }); + + return { + code, + }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 874611968..615c0a0e7 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -1,7 +1,9 @@ -import config from '@/config/index.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; +import { Inject, Injectable } from '@nestjs/common'; import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; -import define from '../../define.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { MetaService } from '@/core/MetaService.js'; +import { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['meta'], @@ -340,91 +342,101 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const instance = await fetchMeta(true); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.config) + private config: Config, - return { - maintainerName: instance.maintainerName, - maintainerEmail: instance.maintainerEmail, - version: config.version, - name: instance.name, - uri: config.url, - description: instance.description, - langs: instance.langs, - tosUrl: instance.ToSUrl, - repositoryUrl: instance.repositoryUrl, - feedbackUrl: instance.feedbackUrl, - disableRegistration: instance.disableRegistration, - disableLocalTimeline: instance.disableLocalTimeline, - disableGlobalTimeline: instance.disableGlobalTimeline, - driveCapacityPerLocalUserMb: instance.localDriveCapacityMb, - driveCapacityPerRemoteUserMb: instance.remoteDriveCapacityMb, - emailRequiredForSignup: instance.emailRequiredForSignup, - enableHcaptcha: instance.enableHcaptcha, - hcaptchaSiteKey: instance.hcaptchaSiteKey, - enableRecaptcha: instance.enableRecaptcha, - recaptchaSiteKey: instance.recaptchaSiteKey, - swPublickey: instance.swPublicKey, - themeColor: instance.themeColor, - mascotImageUrl: instance.mascotImageUrl, - bannerUrl: instance.bannerUrl, - errorImageUrl: instance.errorImageUrl, - iconUrl: instance.iconUrl, - backgroundImageUrl: instance.backgroundImageUrl, - logoImageUrl: instance.logoImageUrl, - maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, // 後方互換性のため - defaultLightTheme: instance.defaultLightTheme, - defaultDarkTheme: instance.defaultDarkTheme, - enableEmail: instance.enableEmail, - enableTwitterIntegration: instance.enableTwitterIntegration, - enableGithubIntegration: instance.enableGithubIntegration, - enableDiscordIntegration: instance.enableDiscordIntegration, - enableServiceWorker: instance.enableServiceWorker, - translatorAvailable: instance.deeplAuthKey != null, - pinnedPages: instance.pinnedPages, - pinnedClipId: instance.pinnedClipId, - cacheRemoteFiles: instance.cacheRemoteFiles, - useStarForReactionFallback: instance.useStarForReactionFallback, - pinnedUsers: instance.pinnedUsers, - hiddenTags: instance.hiddenTags, - blockedHosts: instance.blockedHosts, - hcaptchaSecretKey: instance.hcaptchaSecretKey, - recaptchaSecretKey: instance.recaptchaSecretKey, - sensitiveMediaDetection: instance.sensitiveMediaDetection, - sensitiveMediaDetectionSensitivity: instance.sensitiveMediaDetectionSensitivity, - setSensitiveFlagAutomatically: instance.setSensitiveFlagAutomatically, - enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos, - proxyAccountId: instance.proxyAccountId, - twitterConsumerKey: instance.twitterConsumerKey, - twitterConsumerSecret: instance.twitterConsumerSecret, - githubClientId: instance.githubClientId, - githubClientSecret: instance.githubClientSecret, - discordClientId: instance.discordClientId, - discordClientSecret: instance.discordClientSecret, - summalyProxy: instance.summalyProxy, - email: instance.email, - smtpSecure: instance.smtpSecure, - smtpHost: instance.smtpHost, - smtpPort: instance.smtpPort, - smtpUser: instance.smtpUser, - smtpPass: instance.smtpPass, - swPrivateKey: instance.swPrivateKey, - useObjectStorage: instance.useObjectStorage, - objectStorageBaseUrl: instance.objectStorageBaseUrl, - objectStorageBucket: instance.objectStorageBucket, - objectStoragePrefix: instance.objectStoragePrefix, - objectStorageEndpoint: instance.objectStorageEndpoint, - objectStorageRegion: instance.objectStorageRegion, - objectStoragePort: instance.objectStoragePort, - objectStorageAccessKey: instance.objectStorageAccessKey, - objectStorageSecretKey: instance.objectStorageSecretKey, - objectStorageUseSSL: instance.objectStorageUseSSL, - objectStorageUseProxy: instance.objectStorageUseProxy, - objectStorageSetPublicRead: instance.objectStorageSetPublicRead, - objectStorageS3ForcePathStyle: instance.objectStorageS3ForcePathStyle, - deeplAuthKey: instance.deeplAuthKey, - deeplIsPro: instance.deeplIsPro, - enableIpLogging: instance.enableIpLogging, - enableActiveEmailValidation: instance.enableActiveEmailValidation, - }; -}); + private metaService: MetaService, + ) { + super(meta, paramDef, async (ps, me) => { + const instance = await this.metaService.fetch(true); + + return { + maintainerName: instance.maintainerName, + maintainerEmail: instance.maintainerEmail, + version: this.config.version, + name: instance.name, + uri: this.config.url, + description: instance.description, + langs: instance.langs, + tosUrl: instance.ToSUrl, + repositoryUrl: instance.repositoryUrl, + feedbackUrl: instance.feedbackUrl, + disableRegistration: instance.disableRegistration, + disableLocalTimeline: instance.disableLocalTimeline, + disableGlobalTimeline: instance.disableGlobalTimeline, + driveCapacityPerLocalUserMb: instance.localDriveCapacityMb, + driveCapacityPerRemoteUserMb: instance.remoteDriveCapacityMb, + emailRequiredForSignup: instance.emailRequiredForSignup, + enableHcaptcha: instance.enableHcaptcha, + hcaptchaSiteKey: instance.hcaptchaSiteKey, + enableRecaptcha: instance.enableRecaptcha, + recaptchaSiteKey: instance.recaptchaSiteKey, + swPublickey: instance.swPublicKey, + themeColor: instance.themeColor, + mascotImageUrl: instance.mascotImageUrl, + bannerUrl: instance.bannerUrl, + errorImageUrl: instance.errorImageUrl, + iconUrl: instance.iconUrl, + backgroundImageUrl: instance.backgroundImageUrl, + logoImageUrl: instance.logoImageUrl, + maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, // 後方互換性のため + defaultLightTheme: instance.defaultLightTheme, + defaultDarkTheme: instance.defaultDarkTheme, + enableEmail: instance.enableEmail, + enableTwitterIntegration: instance.enableTwitterIntegration, + enableGithubIntegration: instance.enableGithubIntegration, + enableDiscordIntegration: instance.enableDiscordIntegration, + enableServiceWorker: instance.enableServiceWorker, + translatorAvailable: instance.deeplAuthKey != null, + pinnedPages: instance.pinnedPages, + pinnedClipId: instance.pinnedClipId, + cacheRemoteFiles: instance.cacheRemoteFiles, + useStarForReactionFallback: instance.useStarForReactionFallback, + pinnedUsers: instance.pinnedUsers, + hiddenTags: instance.hiddenTags, + blockedHosts: instance.blockedHosts, + hcaptchaSecretKey: instance.hcaptchaSecretKey, + recaptchaSecretKey: instance.recaptchaSecretKey, + sensitiveMediaDetection: instance.sensitiveMediaDetection, + sensitiveMediaDetectionSensitivity: instance.sensitiveMediaDetectionSensitivity, + setSensitiveFlagAutomatically: instance.setSensitiveFlagAutomatically, + enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos, + proxyAccountId: instance.proxyAccountId, + twitterConsumerKey: instance.twitterConsumerKey, + twitterConsumerSecret: instance.twitterConsumerSecret, + githubClientId: instance.githubClientId, + githubClientSecret: instance.githubClientSecret, + discordClientId: instance.discordClientId, + discordClientSecret: instance.discordClientSecret, + summalyProxy: instance.summalyProxy, + email: instance.email, + smtpSecure: instance.smtpSecure, + smtpHost: instance.smtpHost, + smtpPort: instance.smtpPort, + smtpUser: instance.smtpUser, + smtpPass: instance.smtpPass, + swPrivateKey: instance.swPrivateKey, + useObjectStorage: instance.useObjectStorage, + objectStorageBaseUrl: instance.objectStorageBaseUrl, + objectStorageBucket: instance.objectStorageBucket, + objectStoragePrefix: instance.objectStoragePrefix, + objectStorageEndpoint: instance.objectStorageEndpoint, + objectStorageRegion: instance.objectStorageRegion, + objectStoragePort: instance.objectStoragePort, + objectStorageAccessKey: instance.objectStorageAccessKey, + objectStorageSecretKey: instance.objectStorageSecretKey, + objectStorageUseSSL: instance.objectStorageUseSSL, + objectStorageUseProxy: instance.objectStorageUseProxy, + objectStorageSetPublicRead: instance.objectStorageSetPublicRead, + objectStorageS3ForcePathStyle: instance.objectStorageS3ForcePathStyle, + deeplAuthKey: instance.deeplAuthKey, + deeplIsPro: instance.deeplIsPro, + enableIpLogging: instance.enableIpLogging, + enableActiveEmailValidation: instance.enableActiveEmailValidation, + }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/moderators/add.ts b/packages/backend/src/server/api/endpoints/admin/moderators/add.ts index 7b209c2d9..fe200da6a 100644 --- a/packages/backend/src/server/api/endpoints/admin/moderators/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/moderators/add.ts @@ -1,6 +1,8 @@ -import define from '../../../define.js'; -import { Users } from '@/models/index.js'; -import { publishInternalEvent } from '@/services/stream.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UsersRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -18,20 +20,30 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - const user = await Users.findOneBy({ id: ps.userId }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - if (user == null) { - throw new Error('user not found'); + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps) => { + const user = await this.usersRepository.findOneBy({ id: ps.userId }); + + if (user == null) { + throw new Error('user not found'); + } + + if (user.isAdmin) { + throw new Error('cannot mark as moderator if admin user'); + } + + await this.usersRepository.update(user.id, { + isModerator: true, + }); + + this.globalEventService.publishInternalEvent('userChangeModeratorState', { id: user.id, isModerator: true }); + }); } - - if (user.isAdmin) { - throw new Error('cannot mark as moderator if admin user'); - } - - await Users.update(user.id, { - isModerator: true, - }); - - publishInternalEvent('userChangeModeratorState', { id: user.id, isModerator: true }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/admin/moderators/remove.ts b/packages/backend/src/server/api/endpoints/admin/moderators/remove.ts index a01e9f3c6..3dc7158ba 100644 --- a/packages/backend/src/server/api/endpoints/admin/moderators/remove.ts +++ b/packages/backend/src/server/api/endpoints/admin/moderators/remove.ts @@ -1,6 +1,8 @@ -import define from '../../../define.js'; -import { Users } from '@/models/index.js'; -import { publishInternalEvent } from '@/services/stream.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UsersRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -18,16 +20,26 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - const user = await Users.findOneBy({ id: ps.userId }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - if (user == null) { - throw new Error('user not found'); + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps) => { + const user = await this.usersRepository.findOneBy({ id: ps.userId }); + + if (user == null) { + throw new Error('user not found'); + } + + await this.usersRepository.update(user.id, { + isModerator: false, + }); + + this.globalEventService.publishInternalEvent('userChangeModeratorState', { id: user.id, isModerator: false }); + }); } - - await Users.update(user.id, { - isModerator: false, - }); - - publishInternalEvent('userChangeModeratorState', { id: user.id, isModerator: false }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/admin/promo/create.ts b/packages/backend/src/server/api/endpoints/admin/promo/create.ts index 68a17867b..a179f163d 100644 --- a/packages/backend/src/server/api/endpoints/admin/promo/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/promo/create.ts @@ -1,7 +1,9 @@ -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { PromoNotesRepository } from '@/models/index.js'; +import { GetterService } from '@/server/api/common/GetterService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import { getNote } from '../../../common/getters.js'; -import { PromoNotes } from '@/models/index.js'; export const meta = { tags: ['admin'], @@ -34,21 +36,31 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const note = await getNote(ps.noteId).catch(e => { - if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw e; - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.promoNotesRepository) + private promoNotesRepository: PromoNotesRepository, - const exist = await PromoNotes.findOneBy({ noteId: note.id }); + private getterService: GetterService, + ) { + super(meta, paramDef, async (ps, me) => { + const note = await this.getterService.getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); - if (exist != null) { - throw new ApiError(meta.errors.alreadyPromoted); + const exist = await this.promoNotesRepository.findOneBy({ noteId: note.id }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyPromoted); + } + + await this.promoNotesRepository.insert({ + noteId: note.id, + expiresAt: new Date(ps.expiresAt), + userId: note.userId, + }); + }); } - - await PromoNotes.insert({ - noteId: note.id, - expiresAt: new Date(ps.expiresAt), - userId: note.userId, - }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/clear.ts b/packages/backend/src/server/api/endpoints/admin/queue/clear.ts index 8f015c280..9129f53f0 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/clear.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/clear.ts @@ -1,6 +1,7 @@ -import define from '../../../define.js'; -import { destroy } from '@/queue/index.js'; -import { insertModerationLog } from '@/services/insert-moderation-log.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { QueueService } from '@/core/QueueService.js'; export const meta = { tags: ['admin'], @@ -16,8 +17,16 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - destroy(); +@Injectable() +export default class extends Endpoint { + constructor( + private moderationLogService: ModerationLogService, + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + this.queueService.destroy(); - insertModerationLog(me, 'clearQueue'); -}); + this.moderationLogService.insertModerationLog(me, 'clearQueue'); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts b/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts index 70f7d77de..4b5be70d5 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts @@ -1,6 +1,7 @@ -import { deliverQueue } from '@/queue/queues.js'; import { URL } from 'node:url'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DeliverQueue } from '@/core/queue/QueueModule.js'; export const meta = { tags: ['admin'], @@ -39,21 +40,28 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - const jobs = await deliverQueue.getJobs(['delayed']); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject('queue:deliver') public deliverQueue: DeliverQueue, + ) { + super(meta, paramDef, async (ps, me) => { + const jobs = await this.deliverQueue.getJobs(['delayed']); - const res = [] as [string, number][]; + const res = [] as [string, number][]; - for (const job of jobs) { - const host = new URL(job.data.to).host; - if (res.find(x => x[0] === host)) { - res.find(x => x[0] === host)![1]++; - } else { - res.push([host, 1]); - } + for (const job of jobs) { + const host = new URL(job.data.to).host; + if (res.find(x => x[0] === host)) { + res.find(x => x[0] === host)![1]++; + } else { + res.push([host, 1]); + } + } + + res.sort((a, b) => b[1] - a[1]); + + return res; + }); } - - res.sort((a, b) => b[1] - a[1]); - - return res; -}); +} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts b/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts index 2235ce8f9..715974e91 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts @@ -1,6 +1,7 @@ import { URL } from 'node:url'; -import define from '../../../define.js'; -import { inboxQueue } from '@/queue/queues.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { InboxQueue } from '@/core/queue/QueueModule.js'; export const meta = { tags: ['admin'], @@ -39,21 +40,28 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - const jobs = await inboxQueue.getJobs(['delayed']); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject('queue:inbox') public inboxQueue: InboxQueue, + ) { + super(meta, paramDef, async (ps, me) => { + const jobs = await this.inboxQueue.getJobs(['delayed']); - const res = [] as [string, number][]; + const res = [] as [string, number][]; - for (const job of jobs) { - const host = new URL(job.data.signature.keyId).host; - if (res.find(x => x[0] === host)) { - res.find(x => x[0] === host)![1]++; - } else { - res.push([host, 1]); - } + for (const job of jobs) { + const host = new URL(job.data.signature.keyId).host; + if (res.find(x => x[0] === host)) { + res.find(x => x[0] === host)![1]++; + } else { + res.push([host, 1]); + } + } + + res.sort((a, b) => b[1] - a[1]); + + return res; + }); } - - res.sort((a, b) => b[1] - a[1]); - - return res; -}); +} 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 988b5a5e3..f2ca81a8d 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts @@ -1,5 +1,6 @@ -import { deliverQueue, inboxQueue, dbQueue, objectStorageQueue } from '@/queue/queues.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue } from '@/core/queue/QueueModule.js'; export const meta = { tags: ['admin'], @@ -38,16 +39,29 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - const deliverJobCounts = await deliverQueue.getJobCounts(); - const inboxJobCounts = await inboxQueue.getJobCounts(); - const dbJobCounts = await dbQueue.getJobCounts(); - const objectStorageJobCounts = await objectStorageQueue.getJobCounts(); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject('queue:system') public systemQueue: SystemQueue, + @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, + @Inject('queue:deliver') public deliverQueue: DeliverQueue, + @Inject('queue:inbox') public inboxQueue: InboxQueue, + @Inject('queue:db') public dbQueue: DbQueue, + @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, + @Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, + ) { + super(meta, paramDef, async (ps, me) => { + const deliverJobCounts = await this.deliverQueue.getJobCounts(); + const inboxJobCounts = await this.inboxQueue.getJobCounts(); + const dbJobCounts = await this.dbQueue.getJobCounts(); + const objectStorageJobCounts = await this.objectStorageQueue.getJobCounts(); - return { - deliver: deliverJobCounts, - inbox: inboxJobCounts, - db: dbJobCounts, - objectStorage: objectStorageJobCounts, - }; -}); + return { + deliver: deliverJobCounts, + inbox: inboxJobCounts, + db: dbJobCounts, + objectStorage: objectStorageJobCounts, + }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/relays/add.ts b/packages/backend/src/server/api/endpoints/admin/relays/add.ts index 348e9baca..32ad79918 100644 --- a/packages/backend/src/server/api/endpoints/admin/relays/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/relays/add.ts @@ -1,6 +1,7 @@ import { URL } from 'node:url'; -import define from '../../../define.js'; -import { addRelay } from '@/services/relay.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { RelayService } from '@/core/RelayService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -54,12 +55,19 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - try { - if (new URL(ps.inbox).protocol !== 'https:') throw 'https only'; - } catch { - throw new ApiError(meta.errors.invalidUrl); - } +@Injectable() +export default class extends Endpoint { + constructor( + private relayService: RelayService, + ) { + super(meta, paramDef, async (ps, me) => { + try { + if (new URL(ps.inbox).protocol !== 'https:') throw 'https only'; + } catch { + throw new ApiError(meta.errors.invalidUrl); + } - return await addRelay(ps.inbox); -}); + return await this.relayService.addRelay(ps.inbox); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/relays/list.ts b/packages/backend/src/server/api/endpoints/admin/relays/list.ts index 89ec651e6..079b351ad 100644 --- a/packages/backend/src/server/api/endpoints/admin/relays/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/relays/list.ts @@ -1,5 +1,6 @@ -import define from '../../../define.js'; -import { listRelay } from '@/services/relay.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { RelayService } from '@/core/RelayService.js'; export const meta = { tags: ['admin'], @@ -46,6 +47,13 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - return await listRelay(); -}); +@Injectable() +export default class extends Endpoint { + constructor( + private relayService: RelayService, + ) { + super(meta, paramDef, async (ps, me) => { + return await this.relayService.listRelay(); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/relays/remove.ts b/packages/backend/src/server/api/endpoints/admin/relays/remove.ts index b59cf72c5..9dc4105d1 100644 --- a/packages/backend/src/server/api/endpoints/admin/relays/remove.ts +++ b/packages/backend/src/server/api/endpoints/admin/relays/remove.ts @@ -1,5 +1,6 @@ -import define from '../../../define.js'; -import { removeRelay } from '@/services/relay.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { RelayService } from '@/core/RelayService.js'; export const meta = { tags: ['admin'], @@ -17,6 +18,13 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - return await removeRelay(ps.inbox); -}); +@Injectable() +export default class extends Endpoint { + constructor( + private relayService: RelayService, + ) { + super(meta, paramDef, async (ps, me) => { + return await this.relayService.removeRelay(ps.inbox); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/reset-password.ts b/packages/backend/src/server/api/endpoints/admin/reset-password.ts index be4c2dcee..7446746b4 100644 --- a/packages/backend/src/server/api/endpoints/admin/reset-password.ts +++ b/packages/backend/src/server/api/endpoints/admin/reset-password.ts @@ -1,7 +1,9 @@ -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; import bcrypt from 'bcryptjs'; import rndstr from 'rndstr'; -import { Users, UserProfiles } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UsersRepository, UserProfilesRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -32,29 +34,40 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - const user = await Users.findOneBy({ id: ps.userId }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - if (user == null) { - throw new Error('user not found'); + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + ) { + super(meta, paramDef, async (ps) => { + const user = await this.usersRepository.findOneBy({ id: ps.userId }); + + if (user == null) { + throw new Error('user not found'); + } + + if (user.isAdmin) { + throw new Error('cannot reset password of admin'); + } + + const passwd = rndstr('a-zA-Z0-9', 8); + + // Generate hash of password + const hash = bcrypt.hashSync(passwd); + + await this.userProfilesRepository.update({ + userId: user.id, + }, { + password: hash, + }); + + return { + password: passwd, + }; + }); } - - if (user.isAdmin) { - throw new Error('cannot reset password of admin'); - } - - const passwd = rndstr('a-zA-Z0-9', 8); - - // Generate hash of password - const hash = bcrypt.hashSync(passwd); - - await UserProfiles.update({ - userId: user.id, - }, { - password: hash, - }); - - return { - password: passwd, - }; -}); +} diff --git a/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts b/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts index 3edae4a85..b5828ae9b 100644 --- a/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts +++ b/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts @@ -1,9 +1,10 @@ -import define from '../../define.js'; -import { AbuseUserReports, Users } from '@/models/index.js'; -import { getInstanceActor } from '@/services/instance-actor.js'; -import { deliver } from '@/queue/index.js'; -import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import { renderFlag } from '@/remote/activitypub/renderer/flag.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UsersRepository, AbuseUserReportsRepository } from '@/models/index.js'; +import { InstanceActorService } from '@/core/InstanceActorService.js'; +import { QueueService } from '@/core/QueueService.js'; +import { ApRendererService } from '@/core/remote/activitypub/ApRendererService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -21,24 +22,41 @@ export const paramDef = { required: ['reportId'], } as const; +// TODO: ロジックをサービスに切り出す + // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const report = await AbuseUserReports.findOneByOrFail({ id: ps.reportId }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - if (report == null) { - throw new Error('report not found'); + @Inject(DI.abuseUserReportsRepository) + private abuseUserReportsRepository: AbuseUserReportsRepository, + + private queueService: QueueService, + private instanceActorService: InstanceActorService, + private apRendererService: ApRendererService, + ) { + super(meta, paramDef, async (ps, me) => { + const report = await this.abuseUserReportsRepository.findOneBy({ id: ps.reportId }); + + if (report == null) { + throw new Error('report not found'); + } + + if (ps.forward && report.targetUserHost != null) { + const actor = await this.instanceActorService.getInstanceActor(); + const targetUser = await this.usersRepository.findOneByOrFail({ id: report.targetUserId }); + + this.queueService.deliver(actor, this.apRendererService.renderActivity(this.apRendererService.renderFlag(actor, [targetUser.uri!], report.comment)), targetUser.inbox); + } + + await this.abuseUserReportsRepository.update(report.id, { + resolved: true, + assigneeId: me.id, + forwarded: ps.forward && report.targetUserHost != null, + }); + }); } - - if (ps.forward && report.targetUserHost != null) { - const actor = await getInstanceActor(); - const targetUser = await Users.findOneByOrFail({ id: report.targetUserId }); - - deliver(actor, renderActivity(renderFlag(actor, [targetUser.uri!], report.comment)), targetUser.inbox); - } - - await AbuseUserReports.update(report.id, { - resolved: true, - assigneeId: me.id, - forwarded: ps.forward && report.targetUserHost != null, - }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/admin/send-email.ts b/packages/backend/src/server/api/endpoints/admin/send-email.ts index bbdd66e4c..7434bf4c9 100644 --- a/packages/backend/src/server/api/endpoints/admin/send-email.ts +++ b/packages/backend/src/server/api/endpoints/admin/send-email.ts @@ -1,5 +1,6 @@ -import define from '../../define.js'; -import { sendEmail } from '@/services/send-email.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { EmailService } from '@/core/EmailService.js'; export const meta = { tags: ['admin'], @@ -19,6 +20,13 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - await sendEmail(ps.to, ps.subject, ps.text, ps.text); -}); +@Injectable() +export default class extends Endpoint { + constructor( + private emailService: EmailService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.emailService.sendEmail(ps.to, ps.subject, ps.text, ps.text); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/server-info.ts b/packages/backend/src/server/api/endpoints/admin/server-info.ts index 85c6fb82e..9c576dffe 100644 --- a/packages/backend/src/server/api/endpoints/admin/server-info.ts +++ b/packages/backend/src/server/api/endpoints/admin/server-info.ts @@ -1,8 +1,10 @@ import * as os from 'node:os'; import si from 'systeminformation'; -import define from '../../define.js'; -import { redisClient } from '../../../../db/redis.js'; -import { db } from '@/db/postgre.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import Redis from 'ioredis'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; export const meta = { requireCredential: true, @@ -94,34 +96,46 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async () => { - const memStats = await si.mem(); - const fsStats = await si.fsSize(); - const netInterface = await si.networkInterfaceDefault(); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.db) + private db: DataSource, - const redisServerInfo = await redisClient.info('Server'); - const m = redisServerInfo.match(new RegExp('^redis_version:(.*)', 'm')); - const redis_version = m?.[1]; + @Inject(DI.redis) + private redisClient: Redis.Redis, - return { - machine: os.hostname(), - os: os.platform(), - node: process.version, - psql: await db.query('SHOW server_version').then(x => x[0].server_version), - redis: redis_version, - cpu: { - model: os.cpus()[0].model, - cores: os.cpus().length, - }, - mem: { - total: memStats.total, - }, - fs: { - total: fsStats[0].size, - used: fsStats[0].used, - }, - net: { - interface: netInterface, - }, - }; -}); + ) { + super(meta, paramDef, async () => { + const memStats = await si.mem(); + const fsStats = await si.fsSize(); + const netInterface = await si.networkInterfaceDefault(); + + const redisServerInfo = await this.redisClient.info('Server'); + const m = redisServerInfo.match(new RegExp('^redis_version:(.*)', 'm')); + const redis_version = m?.[1]; + + return { + machine: os.hostname(), + os: os.platform(), + node: process.version, + psql: await this.db.query('SHOW server_version').then(x => x[0].server_version), + redis: redis_version, + cpu: { + model: os.cpus()[0].model, + cores: os.cpus().length, + }, + mem: { + total: memStats.total, + }, + fs: { + total: fsStats[0].size, + used: fsStats[0].used, + }, + net: { + interface: netInterface, + }, + }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/show-moderation-logs.ts b/packages/backend/src/server/api/endpoints/admin/show-moderation-logs.ts index 3545536aa..2424cac42 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-moderation-logs.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-moderation-logs.ts @@ -1,6 +1,8 @@ -import define from '../../define.js'; -import { ModerationLogs } from '@/models/index.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ModerationLogsRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -59,10 +61,20 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - const query = makePaginationQuery(ModerationLogs.createQueryBuilder('report'), ps.sinceId, ps.untilId); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.moderationLogsRepository) + private moderationLogsRepository: ModerationLogsRepository, - const reports = await query.take(ps.limit).getMany(); + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.moderationLogsRepository.createQueryBuilder('report'), ps.sinceId, ps.untilId); - return await ModerationLogs.packMany(reports); -}); + const reports = await query.take(ps.limit).getMany(); + + return await this.moderationLogEntityService.packMany(reports); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts index 0d866b311..b50564210 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -1,5 +1,7 @@ -import { Signins, UserProfiles, Users } from '@/models/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UsersRepository, SigninsRepository, UserProfilesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -22,55 +24,69 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const [user, profile] = await Promise.all([ - Users.findOneBy({ id: ps.userId }), - UserProfiles.findOneBy({ userId: ps.userId }), - ]); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - if (user == null || profile == null) { - throw new Error('user not found'); + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.signinsRepository) + private signinsRepository: SigninsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const [user, profile] = await Promise.all([ + this.usersRepository.findOneBy({ id: ps.userId }), + this.userProfilesRepository.findOneBy({ userId: ps.userId }), + ]); + + if (user == null || profile == null) { + throw new Error('user not found'); + } + + const _me = await this.usersRepository.findOneByOrFail({ id: me.id }); + if ((_me.isModerator && !_me.isAdmin) && user.isAdmin) { + throw new Error('cannot show info of admin'); + } + + if (!_me.isAdmin) { + return { + isModerator: user.isModerator, + isSilenced: user.isSilenced, + isSuspended: user.isSuspended, + }; + } + + const maskedKeys = ['accessToken', 'accessTokenSecret', 'refreshToken']; + Object.keys(profile.integrations).forEach(integration => { + maskedKeys.forEach(key => profile.integrations[integration][key] = ''); + }); + + const signins = await this.signinsRepository.findBy({ userId: user.id }); + + return { + email: profile.email, + emailVerified: profile.emailVerified, + autoAcceptFollowed: profile.autoAcceptFollowed, + noCrawle: profile.noCrawle, + alwaysMarkNsfw: profile.alwaysMarkNsfw, + autoSensitive: profile.autoSensitive, + carefulBot: profile.carefulBot, + injectFeaturedNote: profile.injectFeaturedNote, + receiveAnnouncementEmail: profile.receiveAnnouncementEmail, + integrations: profile.integrations, + mutedWords: profile.mutedWords, + mutedInstances: profile.mutedInstances, + mutingNotificationTypes: profile.mutingNotificationTypes, + isModerator: user.isModerator, + isSilenced: user.isSilenced, + isSuspended: user.isSuspended, + lastActiveDate: user.lastActiveDate, + moderationNote: profile.moderationNote, + signins, + }; + }); } - - const _me = await Users.findOneByOrFail({ id: me.id }); - if ((_me.isModerator && !_me.isAdmin) && user.isAdmin) { - throw new Error('cannot show info of admin'); - } - - if (!_me.isAdmin) { - return { - isModerator: user.isModerator, - isSilenced: user.isSilenced, - isSuspended: user.isSuspended, - }; - } - - const maskedKeys = ['accessToken', 'accessTokenSecret', 'refreshToken']; - Object.keys(profile.integrations).forEach(integration => { - maskedKeys.forEach(key => profile.integrations[integration][key] = ''); - }); - - const signins = await Signins.findBy({ userId: user.id }); - - return { - email: profile.email, - emailVerified: profile.emailVerified, - autoAcceptFollowed: profile.autoAcceptFollowed, - noCrawle: profile.noCrawle, - alwaysMarkNsfw: profile.alwaysMarkNsfw, - autoSensitive: profile.autoSensitive, - carefulBot: profile.carefulBot, - injectFeaturedNote: profile.injectFeaturedNote, - receiveAnnouncementEmail: profile.receiveAnnouncementEmail, - integrations: profile.integrations, - mutedWords: profile.mutedWords, - mutedInstances: profile.mutedInstances, - mutingNotificationTypes: profile.mutingNotificationTypes, - isModerator: user.isModerator, - isSilenced: user.isSilenced, - isSuspended: user.isSuspended, - lastActiveDate: user.lastActiveDate, - moderationNote: profile.moderationNote, - signins, - }; -}); +} diff --git a/packages/backend/src/server/api/endpoints/admin/show-users.ts b/packages/backend/src/server/api/endpoints/admin/show-users.ts index 8e09e72d5..8d11e3ea7 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-users.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-users.ts @@ -1,5 +1,7 @@ -import { Users } from '@/models/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UsersRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -38,46 +40,54 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const query = Users.createQueryBuilder('user'); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.usersRepository.createQueryBuilder('user'); - switch (ps.state) { - case 'available': query.where('user.isSuspended = FALSE'); break; - case 'admin': query.where('user.isAdmin = TRUE'); break; - case 'moderator': query.where('user.isModerator = TRUE'); break; - case 'adminOrModerator': query.where('user.isAdmin = TRUE OR user.isModerator = TRUE'); break; - case 'alive': query.where('user.updatedAt > :date', { date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5) }); break; - case 'silenced': query.where('user.isSilenced = TRUE'); break; - case 'suspended': query.where('user.isSuspended = TRUE'); break; + switch (ps.state) { + case 'available': query.where('user.isSuspended = FALSE'); break; + case 'admin': query.where('user.isAdmin = TRUE'); break; + case 'moderator': query.where('user.isModerator = TRUE'); break; + case 'adminOrModerator': query.where('user.isAdmin = TRUE OR user.isModerator = TRUE'); break; + case 'alive': query.where('user.updatedAt > :date', { date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5) }); break; + case 'silenced': query.where('user.isSilenced = TRUE'); break; + case 'suspended': query.where('user.isSuspended = TRUE'); break; + } + + switch (ps.origin) { + case 'local': query.andWhere('user.host IS NULL'); break; + case 'remote': query.andWhere('user.host IS NOT NULL'); break; + } + + if (ps.username) { + query.andWhere('user.usernameLower like :username', { username: ps.username.toLowerCase() + '%' }); + } + + if (ps.hostname) { + query.andWhere('user.host = :hostname', { hostname: ps.hostname.toLowerCase() }); + } + + switch (ps.sort) { + case '+follower': query.orderBy('user.followersCount', 'DESC'); break; + case '-follower': query.orderBy('user.followersCount', 'ASC'); break; + case '+createdAt': query.orderBy('user.createdAt', 'DESC'); break; + case '-createdAt': query.orderBy('user.createdAt', 'ASC'); break; + case '+updatedAt': query.orderBy('user.updatedAt', 'DESC', 'NULLS LAST'); break; + case '-updatedAt': query.orderBy('user.updatedAt', 'ASC', 'NULLS FIRST'); break; + default: query.orderBy('user.id', 'ASC'); break; + } + + query.take(ps.limit); + query.skip(ps.offset); + + const users = await query.getMany(); + + return await this.userEntityService.packMany(users, me, { detail: true }); + }); } - - switch (ps.origin) { - case 'local': query.andWhere('user.host IS NULL'); break; - case 'remote': query.andWhere('user.host IS NOT NULL'); break; - } - - if (ps.username) { - query.andWhere('user.usernameLower like :username', { username: ps.username.toLowerCase() + '%' }); - } - - if (ps.hostname) { - query.andWhere('user.host = :hostname', { hostname: ps.hostname.toLowerCase() }); - } - - switch (ps.sort) { - case '+follower': query.orderBy('user.followersCount', 'DESC'); break; - case '-follower': query.orderBy('user.followersCount', 'ASC'); break; - case '+createdAt': query.orderBy('user.createdAt', 'DESC'); break; - case '-createdAt': query.orderBy('user.createdAt', 'ASC'); break; - case '+updatedAt': query.orderBy('user.updatedAt', 'DESC', 'NULLS LAST'); break; - case '-updatedAt': query.orderBy('user.updatedAt', 'ASC', 'NULLS FIRST'); break; - default: query.orderBy('user.id', 'ASC'); break; - } - - query.take(ps.limit); - query.skip(ps.offset); - - const users = await query.getMany(); - - return await Users.packMany(users, me, { detail: true }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/admin/silence-user.ts b/packages/backend/src/server/api/endpoints/admin/silence-user.ts index 17b9f3b5a..bec8f7719 100644 --- a/packages/backend/src/server/api/endpoints/admin/silence-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/silence-user.ts @@ -1,7 +1,9 @@ -import define from '../../define.js'; -import { Users } from '@/models/index.js'; -import { insertModerationLog } from '@/services/insert-moderation-log.js'; -import { publishInternalEvent } from '@/services/stream.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { UsersRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -19,24 +21,35 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const user = await Users.findOneBy({ id: ps.userId }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - if (user == null) { - throw new Error('user not found'); + private moderationLogService: ModerationLogService, + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + const user = await this.usersRepository.findOneBy({ id: ps.userId }); + + if (user == null) { + throw new Error('user not found'); + } + + if (user.isAdmin) { + throw new Error('cannot silence admin'); + } + + await this.usersRepository.update(user.id, { + isSilenced: true, + }); + + this.globalEventService.publishInternalEvent('userChangeSilencedState', { id: user.id, isSilenced: true }); + + this.moderationLogService.insertModerationLog(me, 'silence', { + targetId: user.id, + }); + }); } - - if (user.isAdmin) { - throw new Error('cannot silence admin'); - } - - await Users.update(user.id, { - isSilenced: true, - }); - - publishInternalEvent('userChangeSilencedState', { id: user.id, isSilenced: true }); - - insertModerationLog(me, 'silence', { - targetId: user.id, - }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/admin/suspend-user.ts b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts index ed513eda0..d9266aac6 100644 --- a/packages/backend/src/server/api/endpoints/admin/suspend-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts @@ -1,10 +1,12 @@ -import define from '../../define.js'; -import deleteFollowing from '@/services/following/delete.js'; -import { Users, Followings, Notifications } from '@/models/index.js'; -import { User } from '@/models/entities/user.js'; -import { insertModerationLog } from '@/services/insert-moderation-log.js'; -import { doPostSuspend } from '@/services/suspend-user.js'; -import { publishUserEvent } from '@/services/stream.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UsersRepository, FollowingsRepository, NotificationsRepository } from '@/models/index.js'; +import type { User } from '@/models/entities/User.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { UserSuspendService } from '@/core/UserSuspendService.js'; +import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -22,64 +24,83 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const user = await Users.findOneBy({ id: ps.userId }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - if (user == null) { - throw new Error('user not found'); - } + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, - if (user.isAdmin) { - throw new Error('cannot suspend admin'); - } + @Inject(DI.notificationsRepository) + private notificationsRepository: NotificationsRepository, - if (user.isModerator) { - throw new Error('cannot suspend moderator'); - } + private userFollowingService: UserFollowingService, + private userSuspendService: UserSuspendService, + private moderationLogService: ModerationLogService, + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + const user = await this.usersRepository.findOneBy({ id: ps.userId }); - await Users.update(user.id, { - isSuspended: true, - }); + if (user == null) { + throw new Error('user not found'); + } - insertModerationLog(me, 'suspend', { - targetId: user.id, - }); + if (user.isAdmin) { + throw new Error('cannot suspend admin'); + } - // Terminate streaming - if (Users.isLocalUser(user)) { - publishUserEvent(user.id, 'terminate', {}); - } + if (user.isModerator) { + throw new Error('cannot suspend moderator'); + } - (async () => { - await doPostSuspend(user).catch(e => {}); - await unFollowAll(user).catch(e => {}); - await readAllNotify(user).catch(e => {}); - })(); -}); + await this.usersRepository.update(user.id, { + isSuspended: true, + }); -async function unFollowAll(follower: User) { - const followings = await Followings.findBy({ - followerId: follower.id, - }); + this.moderationLogService.insertModerationLog(me, 'suspend', { + targetId: user.id, + }); - for (const following of followings) { - const followee = await Users.findOneBy({ - id: following.followeeId, + // Terminate streaming + if (this.userEntityService.isLocalUser(user)) { + this.globalEventService.publishUserEvent(user.id, 'terminate', {}); + } + + (async () => { + await this.userSuspendService.doPostSuspend(user).catch(e => {}); + await this.unFollowAll(user).catch(e => {}); + await this.readAllNotify(user).catch(e => {}); + })(); }); + } - if (followee == null) { - throw `Cant find followee ${following.followeeId}`; + private async unFollowAll(follower: User) { + const followings = await this.followingsRepository.findBy({ + followerId: follower.id, + }); + + for (const following of followings) { + const followee = await this.usersRepository.findOneBy({ + id: following.followeeId, + }); + + if (followee == null) { + throw `Cant find followee ${following.followeeId}`; + } + + await this.userFollowingService.unfollow(follower, followee, true); } - - await deleteFollowing(follower, followee, true); + } + + private async readAllNotify(notifier: User) { + await this.notificationsRepository.update({ + notifierId: notifier.id, + isRead: false, + }, { + isRead: true, + }); } } - -async function readAllNotify(notifier: User) { - await Notifications.update({ - notifierId: notifier.id, - isRead: false, - }, { - isRead: true, - }); -} diff --git a/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts b/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts index a4b373f5c..b4671a2f4 100644 --- a/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts @@ -1,7 +1,9 @@ -import define from '../../define.js'; -import { Users } from '@/models/index.js'; -import { insertModerationLog } from '@/services/insert-moderation-log.js'; -import { publishInternalEvent } from '@/services/stream.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UsersRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -19,20 +21,31 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const user = await Users.findOneBy({ id: ps.userId }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - if (user == null) { - throw new Error('user not found'); + private moderationLogService: ModerationLogService, + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + const user = await this.usersRepository.findOneBy({ id: ps.userId }); + + if (user == null) { + throw new Error('user not found'); + } + + await this.usersRepository.update(user.id, { + isSilenced: false, + }); + + this.globalEventService.publishInternalEvent('userChangeSilencedState', { id: user.id, isSilenced: false }); + + this.moderationLogService.insertModerationLog(me, 'unsilence', { + targetId: user.id, + }); + }); } - - await Users.update(user.id, { - isSilenced: false, - }); - - publishInternalEvent('userChangeSilencedState', { id: user.id, isSilenced: false }); - - insertModerationLog(me, 'unsilence', { - targetId: user.id, - }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts b/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts index 5cf26251b..96283d251 100644 --- a/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts @@ -1,7 +1,9 @@ -import define from '../../define.js'; -import { Users } from '@/models/index.js'; -import { insertModerationLog } from '@/services/insert-moderation-log.js'; -import { doPostUnsuspend } from '@/services/unsuspend-user.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UsersRepository } from '@/models/index.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { UserSuspendService } from '@/core/UserSuspendService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -19,20 +21,31 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const user = await Users.findOneBy({ id: ps.userId }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - if (user == null) { - throw new Error('user not found'); + private userSuspendService: UserSuspendService, + private moderationLogService: ModerationLogService, + ) { + super(meta, paramDef, async (ps, me) => { + const user = await this.usersRepository.findOneBy({ id: ps.userId }); + + if (user == null) { + throw new Error('user not found'); + } + + await this.usersRepository.update(user.id, { + isSuspended: false, + }); + + this.moderationLogService.insertModerationLog(me, 'unsuspend', { + targetId: user.id, + }); + + this.userSuspendService.doPostUnsuspend(user); + }); } - - await Users.update(user.id, { - isSuspended: false, - }); - - insertModerationLog(me, 'unsuspend', { - targetId: user.id, - }); - - doPostUnsuspend(user); -}); +} 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 f14aa4105..968ed4d26 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -1,8 +1,10 @@ -import { Meta } from '@/models/entities/meta.js'; -import { insertModerationLog } from '@/services/insert-moderation-log.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { Meta } from '@/models/entities/Meta.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; import { DB_MAX_NOTE_TEXT_LENGTH } from '@/misc/hard-limits.js'; -import { db } from '@/db/postgre.js'; -import define from '../../define.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -107,340 +109,350 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const set = {} as Partial; +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.db) + private db: DataSource, + + private moderationLogService: ModerationLogService, + ) { + super(meta, paramDef, async (ps, me) => { + const set = {} as Partial; - if (typeof ps.disableRegistration === 'boolean') { - set.disableRegistration = ps.disableRegistration; - } + if (typeof ps.disableRegistration === 'boolean') { + set.disableRegistration = ps.disableRegistration; + } - if (typeof ps.disableLocalTimeline === 'boolean') { - set.disableLocalTimeline = ps.disableLocalTimeline; - } + if (typeof ps.disableLocalTimeline === 'boolean') { + set.disableLocalTimeline = ps.disableLocalTimeline; + } - if (typeof ps.disableGlobalTimeline === 'boolean') { - set.disableGlobalTimeline = ps.disableGlobalTimeline; - } + if (typeof ps.disableGlobalTimeline === 'boolean') { + set.disableGlobalTimeline = ps.disableGlobalTimeline; + } - if (typeof ps.useStarForReactionFallback === 'boolean') { - set.useStarForReactionFallback = ps.useStarForReactionFallback; - } + if (typeof ps.useStarForReactionFallback === 'boolean') { + set.useStarForReactionFallback = ps.useStarForReactionFallback; + } - if (Array.isArray(ps.pinnedUsers)) { - set.pinnedUsers = ps.pinnedUsers.filter(Boolean); - } + if (Array.isArray(ps.pinnedUsers)) { + set.pinnedUsers = ps.pinnedUsers.filter(Boolean); + } - if (Array.isArray(ps.hiddenTags)) { - set.hiddenTags = ps.hiddenTags.filter(Boolean); - } + if (Array.isArray(ps.hiddenTags)) { + set.hiddenTags = ps.hiddenTags.filter(Boolean); + } - if (Array.isArray(ps.blockedHosts)) { - set.blockedHosts = ps.blockedHosts.filter(Boolean); - } + if (Array.isArray(ps.blockedHosts)) { + set.blockedHosts = ps.blockedHosts.filter(Boolean); + } - if (ps.themeColor !== undefined) { - set.themeColor = ps.themeColor; - } + if (ps.themeColor !== undefined) { + set.themeColor = ps.themeColor; + } - if (ps.mascotImageUrl !== undefined) { - set.mascotImageUrl = ps.mascotImageUrl; - } + if (ps.mascotImageUrl !== undefined) { + set.mascotImageUrl = ps.mascotImageUrl; + } - if (ps.bannerUrl !== undefined) { - set.bannerUrl = ps.bannerUrl; - } + if (ps.bannerUrl !== undefined) { + set.bannerUrl = ps.bannerUrl; + } - if (ps.iconUrl !== undefined) { - set.iconUrl = ps.iconUrl; - } + if (ps.iconUrl !== undefined) { + set.iconUrl = ps.iconUrl; + } - if (ps.backgroundImageUrl !== undefined) { - set.backgroundImageUrl = ps.backgroundImageUrl; - } + if (ps.backgroundImageUrl !== undefined) { + set.backgroundImageUrl = ps.backgroundImageUrl; + } - if (ps.logoImageUrl !== undefined) { - set.logoImageUrl = ps.logoImageUrl; - } + if (ps.logoImageUrl !== undefined) { + set.logoImageUrl = ps.logoImageUrl; + } - if (ps.name !== undefined) { - set.name = ps.name; - } + if (ps.name !== undefined) { + set.name = ps.name; + } - if (ps.description !== undefined) { - set.description = ps.description; - } + if (ps.description !== undefined) { + set.description = ps.description; + } - if (ps.defaultLightTheme !== undefined) { - set.defaultLightTheme = ps.defaultLightTheme; - } + if (ps.defaultLightTheme !== undefined) { + set.defaultLightTheme = ps.defaultLightTheme; + } - if (ps.defaultDarkTheme !== undefined) { - set.defaultDarkTheme = ps.defaultDarkTheme; - } + if (ps.defaultDarkTheme !== undefined) { + set.defaultDarkTheme = ps.defaultDarkTheme; + } - if (ps.localDriveCapacityMb !== undefined) { - set.localDriveCapacityMb = ps.localDriveCapacityMb; - } + if (ps.localDriveCapacityMb !== undefined) { + set.localDriveCapacityMb = ps.localDriveCapacityMb; + } - if (ps.remoteDriveCapacityMb !== undefined) { - set.remoteDriveCapacityMb = ps.remoteDriveCapacityMb; - } + if (ps.remoteDriveCapacityMb !== undefined) { + set.remoteDriveCapacityMb = ps.remoteDriveCapacityMb; + } - if (ps.cacheRemoteFiles !== undefined) { - set.cacheRemoteFiles = ps.cacheRemoteFiles; - } + if (ps.cacheRemoteFiles !== undefined) { + set.cacheRemoteFiles = ps.cacheRemoteFiles; + } - if (ps.emailRequiredForSignup !== undefined) { - set.emailRequiredForSignup = ps.emailRequiredForSignup; - } + if (ps.emailRequiredForSignup !== undefined) { + set.emailRequiredForSignup = ps.emailRequiredForSignup; + } - if (ps.enableHcaptcha !== undefined) { - set.enableHcaptcha = ps.enableHcaptcha; - } + if (ps.enableHcaptcha !== undefined) { + set.enableHcaptcha = ps.enableHcaptcha; + } - if (ps.hcaptchaSiteKey !== undefined) { - set.hcaptchaSiteKey = ps.hcaptchaSiteKey; - } + if (ps.hcaptchaSiteKey !== undefined) { + set.hcaptchaSiteKey = ps.hcaptchaSiteKey; + } - if (ps.hcaptchaSecretKey !== undefined) { - set.hcaptchaSecretKey = ps.hcaptchaSecretKey; - } + if (ps.hcaptchaSecretKey !== undefined) { + set.hcaptchaSecretKey = ps.hcaptchaSecretKey; + } - if (ps.enableRecaptcha !== undefined) { - set.enableRecaptcha = ps.enableRecaptcha; - } + if (ps.enableRecaptcha !== undefined) { + set.enableRecaptcha = ps.enableRecaptcha; + } - if (ps.recaptchaSiteKey !== undefined) { - set.recaptchaSiteKey = ps.recaptchaSiteKey; - } + if (ps.recaptchaSiteKey !== undefined) { + set.recaptchaSiteKey = ps.recaptchaSiteKey; + } - if (ps.recaptchaSecretKey !== undefined) { - set.recaptchaSecretKey = ps.recaptchaSecretKey; - } + if (ps.recaptchaSecretKey !== undefined) { + set.recaptchaSecretKey = ps.recaptchaSecretKey; + } - if (ps.sensitiveMediaDetection !== undefined) { - set.sensitiveMediaDetection = ps.sensitiveMediaDetection; - } + if (ps.sensitiveMediaDetection !== undefined) { + set.sensitiveMediaDetection = ps.sensitiveMediaDetection; + } - if (ps.sensitiveMediaDetectionSensitivity !== undefined) { - set.sensitiveMediaDetectionSensitivity = ps.sensitiveMediaDetectionSensitivity; - } + if (ps.sensitiveMediaDetectionSensitivity !== undefined) { + set.sensitiveMediaDetectionSensitivity = ps.sensitiveMediaDetectionSensitivity; + } - if (ps.setSensitiveFlagAutomatically !== undefined) { - set.setSensitiveFlagAutomatically = ps.setSensitiveFlagAutomatically; - } + if (ps.setSensitiveFlagAutomatically !== undefined) { + set.setSensitiveFlagAutomatically = ps.setSensitiveFlagAutomatically; + } - if (ps.enableSensitiveMediaDetectionForVideos !== undefined) { - set.enableSensitiveMediaDetectionForVideos = ps.enableSensitiveMediaDetectionForVideos; - } + if (ps.enableSensitiveMediaDetectionForVideos !== undefined) { + set.enableSensitiveMediaDetectionForVideos = ps.enableSensitiveMediaDetectionForVideos; + } - if (ps.proxyAccountId !== undefined) { - set.proxyAccountId = ps.proxyAccountId; - } + if (ps.proxyAccountId !== undefined) { + set.proxyAccountId = ps.proxyAccountId; + } - if (ps.maintainerName !== undefined) { - set.maintainerName = ps.maintainerName; - } + if (ps.maintainerName !== undefined) { + set.maintainerName = ps.maintainerName; + } - if (ps.maintainerEmail !== undefined) { - set.maintainerEmail = ps.maintainerEmail; - } + if (ps.maintainerEmail !== undefined) { + set.maintainerEmail = ps.maintainerEmail; + } - if (Array.isArray(ps.langs)) { - set.langs = ps.langs.filter(Boolean); - } + if (Array.isArray(ps.langs)) { + set.langs = ps.langs.filter(Boolean); + } - if (Array.isArray(ps.pinnedPages)) { - set.pinnedPages = ps.pinnedPages.filter(Boolean); - } + if (Array.isArray(ps.pinnedPages)) { + set.pinnedPages = ps.pinnedPages.filter(Boolean); + } - if (ps.pinnedClipId !== undefined) { - set.pinnedClipId = ps.pinnedClipId; - } + if (ps.pinnedClipId !== undefined) { + set.pinnedClipId = ps.pinnedClipId; + } - if (ps.summalyProxy !== undefined) { - set.summalyProxy = ps.summalyProxy; - } + if (ps.summalyProxy !== undefined) { + set.summalyProxy = ps.summalyProxy; + } - if (ps.enableTwitterIntegration !== undefined) { - set.enableTwitterIntegration = ps.enableTwitterIntegration; - } + if (ps.enableTwitterIntegration !== undefined) { + set.enableTwitterIntegration = ps.enableTwitterIntegration; + } - if (ps.twitterConsumerKey !== undefined) { - set.twitterConsumerKey = ps.twitterConsumerKey; - } + if (ps.twitterConsumerKey !== undefined) { + set.twitterConsumerKey = ps.twitterConsumerKey; + } - if (ps.twitterConsumerSecret !== undefined) { - set.twitterConsumerSecret = ps.twitterConsumerSecret; - } + if (ps.twitterConsumerSecret !== undefined) { + set.twitterConsumerSecret = ps.twitterConsumerSecret; + } - if (ps.enableGithubIntegration !== undefined) { - set.enableGithubIntegration = ps.enableGithubIntegration; - } + if (ps.enableGithubIntegration !== undefined) { + set.enableGithubIntegration = ps.enableGithubIntegration; + } - if (ps.githubClientId !== undefined) { - set.githubClientId = ps.githubClientId; - } + if (ps.githubClientId !== undefined) { + set.githubClientId = ps.githubClientId; + } - if (ps.githubClientSecret !== undefined) { - set.githubClientSecret = ps.githubClientSecret; - } + if (ps.githubClientSecret !== undefined) { + set.githubClientSecret = ps.githubClientSecret; + } - if (ps.enableDiscordIntegration !== undefined) { - set.enableDiscordIntegration = ps.enableDiscordIntegration; - } + if (ps.enableDiscordIntegration !== undefined) { + set.enableDiscordIntegration = ps.enableDiscordIntegration; + } - if (ps.discordClientId !== undefined) { - set.discordClientId = ps.discordClientId; - } + if (ps.discordClientId !== undefined) { + set.discordClientId = ps.discordClientId; + } - if (ps.discordClientSecret !== undefined) { - set.discordClientSecret = ps.discordClientSecret; - } + if (ps.discordClientSecret !== undefined) { + set.discordClientSecret = ps.discordClientSecret; + } - if (ps.enableEmail !== undefined) { - set.enableEmail = ps.enableEmail; - } + if (ps.enableEmail !== undefined) { + set.enableEmail = ps.enableEmail; + } - if (ps.email !== undefined) { - set.email = ps.email; - } + if (ps.email !== undefined) { + set.email = ps.email; + } - if (ps.smtpSecure !== undefined) { - set.smtpSecure = ps.smtpSecure; - } + if (ps.smtpSecure !== undefined) { + set.smtpSecure = ps.smtpSecure; + } - if (ps.smtpHost !== undefined) { - set.smtpHost = ps.smtpHost; - } + if (ps.smtpHost !== undefined) { + set.smtpHost = ps.smtpHost; + } - if (ps.smtpPort !== undefined) { - set.smtpPort = ps.smtpPort; - } + if (ps.smtpPort !== undefined) { + set.smtpPort = ps.smtpPort; + } - if (ps.smtpUser !== undefined) { - set.smtpUser = ps.smtpUser; - } + if (ps.smtpUser !== undefined) { + set.smtpUser = ps.smtpUser; + } - if (ps.smtpPass !== undefined) { - set.smtpPass = ps.smtpPass; - } + if (ps.smtpPass !== undefined) { + set.smtpPass = ps.smtpPass; + } - if (ps.errorImageUrl !== undefined) { - set.errorImageUrl = ps.errorImageUrl; - } - - if (ps.enableServiceWorker !== undefined) { - set.enableServiceWorker = ps.enableServiceWorker; - } - - if (ps.swPublicKey !== undefined) { - set.swPublicKey = ps.swPublicKey; - } - - if (ps.swPrivateKey !== undefined) { - set.swPrivateKey = ps.swPrivateKey; - } - - if (ps.tosUrl !== undefined) { - set.ToSUrl = ps.tosUrl; - } - - if (ps.repositoryUrl !== undefined) { - set.repositoryUrl = ps.repositoryUrl; - } - - if (ps.feedbackUrl !== undefined) { - set.feedbackUrl = ps.feedbackUrl; - } - - if (ps.useObjectStorage !== undefined) { - set.useObjectStorage = ps.useObjectStorage; - } - - if (ps.objectStorageBaseUrl !== undefined) { - set.objectStorageBaseUrl = ps.objectStorageBaseUrl; - } - - if (ps.objectStorageBucket !== undefined) { - set.objectStorageBucket = ps.objectStorageBucket; - } - - if (ps.objectStoragePrefix !== undefined) { - set.objectStoragePrefix = ps.objectStoragePrefix; - } - - if (ps.objectStorageEndpoint !== undefined) { - set.objectStorageEndpoint = ps.objectStorageEndpoint; - } - - if (ps.objectStorageRegion !== undefined) { - set.objectStorageRegion = ps.objectStorageRegion; - } - - if (ps.objectStoragePort !== undefined) { - set.objectStoragePort = ps.objectStoragePort; - } - - if (ps.objectStorageAccessKey !== undefined) { - set.objectStorageAccessKey = ps.objectStorageAccessKey; - } - - if (ps.objectStorageSecretKey !== undefined) { - set.objectStorageSecretKey = ps.objectStorageSecretKey; - } - - if (ps.objectStorageUseSSL !== undefined) { - set.objectStorageUseSSL = ps.objectStorageUseSSL; - } - - if (ps.objectStorageUseProxy !== undefined) { - set.objectStorageUseProxy = ps.objectStorageUseProxy; - } - - if (ps.objectStorageSetPublicRead !== undefined) { - set.objectStorageSetPublicRead = ps.objectStorageSetPublicRead; - } - - if (ps.objectStorageS3ForcePathStyle !== undefined) { - set.objectStorageS3ForcePathStyle = ps.objectStorageS3ForcePathStyle; - } - - if (ps.deeplAuthKey !== undefined) { - if (ps.deeplAuthKey === '') { - set.deeplAuthKey = null; - } else { - set.deeplAuthKey = ps.deeplAuthKey; - } - } - - if (ps.deeplIsPro !== undefined) { - set.deeplIsPro = ps.deeplIsPro; - } - - if (ps.enableIpLogging !== undefined) { - set.enableIpLogging = ps.enableIpLogging; - } - - if (ps.enableActiveEmailValidation !== undefined) { - set.enableActiveEmailValidation = ps.enableActiveEmailValidation; - } - - await db.transaction(async transactionalEntityManager => { - const metas = await transactionalEntityManager.find(Meta, { - order: { - id: 'DESC', - }, + if (ps.errorImageUrl !== undefined) { + set.errorImageUrl = ps.errorImageUrl; + } + + if (ps.enableServiceWorker !== undefined) { + set.enableServiceWorker = ps.enableServiceWorker; + } + + if (ps.swPublicKey !== undefined) { + set.swPublicKey = ps.swPublicKey; + } + + if (ps.swPrivateKey !== undefined) { + set.swPrivateKey = ps.swPrivateKey; + } + + if (ps.tosUrl !== undefined) { + set.ToSUrl = ps.tosUrl; + } + + if (ps.repositoryUrl !== undefined) { + set.repositoryUrl = ps.repositoryUrl; + } + + if (ps.feedbackUrl !== undefined) { + set.feedbackUrl = ps.feedbackUrl; + } + + if (ps.useObjectStorage !== undefined) { + set.useObjectStorage = ps.useObjectStorage; + } + + if (ps.objectStorageBaseUrl !== undefined) { + set.objectStorageBaseUrl = ps.objectStorageBaseUrl; + } + + if (ps.objectStorageBucket !== undefined) { + set.objectStorageBucket = ps.objectStorageBucket; + } + + if (ps.objectStoragePrefix !== undefined) { + set.objectStoragePrefix = ps.objectStoragePrefix; + } + + if (ps.objectStorageEndpoint !== undefined) { + set.objectStorageEndpoint = ps.objectStorageEndpoint; + } + + if (ps.objectStorageRegion !== undefined) { + set.objectStorageRegion = ps.objectStorageRegion; + } + + if (ps.objectStoragePort !== undefined) { + set.objectStoragePort = ps.objectStoragePort; + } + + if (ps.objectStorageAccessKey !== undefined) { + set.objectStorageAccessKey = ps.objectStorageAccessKey; + } + + if (ps.objectStorageSecretKey !== undefined) { + set.objectStorageSecretKey = ps.objectStorageSecretKey; + } + + if (ps.objectStorageUseSSL !== undefined) { + set.objectStorageUseSSL = ps.objectStorageUseSSL; + } + + if (ps.objectStorageUseProxy !== undefined) { + set.objectStorageUseProxy = ps.objectStorageUseProxy; + } + + if (ps.objectStorageSetPublicRead !== undefined) { + set.objectStorageSetPublicRead = ps.objectStorageSetPublicRead; + } + + if (ps.objectStorageS3ForcePathStyle !== undefined) { + set.objectStorageS3ForcePathStyle = ps.objectStorageS3ForcePathStyle; + } + + if (ps.deeplAuthKey !== undefined) { + if (ps.deeplAuthKey === '') { + set.deeplAuthKey = null; + } else { + set.deeplAuthKey = ps.deeplAuthKey; + } + } + + if (ps.deeplIsPro !== undefined) { + set.deeplIsPro = ps.deeplIsPro; + } + + if (ps.enableIpLogging !== undefined) { + set.enableIpLogging = ps.enableIpLogging; + } + + if (ps.enableActiveEmailValidation !== undefined) { + set.enableActiveEmailValidation = ps.enableActiveEmailValidation; + } + + await this.db.transaction(async transactionalEntityManager => { + const metas = await transactionalEntityManager.find(Meta, { + order: { + id: 'DESC', + }, + }); + + const meta = metas[0]; + + if (meta) { + await transactionalEntityManager.update(Meta, meta.id, set); + } else { + await transactionalEntityManager.save(Meta, set); + } + }); + + this.moderationLogService.insertModerationLog(me, 'updateMeta'); }); - - const meta = metas[0]; - - if (meta) { - await transactionalEntityManager.update(Meta, meta.id, set); - } else { - await transactionalEntityManager.save(Meta, set); - } - }); - - insertModerationLog(me, 'updateMeta'); -}); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/update-user-note.ts b/packages/backend/src/server/api/endpoints/admin/update-user-note.ts index fa21ab783..1ea0e6aac 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-user-note.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-user-note.ts @@ -1,5 +1,7 @@ -import { UserProfiles, Users } from '@/models/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UserProfilesRepository, UsersRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], @@ -18,14 +20,25 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const user = await Users.findOneBy({ id: ps.userId }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - if (user == null) { - throw new Error('user not found'); + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const user = await this.usersRepository.findOneBy({ id: ps.userId }); + + if (user == null) { + throw new Error('user not found'); + } + + await this.userProfilesRepository.update({ userId: user.id }, { + moderationNote: ps.text, + }); + }); } - - await UserProfiles.update({ userId: user.id }, { - moderationNote: ps.text, - }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/admin/vacuum.ts b/packages/backend/src/server/api/endpoints/admin/vacuum.ts deleted file mode 100644 index 0546acfac..000000000 --- a/packages/backend/src/server/api/endpoints/admin/vacuum.ts +++ /dev/null @@ -1,36 +0,0 @@ -import define from '../../define.js'; -import { insertModerationLog } from '@/services/insert-moderation-log.js'; -import { db } from '@/db/postgre.js'; - -export const meta = { - tags: ['admin'], - - requireCredential: true, - requireModerator: true, -} as const; - -export const paramDef = { - type: 'object', - properties: { - full: { type: 'boolean' }, - analyze: { type: 'boolean' }, - }, - required: ['full', 'analyze'], -} as const; - -// eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const params: string[] = []; - - if (ps.full) { - params.push('FULL'); - } - - if (ps.analyze) { - params.push('ANALYZE'); - } - - db.query('VACUUM ' + params.join(' ')); - - insertModerationLog(me, 'vacuum', ps); -}); diff --git a/packages/backend/src/server/api/endpoints/announcements.ts b/packages/backend/src/server/api/endpoints/announcements.ts index 23cb93c9a..aa44dfd5d 100644 --- a/packages/backend/src/server/api/endpoints/announcements.ts +++ b/packages/backend/src/server/api/endpoints/announcements.ts @@ -1,6 +1,8 @@ -import { Announcements, AnnouncementReads } from '@/models/index.js'; -import define from '../define.js'; -import { makePaginationQuery } from '../common/make-pagination-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { DI } from '@/di-symbols.js'; +import { AnnouncementReadsRepository, AnnouncementsRepository } from '@/models'; export const meta = { tags: ['meta'], @@ -63,24 +65,37 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const query = makePaginationQuery(Announcements.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.announcementsRepository) + private announcementsRepository: AnnouncementsRepository, - const announcements = await query.take(ps.limit).getMany(); + @Inject(DI.announcementReadsRepository) + private announcementReadsRepository: AnnouncementReadsRepository, - if (user) { - const reads = (await AnnouncementReads.findBy({ - userId: user.id, - })).map(x => x.announcementId); + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); - for (const announcement of announcements) { - (announcement as any).isRead = reads.includes(announcement.id); - } + const announcements = await query.take(ps.limit).getMany(); + + if (me) { + const reads = (await this.announcementReadsRepository.findBy({ + userId: me.id, + })).map(x => x.announcementId); + + for (const announcement of announcements) { + (announcement as any).isRead = reads.includes(announcement.id); + } + } + + return (ps.withUnreads ? announcements.filter((a: any) => !a.isRead) : announcements).map((a) => ({ + ...a, + createdAt: a.createdAt.toISOString(), + updatedAt: a.updatedAt?.toISOString() ?? null, + })); + }); } - - return (ps.withUnreads ? announcements.filter((a: any) => !a.isRead) : announcements).map((a) => ({ - ...a, - createdAt: a.createdAt.toISOString(), - updatedAt: a.updatedAt?.toISOString() ?? null, - })); -}); +} diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts index 7a4923b94..56bd343d5 100644 --- a/packages/backend/src/server/api/endpoints/antennas/create.ts +++ b/packages/backend/src/server/api/endpoints/antennas/create.ts @@ -1,8 +1,11 @@ -import define from '../../define.js'; -import { genId } from '@/misc/gen-id.js'; -import { Antennas, UserLists, UserGroupJoinings } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { IdService } from '@/core/IdService.js'; +import { UserListsRepository, UserGroupJoiningsRepository, AntennasRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { AntennaEntityService } from '@/core/entities/AntennaEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { publishInternalEvent } from '@/services/stream.js'; export const meta = { tags: ['antennas'], @@ -61,48 +64,66 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - let userList; - let userGroupJoining; +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.antennasRepository) + private antennasRepository: AntennasRepository, - if (ps.src === 'list' && ps.userListId) { - userList = await UserLists.findOneBy({ - id: ps.userListId, - userId: user.id, + @Inject(DI.userListsRepository) + private userListsRepository: UserListsRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + private antennaEntityService: AntennaEntityService, + private idService: IdService, + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + let userList; + let userGroupJoining; + + if (ps.src === 'list' && ps.userListId) { + userList = await this.userListsRepository.findOneBy({ + id: ps.userListId, + userId: me.id, + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchUserList); + } + } else if (ps.src === 'group' && ps.userGroupId) { + userGroupJoining = await this.userGroupJoiningsRepository.findOneBy({ + userGroupId: ps.userGroupId, + userId: me.id, + }); + + if (userGroupJoining == null) { + throw new ApiError(meta.errors.noSuchUserGroup); + } + } + + const antenna = await this.antennasRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: me.id, + name: ps.name, + src: ps.src, + userListId: userList ? userList.id : null, + userGroupJoiningId: userGroupJoining ? userGroupJoining.id : null, + keywords: ps.keywords, + excludeKeywords: ps.excludeKeywords, + users: ps.users, + caseSensitive: ps.caseSensitive, + withReplies: ps.withReplies, + withFile: ps.withFile, + notify: ps.notify, + }).then(x => this.antennasRepository.findOneByOrFail(x.identifiers[0])); + + this.globalEventService.publishInternalEvent('antennaCreated', antenna); + + return await this.antennaEntityService.pack(antenna); }); - - if (userList == null) { - throw new ApiError(meta.errors.noSuchUserList); - } - } else if (ps.src === 'group' && ps.userGroupId) { - userGroupJoining = await UserGroupJoinings.findOneBy({ - userGroupId: ps.userGroupId, - userId: user.id, - }); - - if (userGroupJoining == null) { - throw new ApiError(meta.errors.noSuchUserGroup); - } } - - const antenna = await Antennas.insert({ - id: genId(), - createdAt: new Date(), - userId: user.id, - name: ps.name, - src: ps.src, - userListId: userList ? userList.id : null, - userGroupJoiningId: userGroupJoining ? userGroupJoining.id : null, - keywords: ps.keywords, - excludeKeywords: ps.excludeKeywords, - users: ps.users, - caseSensitive: ps.caseSensitive, - withReplies: ps.withReplies, - withFile: ps.withFile, - notify: ps.notify, - }).then(x => Antennas.findOneByOrFail(x.identifiers[0])); - - publishInternalEvent('antennaCreated', antenna); - - return await Antennas.pack(antenna); -}); +} diff --git a/packages/backend/src/server/api/endpoints/antennas/delete.ts b/packages/backend/src/server/api/endpoints/antennas/delete.ts index ced34ba31..127aca0c3 100644 --- a/packages/backend/src/server/api/endpoints/antennas/delete.ts +++ b/packages/backend/src/server/api/endpoints/antennas/delete.ts @@ -1,7 +1,9 @@ -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AntennasRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { Antennas } from '@/models/index.js'; -import { publishInternalEvent } from '@/services/stream.js'; export const meta = { tags: ['antennas'], @@ -28,17 +30,27 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const antenna = await Antennas.findOneBy({ - id: ps.antennaId, - userId: user.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.antennasRepository) + private antennasRepository: AntennasRepository, - if (antenna == null) { - throw new ApiError(meta.errors.noSuchAntenna); + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + const antenna = await this.antennasRepository.findOneBy({ + id: ps.antennaId, + userId: me.id, + }); + + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); + } + + await this.antennasRepository.delete(antenna.id); + + this.globalEventService.publishInternalEvent('antennaDeleted', antenna); + }); } - - await Antennas.delete(antenna.id); - - publishInternalEvent('antennaDeleted', antenna); -}); +} diff --git a/packages/backend/src/server/api/endpoints/antennas/list.ts b/packages/backend/src/server/api/endpoints/antennas/list.ts index c519b452e..bdc44895c 100644 --- a/packages/backend/src/server/api/endpoints/antennas/list.ts +++ b/packages/backend/src/server/api/endpoints/antennas/list.ts @@ -1,5 +1,8 @@ -import define from '../../define.js'; -import { Antennas } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AntennasRepository } from '@/models/index.js'; +import { AntennaEntityService } from '@/core/entities/AntennaEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['antennas', 'account'], @@ -26,10 +29,20 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const antennas = await Antennas.findBy({ - userId: me.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.antennasRepository) + private antennasRepository: AntennasRepository, - return await Promise.all(antennas.map(x => Antennas.pack(x))); -}); + private antennaEntityService: AntennaEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const antennas = await this.antennasRepository.findBy({ + userId: me.id, + }); + + return await Promise.all(antennas.map(x => this.antennaEntityService.pack(x))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index 8aac55b4a..eba42afe5 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -1,11 +1,11 @@ -import define from '../../define.js'; -import readNote from '@/services/note/read.js'; -import { Antennas, Notes, AntennaNotes } from '@/models/index.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; -import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; -import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { NotesRepository, AntennaNotesRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { NoteReadService } from '@/core/NoteReadService.js'; +import { DI } from '@/di-symbols.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { ApiError } from '../../error.js'; -import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; export const meta = { tags: ['antennas', 'account', 'notes'], @@ -47,43 +47,61 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const antenna = await Antennas.findOneBy({ - id: ps.antennaId, - userId: user.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, - if (antenna == null) { - throw new ApiError(meta.errors.noSuchAntenna); + @Inject(DI.antennasRepository) + private antennasRepository: AntennasRepository, + + @Inject(DI.antennaNotesRepository) + private antennaNotesRepository: AntennaNotesRepository, + + private noteEntityService: NoteEntityService, + private queryService: QueryService, + private noteReadService: NoteReadService, + ) { + super(meta, paramDef, async (ps, me) => { + const antenna = await this.antennasRepository.findOneBy({ + id: ps.antennaId, + userId: me.id, + }); + + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); + } + + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .innerJoin(this.antennaNotesRepository.metadata.targetName, 'antennaNote', 'antennaNote.noteId = note.id') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') + .andWhere('antennaNote.antennaId = :antennaId', { antennaId: antenna.id }); + + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + + const notes = await query + .take(ps.limit) + .getMany(); + + if (notes.length > 0) { + this.noteReadService.read(me.id, notes); + } + + return await this.noteEntityService.packMany(notes, me); + }); } - - const query = makePaginationQuery(Notes.createQueryBuilder('note'), - ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .innerJoin(AntennaNotes.metadata.targetName, 'antennaNote', 'antennaNote.noteId = note.id') - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') - .andWhere('antennaNote.antennaId = :antennaId', { antennaId: antenna.id }); - - generateVisibilityQuery(query, user); - generateMutedUserQuery(query, user); - generateBlockedUserQuery(query, user); - - const notes = await query - .take(ps.limit) - .getMany(); - - if (notes.length > 0) { - readNote(user.id, notes); - } - - return await Notes.packMany(notes, user); -}); +} diff --git a/packages/backend/src/server/api/endpoints/antennas/show.ts b/packages/backend/src/server/api/endpoints/antennas/show.ts index dd693789c..8bd8ad124 100644 --- a/packages/backend/src/server/api/endpoints/antennas/show.ts +++ b/packages/backend/src/server/api/endpoints/antennas/show.ts @@ -1,6 +1,9 @@ -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AntennasRepository } from '@/models/index.js'; +import { AntennaEntityService } from '@/core/entities/AntennaEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { Antennas } from '@/models/index.js'; export const meta = { tags: ['antennas', 'account'], @@ -33,16 +36,26 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - // Fetch the antenna - const antenna = await Antennas.findOneBy({ - id: ps.antennaId, - userId: me.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.antennasRepository) + private antennasRepository: AntennasRepository, - if (antenna == null) { - throw new ApiError(meta.errors.noSuchAntenna); + private antennaEntityService: AntennaEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch the antenna + const antenna = await this.antennasRepository.findOneBy({ + id: ps.antennaId, + userId: me.id, + }); + + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); + } + + return await this.antennaEntityService.pack(antenna); + }); } - - return await Antennas.pack(antenna); -}); +} diff --git a/packages/backend/src/server/api/endpoints/antennas/update.ts b/packages/backend/src/server/api/endpoints/antennas/update.ts index edfedc175..59bba04ee 100644 --- a/packages/backend/src/server/api/endpoints/antennas/update.ts +++ b/packages/backend/src/server/api/endpoints/antennas/update.ts @@ -1,7 +1,10 @@ -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AntennasRepository, UserListsRepository, UserGroupJoiningsRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { AntennaEntityService } from '@/core/entities/AntennaEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { Antennas, UserLists, UserGroupJoinings } from '@/models/index.js'; -import { publishInternalEvent } from '@/services/stream.js'; export const meta = { tags: ['antennas'], @@ -67,55 +70,72 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - // Fetch the antenna - const antenna = await Antennas.findOneBy({ - id: ps.antennaId, - userId: user.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.antennasRepository) + private antennasRepository: AntennasRepository, - if (antenna == null) { - throw new ApiError(meta.errors.noSuchAntenna); - } + @Inject(DI.userListsRepository) + private userListsRepository: UserListsRepository, - let userList; - let userGroupJoining; + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + private antennaEntityService: AntennaEntityService, + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch the antenna + const antenna = await this.antennasRepository.findOneBy({ + id: ps.antennaId, + userId: me.id, + }); - if (ps.src === 'list' && ps.userListId) { - userList = await UserLists.findOneBy({ - id: ps.userListId, - userId: user.id, + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); + } + + let userList; + let userGroupJoining; + + if (ps.src === 'list' && ps.userListId) { + userList = await this.userListsRepository.findOneBy({ + id: ps.userListId, + userId: me.id, + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchUserList); + } + } else if (ps.src === 'group' && ps.userGroupId) { + userGroupJoining = await this.userGroupJoiningsRepository.findOneBy({ + userGroupId: ps.userGroupId, + userId: me.id, + }); + + if (userGroupJoining == null) { + throw new ApiError(meta.errors.noSuchUserGroup); + } + } + + await this.antennasRepository.update(antenna.id, { + name: ps.name, + src: ps.src, + userListId: userList ? userList.id : null, + userGroupJoiningId: userGroupJoining ? userGroupJoining.id : null, + keywords: ps.keywords, + excludeKeywords: ps.excludeKeywords, + users: ps.users, + caseSensitive: ps.caseSensitive, + withReplies: ps.withReplies, + withFile: ps.withFile, + notify: ps.notify, + }); + + this.globalEventService.publishInternalEvent('antennaUpdated', await this.antennasRepository.findOneByOrFail({ id: antenna.id })); + + return await this.antennaEntityService.pack(antenna.id); }); - - if (userList == null) { - throw new ApiError(meta.errors.noSuchUserList); - } - } else if (ps.src === 'group' && ps.userGroupId) { - userGroupJoining = await UserGroupJoinings.findOneBy({ - userGroupId: ps.userGroupId, - userId: user.id, - }); - - if (userGroupJoining == null) { - throw new ApiError(meta.errors.noSuchUserGroup); - } } - - await Antennas.update(antenna.id, { - name: ps.name, - src: ps.src, - userListId: userList ? userList.id : null, - userGroupJoiningId: userGroupJoining ? userGroupJoining.id : null, - keywords: ps.keywords, - excludeKeywords: ps.excludeKeywords, - users: ps.users, - caseSensitive: ps.caseSensitive, - withReplies: ps.withReplies, - withFile: ps.withFile, - notify: ps.notify, - }); - - publishInternalEvent('antennaUpdated', await Antennas.findOneByOrFail({ id: antenna.id })); - - return await Antennas.pack(antenna.id); -}); +} diff --git a/packages/backend/src/server/api/endpoints/ap/get.ts b/packages/backend/src/server/api/endpoints/ap/get.ts index 0cbe7ebc6..3d4c85e50 100644 --- a/packages/backend/src/server/api/endpoints/ap/get.ts +++ b/packages/backend/src/server/api/endpoints/ap/get.ts @@ -1,7 +1,8 @@ -import define from '../../define.js'; -import Resolver from '@/remote/activitypub/resolver.js'; -import { ApiError } from '../../error.js'; +import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApResolverService } from '@/core/remote/activitypub/ApResolverService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['federation'], @@ -31,8 +32,15 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - const resolver = new Resolver(); - const object = await resolver.resolve(ps.uri); - return object; -}); +@Injectable() +export default class extends Endpoint { + constructor( + private apResolverService: ApResolverService, + ) { + super(meta, paramDef, async (ps, me) => { + const resolver = this.apResolverService.createResolver(); + const object = await resolver.resolve(ps.uri); + return object; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index 6442a1412..e291b5908 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -1,18 +1,21 @@ -import define from '../../define.js'; -import config from '@/config/index.js'; -import { createPerson } from '@/remote/activitypub/models/person.js'; -import { createNote } from '@/remote/activitypub/models/note.js'; -import DbResolver from '@/remote/activitypub/db-resolver.js'; -import Resolver from '@/remote/activitypub/resolver.js'; -import { ApiError } from '../../error.js'; -import { extractDbHost } from '@/misc/convert-host.js'; -import { Users, Notes } from '@/models/index.js'; -import { Note } from '@/models/entities/note.js'; -import { CacheableLocalUser, User } from '@/models/entities/user.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { isActor, isPost, getApId } from '@/remote/activitypub/type.js'; +import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; -import { SchemaType } from '@/misc/schema.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UsersRepository, NotesRepository } from '@/models/index.js'; +import type { Note } from '@/models/entities/Note.js'; +import type { CacheableLocalUser, User } from '@/models/entities/User.js'; +import { isActor, isPost, getApId } from '@/core/remote/activitypub/type.js'; +import type { SchemaType } from '@/misc/schema.js'; +import { ApResolverService } from '@/core/remote/activitypub/ApResolverService.js'; +import { ApDbResolverService } from '@/core/remote/activitypub/ApDbResolverService.js'; +import { MetaService } from '@/core/MetaService.js'; +import { ApPersonService } from '@/core/remote/activitypub/models/ApPersonService.js'; +import { ApNoteService } from '@/core/remote/activitypub/models/ApNoteService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['federation'], @@ -47,8 +50,8 @@ export const meta = { type: 'object', optional: false, nullable: false, ref: 'UserDetailedNotMe', - } - } + }, + }, }, { type: 'object', @@ -62,9 +65,9 @@ export const meta = { type: 'object', optional: false, nullable: false, ref: 'Note', - } - } - } + }, + }, + }, ], }, } as const; @@ -78,70 +81,88 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const object = await fetchAny(ps.uri, me); - if (object) { - return object; - } else { - throw new ApiError(meta.errors.noSuchObject); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private utilityService: UtilityService, + private userEntityService: UserEntityService, + private noteEntityService: NoteEntityService, + private metaService: MetaService, + private apResolverService: ApResolverService, + private apDbResolverService: ApDbResolverService, + private apPersonService: ApPersonService, + private apNoteService: ApNoteService, + ) { + super(meta, paramDef, async (ps, me) => { + const object = await this.fetchAny(ps.uri, me); + if (object) { + return object; + } else { + throw new ApiError(meta.errors.noSuchObject); + } + }); } -}); -/*** - * URIからUserかNoteを解決する - */ -async function fetchAny(uri: string, me: CacheableLocalUser | null | undefined): Promise | null> { + /*** + * URIからUserかNoteを解決する + */ + private async fetchAny(uri: string, me: CacheableLocalUser | null | undefined): Promise | null> { // ブロックしてたら中断 - const fetchedMeta = await fetchMeta(); - if (fetchedMeta.blockedHosts.includes(extractDbHost(uri))) return null; + const fetchedMeta = await this.metaService.fetch(); + if (fetchedMeta.blockedHosts.includes(this.utilityService.extractDbHost(uri))) return null; - const dbResolver = new DbResolver(); - - let local = await mergePack(me, ...await Promise.all([ - dbResolver.getUserFromApId(uri), - dbResolver.getNoteFromApId(uri), - ])); - if (local != null) return local; - - // リモートから一旦オブジェクトフェッチ - const resolver = new Resolver(); - const object = await resolver.resolve(uri) as any; - - // /@user のような正規id以外で取得できるURIが指定されていた場合、ここで初めて正規URIが確定する - // これはDBに存在する可能性があるため再度DB検索 - if (uri !== object.id) { - local = await mergePack(me, ...await Promise.all([ - dbResolver.getUserFromApId(object.id), - dbResolver.getNoteFromApId(object.id), + let local = await this.mergePack(me, ...await Promise.all([ + this.apDbResolverService.getUserFromApId(uri), + this.apDbResolverService.getNoteFromApId(uri), ])); if (local != null) return local; - } - return await mergePack( - me, - isActor(object) ? await createPerson(getApId(object)) : null, - isPost(object) ? await createNote(getApId(object), undefined, true) : null, - ); -} + // リモートから一旦オブジェクトフェッチ + const resolver = this.apResolverService.createResolver(); + const object = await resolver.resolve(uri) as any; -async function mergePack(me: CacheableLocalUser | null | undefined, user: User | null | undefined, note: Note | null | undefined): Promise | null> { - if (user != null) { - return { - type: 'User', - object: await Users.pack(user, me, { detail: true }), - }; - } else if (note != null) { - try { - const object = await Notes.pack(note, me, { detail: true }); - - return { - type: 'Note', - object, - }; - } catch (e) { - return null; + // /@user のような正規id以外で取得できるURIが指定されていた場合、ここで初めて正規URIが確定する + // これはDBに存在する可能性があるため再度DB検索 + if (uri !== object.id) { + local = await this.mergePack(me, ...await Promise.all([ + this.apDbResolverService.getUserFromApId(object.id), + this.apDbResolverService.getNoteFromApId(object.id), + ])); + if (local != null) return local; } + + return await this.mergePack( + me, + isActor(object) ? await this.apPersonService.createPerson(getApId(object)) : null, + isPost(object) ? await this.apNoteService.createNote(getApId(object), undefined, true) : null, + ); } - return null; + private async mergePack(me: CacheableLocalUser | null | undefined, user: User | null | undefined, note: Note | null | undefined): Promise | null> { + if (user != null) { + return { + type: 'User', + object: await this.userEntityService.pack(user, me, { detail: true }), + }; + } else if (note != null) { + try { + const object = await this.noteEntityService.pack(note, me, { detail: true }); + + return { + type: 'Note', + object, + }; + } catch (e) { + return null; + } + } + + return null; + } } diff --git a/packages/backend/src/server/api/endpoints/app/create.ts b/packages/backend/src/server/api/endpoints/app/create.ts index a0a735082..f52d18f7f 100644 --- a/packages/backend/src/server/api/endpoints/app/create.ts +++ b/packages/backend/src/server/api/endpoints/app/create.ts @@ -1,8 +1,11 @@ -import define from '../../define.js'; -import { Apps } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; -import { unique } from '@/prelude/array.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AppsRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { unique } from '@/misc/prelude/array.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; +import { AppEntityService } from '@/core/entities/AppEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['app'], @@ -30,27 +33,38 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - // Generate secret - const secret = secureRndstr(32, true); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.appsRepository) + private appsRepository: AppsRepository, - // for backward compatibility - const permission = unique(ps.permission.map(v => v.replace(/^(.+)(\/|-)(read|write)$/, '$3:$1'))); + private appEntityService: AppEntityService, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + // Generate secret + const secret = secureRndstr(32, true); - // Create account - const app = await Apps.insert({ - id: genId(), - createdAt: new Date(), - userId: user ? user.id : null, - name: ps.name, - description: ps.description, - permission, - callbackUrl: ps.callbackUrl, - secret: secret, - }).then(x => Apps.findOneByOrFail(x.identifiers[0])); + // for backward compatibility + const permission = unique(ps.permission.map(v => v.replace(/^(.+)(\/|-)(read|write)$/, '$3:$1'))); - return await Apps.pack(app, null, { - detail: true, - includeSecret: true, - }); -}); + // Create account + const app = await this.appsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: me ? me.id : null, + name: ps.name, + description: ps.description, + permission, + callbackUrl: ps.callbackUrl, + secret: secret, + }).then(x => this.appsRepository.findOneByOrFail(x.identifiers[0])); + + return await this.appEntityService.pack(app, null, { + detail: true, + includeSecret: true, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/app/show.ts b/packages/backend/src/server/api/endpoints/app/show.ts index 451969d97..f94fed534 100644 --- a/packages/backend/src/server/api/endpoints/app/show.ts +++ b/packages/backend/src/server/api/endpoints/app/show.ts @@ -1,6 +1,9 @@ -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AppsRepository } from '@/models/index.js'; +import { AppEntityService } from '@/core/entities/AppEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { Apps } from '@/models/index.js'; export const meta = { tags: ['app'], @@ -29,18 +32,28 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user, token) => { - const isSecure = user != null && token == null; +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.appsRepository) + private appsRepository: AppsRepository, - // Lookup app - const ap = await Apps.findOneBy({ id: ps.appId }); + private appEntityService: AppEntityService, + ) { + super(meta, paramDef, async (ps, user, token) => { + const isSecure = user != null && token == null; - if (ap == null) { - throw new ApiError(meta.errors.noSuchApp); + // Lookup app + const ap = await this.appsRepository.findOneBy({ id: ps.appId }); + + if (ap == null) { + throw new ApiError(meta.errors.noSuchApp); + } + + return await this.appEntityService.pack(ap, user, { + detail: true, + includeSecret: isSecure && (ap.userId === user!.id), + }); + }); } - - return await Apps.pack(ap, user, { - detail: true, - includeSecret: isSecure && (ap.userId === user!.id), - }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/auth/accept.ts b/packages/backend/src/server/api/endpoints/auth/accept.ts index b5c06792b..6032b59be 100644 --- a/packages/backend/src/server/api/endpoints/auth/accept.ts +++ b/packages/backend/src/server/api/endpoints/auth/accept.ts @@ -1,9 +1,11 @@ import * as crypto from 'node:crypto'; -import define from '../../define.js'; -import { ApiError } from '../../error.js'; -import { AuthSessions, AccessTokens, Apps } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AuthSessionsRepository, AppsRepository, AccessTokensRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['auth'], @@ -30,49 +32,65 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - // Fetch token - const session = await AuthSessions - .findOneBy({ token: ps.token }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.appsRepository) + private appsRepository: AppsRepository, - if (session == null) { - throw new ApiError(meta.errors.noSuchSession); - } + @Inject(DI.authSessionsRepository) + private authSessionsRepository: AuthSessionsRepository, - // Generate access token - const accessToken = secureRndstr(32, true); + @Inject(DI.accessTokensRepository) + private accessTokensRepository: AccessTokensRepository, - // Fetch exist access token - const exist = await AccessTokens.findOneBy({ - appId: session.appId, - userId: user.id, - }); + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch token + const session = await this.authSessionsRepository + .findOneBy({ token: ps.token }); - if (exist == null) { - // Lookup app - const app = await Apps.findOneByOrFail({ id: session.appId }); + if (session == null) { + throw new ApiError(meta.errors.noSuchSession); + } - // Generate Hash - const sha256 = crypto.createHash('sha256'); - sha256.update(accessToken + app.secret); - const hash = sha256.digest('hex'); + // Generate access token + const accessToken = secureRndstr(32, true); - const now = new Date(); + // Fetch exist access token + const exist = await this.accessTokensRepository.findOneBy({ + appId: session.appId, + userId: me.id, + }); - // Insert access token doc - await AccessTokens.insert({ - id: genId(), - createdAt: now, - lastUsedAt: now, - appId: session.appId, - userId: user.id, - token: accessToken, - hash: hash, + if (exist == null) { + // Lookup app + const app = await this.appsRepository.findOneByOrFail({ id: session.appId }); + + // Generate Hash + const sha256 = crypto.createHash('sha256'); + sha256.update(accessToken + app.secret); + const hash = sha256.digest('hex'); + + const now = new Date(); + + // Insert access token doc + await this.accessTokensRepository.insert({ + id: this.idService.genId(), + createdAt: now, + lastUsedAt: now, + appId: session.appId, + userId: me.id, + token: accessToken, + hash: hash, + }); + } + + // Update session + await this.authSessionsRepository.update(session.id, { + userId: me.id, + }); }); } - - // Update session - await AuthSessions.update(session.id, { - userId: user.id, - }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/auth/session/generate.ts b/packages/backend/src/server/api/endpoints/auth/session/generate.ts index 717c3e508..7f8325dbb 100644 --- a/packages/backend/src/server/api/endpoints/auth/session/generate.ts +++ b/packages/backend/src/server/api/endpoints/auth/session/generate.ts @@ -1,9 +1,11 @@ import { v4 as uuid } from 'uuid'; -import config from '@/config/index.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AppsRepository, AuthSessionsRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import { Apps, AuthSessions } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; export const meta = { tags: ['auth'], @@ -44,29 +46,45 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - // Lookup app - const app = await Apps.findOneBy({ - secret: ps.appSecret, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.config) + private config: Config, - if (app == null) { - throw new ApiError(meta.errors.noSuchApp); + @Inject(DI.appsRepository) + private appsRepository: AppsRepository, + + @Inject(DI.authSessionsRepository) + private authSessionsRepository: AuthSessionsRepository, + + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + // Lookup app + const app = await this.appsRepository.findOneBy({ + secret: ps.appSecret, + }); + + if (app == null) { + throw new ApiError(meta.errors.noSuchApp); + } + + // Generate token + const token = uuid(); + + // Create session token document + const doc = await this.authSessionsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + appId: app.id, + token: token, + }).then(x => this.authSessionsRepository.findOneByOrFail(x.identifiers[0])); + + return { + token: doc.token, + url: `${this.config.authUrl}/${doc.token}`, + }; + }); } - - // Generate token - const token = uuid(); - - // Create session token document - const doc = await AuthSessions.insert({ - id: genId(), - createdAt: new Date(), - appId: app.id, - token: token, - }).then(x => AuthSessions.findOneByOrFail(x.identifiers[0])); - - return { - token: doc.token, - url: `${config.authUrl}/${doc.token}`, - }; -}); +} diff --git a/packages/backend/src/server/api/endpoints/auth/session/show.ts b/packages/backend/src/server/api/endpoints/auth/session/show.ts index 3f3a4d142..dff4c7434 100644 --- a/packages/backend/src/server/api/endpoints/auth/session/show.ts +++ b/packages/backend/src/server/api/endpoints/auth/session/show.ts @@ -1,6 +1,9 @@ -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AuthSessionsRepository } from '@/models/index.js'; +import { AuthSessionEntityService } from '@/core/entities/AuthSessionEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import { AuthSessions } from '@/models/index.js'; export const meta = { tags: ['auth'], @@ -46,15 +49,25 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - // Lookup session - const session = await AuthSessions.findOneBy({ - token: ps.token, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.authSessionsRepository) + private authSessionsRepository: AuthSessionsRepository, - if (session == null) { - throw new ApiError(meta.errors.noSuchSession); + private authSessionEntityService: AuthSessionEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + // Lookup session + const session = await this.authSessionsRepository.findOneBy({ + token: ps.token, + }); + + if (session == null) { + throw new ApiError(meta.errors.noSuchSession); + } + + return await this.authSessionEntityService.pack(session, me); + }); } - - return await AuthSessions.pack(session, user); -}); +} diff --git a/packages/backend/src/server/api/endpoints/auth/session/userkey.ts b/packages/backend/src/server/api/endpoints/auth/session/userkey.ts index 89884ed38..9c9f13f50 100644 --- a/packages/backend/src/server/api/endpoints/auth/session/userkey.ts +++ b/packages/backend/src/server/api/endpoints/auth/session/userkey.ts @@ -1,6 +1,9 @@ -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UsersRepository, AppsRepository, AccessTokensRepository, AuthSessionsRepository } from '@/models/index.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import { Apps, AuthSessions, AccessTokens, Users } from '@/models/index.js'; export const meta = { tags: ['auth'], @@ -55,43 +58,62 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - // Lookup app - const app = await Apps.findOneBy({ - secret: ps.appSecret, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - if (app == null) { - throw new ApiError(meta.errors.noSuchApp); + @Inject(DI.appsRepository) + private appsRepository: AppsRepository, + + @Inject(DI.authSessionsRepository) + private authSessionsRepository: AuthSessionsRepository, + + @Inject(DI.accessTokensRepository) + private accessTokensRepository: AccessTokensRepository, + + private userEntityService: UserEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + // Lookup app + const app = await this.appsRepository.findOneBy({ + secret: ps.appSecret, + }); + + if (app == null) { + throw new ApiError(meta.errors.noSuchApp); + } + + // Fetch token + const session = await this.authSessionsRepository.findOneBy({ + token: ps.token, + appId: app.id, + }); + + if (session == null) { + throw new ApiError(meta.errors.noSuchSession); + } + + if (session.userId == null) { + throw new ApiError(meta.errors.pendingSession); + } + + // Lookup access token + const accessToken = await this.accessTokensRepository.findOneByOrFail({ + appId: app.id, + userId: session.userId, + }); + + // Delete session + this.authSessionsRepository.delete(session.id); + + return { + accessToken: accessToken.token, + user: await this.userEntityService.pack(session.userId, null, { + detail: true, + }), + }; + }); } - - // Fetch token - const session = await AuthSessions.findOneBy({ - token: ps.token, - appId: app.id, - }); - - if (session == null) { - throw new ApiError(meta.errors.noSuchSession); - } - - if (session.userId == null) { - throw new ApiError(meta.errors.pendingSession); - } - - // Lookup access token - const accessToken = await AccessTokens.findOneByOrFail({ - appId: app.id, - userId: session.userId, - }); - - // Delete session - AuthSessions.delete(session.id); - - return { - accessToken: accessToken.token, - user: await Users.pack(session.userId, null, { - detail: true, - }), - }; -}); +} diff --git a/packages/backend/src/server/api/endpoints/blocking/create.ts b/packages/backend/src/server/api/endpoints/blocking/create.ts index 0540e6ab0..33614a155 100644 --- a/packages/backend/src/server/api/endpoints/blocking/create.ts +++ b/packages/backend/src/server/api/endpoints/blocking/create.ts @@ -1,9 +1,12 @@ import ms from 'ms'; -import create from '@/services/blocking/create.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UsersRepository, BlockingsRepository } from '@/models/index.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { UserBlockingService } from '@/core/UserBlockingService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { getUser } from '../../common/getters.js'; -import { Blockings, NoteWatchings, Users } from '@/models/index.js'; +import { GetterService } from '../../common/GetterService.js'; export const meta = { tags: ['account'], @@ -53,38 +56,48 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const blocker = await Users.findOneByOrFail({ id: user.id }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - // 自分自身 - if (user.id === ps.userId) { - throw new ApiError(meta.errors.blockeeIsYourself); + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + private userEntityService: UserEntityService, + private getterService: GetterService, + private userBlockingService: UserBlockingService, + ) { + super(meta, paramDef, async (ps, me) => { + const blocker = await this.usersRepository.findOneByOrFail({ id: me.id }); + + // 自分自身 + if (me.id === ps.userId) { + throw new ApiError(meta.errors.blockeeIsYourself); + } + + // Get blockee + const blockee = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + // Check if already blocking + const exist = await this.blockingsRepository.findOneBy({ + blockerId: blocker.id, + blockeeId: blockee.id, + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyBlocking); + } + + await this.userBlockingService.block(blocker, blockee); + + return await this.userEntityService.pack(blockee.id, blocker, { + detail: true, + }); + }); } - - // Get blockee - const blockee = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw e; - }); - - // Check if already blocking - const exist = await Blockings.findOneBy({ - blockerId: blocker.id, - blockeeId: blockee.id, - }); - - if (exist != null) { - throw new ApiError(meta.errors.alreadyBlocking); - } - - await create(blocker, blockee); - - NoteWatchings.delete({ - userId: blocker.id, - noteUserId: blockee.id, - }); - - return await Users.pack(blockee.id, blocker, { - detail: true, - }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/blocking/delete.ts b/packages/backend/src/server/api/endpoints/blocking/delete.ts index 77e17b3ba..f2cc28e92 100644 --- a/packages/backend/src/server/api/endpoints/blocking/delete.ts +++ b/packages/backend/src/server/api/endpoints/blocking/delete.ts @@ -1,9 +1,12 @@ import ms from 'ms'; -import deleteBlocking from '@/services/blocking/delete.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UsersRepository, BlockingsRepository } from '@/models/index.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { UserBlockingService } from '@/core/UserBlockingService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { getUser } from '../../common/getters.js'; -import { Blockings, Users } from '@/models/index.js'; +import { GetterService } from '../../common/GetterService.js'; export const meta = { tags: ['account'], @@ -53,34 +56,49 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const blocker = await Users.findOneByOrFail({ id: user.id }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - // Check if the blockee is yourself - if (user.id === ps.userId) { - throw new ApiError(meta.errors.blockeeIsYourself); + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + private userEntityService: UserEntityService, + private getterService: GetterService, + private userBlockingService: UserBlockingService, + ) { + super(meta, paramDef, async (ps, me) => { + const blocker = await this.usersRepository.findOneByOrFail({ id: me.id }); + + // Check if the blockee is yourself + if (me.id === ps.userId) { + throw new ApiError(meta.errors.blockeeIsYourself); + } + + // Get blockee + const blockee = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + // Check not blocking + const exist = await this.blockingsRepository.findOneBy({ + blockerId: blocker.id, + blockeeId: blockee.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notBlocking); + } + + // Delete blocking + await this.userBlockingService.unblock(blocker, blockee); + + return await this.userEntityService.pack(blockee.id, blocker, { + detail: true, + }); + }); } - - // Get blockee - const blockee = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw e; - }); - - // Check not blocking - const exist = await Blockings.findOneBy({ - blockerId: blocker.id, - blockeeId: blockee.id, - }); - - if (exist == null) { - throw new ApiError(meta.errors.notBlocking); - } - - // Delete blocking - await deleteBlocking(blocker, blockee); - - return await Users.pack(blockee.id, blocker, { - detail: true, - }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/blocking/list.ts b/packages/backend/src/server/api/endpoints/blocking/list.ts index 29095ebe2..4f5e11cd6 100644 --- a/packages/backend/src/server/api/endpoints/blocking/list.ts +++ b/packages/backend/src/server/api/endpoints/blocking/list.ts @@ -1,6 +1,9 @@ -import define from '../../define.js'; -import { Blockings } from '@/models/index.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { BlockingsRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { BlockingEntityService } from '@/core/entities/BlockingEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['account'], @@ -31,13 +34,24 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const query = makePaginationQuery(Blockings.createQueryBuilder('blocking'), ps.sinceId, ps.untilId) - .andWhere(`blocking.blockerId = :meId`, { meId: me.id }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, - const blockings = await query - .take(ps.limit) - .getMany(); + private blockingEntityService: BlockingEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.blockingsRepository.createQueryBuilder('blocking'), ps.sinceId, ps.untilId) + .andWhere('blocking.blockerId = :meId', { meId: me.id }); - return await Blockings.packMany(blockings, me); -}); + const blockings = await query + .take(ps.limit) + .getMany(); + + return await this.blockingEntityService.packMany(blockings, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/create.ts b/packages/backend/src/server/api/endpoints/channels/create.ts index 94dcfe502..21979884f 100644 --- a/packages/backend/src/server/api/endpoints/channels/create.ts +++ b/packages/backend/src/server/api/endpoints/channels/create.ts @@ -1,8 +1,11 @@ -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ChannelsRepository, DriveFilesRepository } from '@/models/index.js'; +import type { Channel } from '@/models/entities/Channel.js'; +import { IdService } from '@/core/IdService.js'; +import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { Channels, DriveFiles } from '@/models/index.js'; -import { Channel } from '@/models/entities/channel.js'; -import { genId } from '@/misc/gen-id.js'; export const meta = { tags: ['channels'], @@ -37,27 +40,41 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - let banner = null; - if (ps.bannerId != null) { - banner = await DriveFiles.findOneBy({ - id: ps.bannerId, - userId: user.id, +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + private idService: IdService, + private channelEntityService: ChannelEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + let banner = null; + if (ps.bannerId != null) { + banner = await this.driveFilesRepository.findOneBy({ + id: ps.bannerId, + userId: me.id, + }); + + if (banner == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + const channel = await this.channelsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: me.id, + name: ps.name, + description: ps.description ?? null, + bannerId: banner ? banner.id : null, + } as Channel).then(x => this.channelsRepository.findOneByOrFail(x.identifiers[0])); + + return await this.channelEntityService.pack(channel, me); }); - - if (banner == null) { - throw new ApiError(meta.errors.noSuchFile); - } } - - const channel = await Channels.insert({ - id: genId(), - createdAt: new Date(), - userId: user.id, - name: ps.name, - description: ps.description || null, - bannerId: banner ? banner.id : null, - } as Channel).then(x => Channels.findOneByOrFail(x.identifiers[0])); - - return await Channels.pack(channel, user); -}); +} diff --git a/packages/backend/src/server/api/endpoints/channels/featured.ts b/packages/backend/src/server/api/endpoints/channels/featured.ts index 73980c0fa..0c3f9509d 100644 --- a/packages/backend/src/server/api/endpoints/channels/featured.ts +++ b/packages/backend/src/server/api/endpoints/channels/featured.ts @@ -1,5 +1,8 @@ -import define from '../../define.js'; -import { Channels } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ChannelsRepository } from '@/models/index.js'; +import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['channels'], @@ -24,12 +27,22 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const query = Channels.createQueryBuilder('channel') - .where('channel.lastNotedAt IS NOT NULL') - .orderBy('channel.lastNotedAt', 'DESC'); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, - const channels = await query.take(10).getMany(); + private channelEntityService: ChannelEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.channelsRepository.createQueryBuilder('channel') + .where('channel.lastNotedAt IS NOT NULL') + .orderBy('channel.lastNotedAt', 'DESC'); - return await Promise.all(channels.map(x => Channels.pack(x, me))); -}); + const channels = await query.take(10).getMany(); + + return await Promise.all(channels.map(x => this.channelEntityService.pack(x, me))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/follow.ts b/packages/backend/src/server/api/endpoints/channels/follow.ts index 895ffed0b..6c6b498a9 100644 --- a/packages/backend/src/server/api/endpoints/channels/follow.ts +++ b/packages/backend/src/server/api/endpoints/channels/follow.ts @@ -1,8 +1,10 @@ -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ChannelFollowingsRepository, ChannelsRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { Channels, ChannelFollowings } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; -import { publishUserEvent } from '@/services/stream.js'; export const meta = { tags: ['channels'], @@ -29,21 +31,35 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const channel = await Channels.findOneBy({ - id: ps.channelId, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, - if (channel == null) { - throw new ApiError(meta.errors.noSuchChannel); + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + + private idService: IdService, + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + const channel = await Channels.findOneBy({ + id: ps.channelId, + }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + + await this.channelFollowingsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + followerId: me.id, + followeeId: channel.id, + }); + + this.globalEventService.publishUserEvent(me.id, 'followChannel', channel); + }); } - - await ChannelFollowings.insert({ - id: genId(), - createdAt: new Date(), - followerId: user.id, - followeeId: channel.id, - }); - - publishUserEvent(user.id, 'followChannel', channel); -}); +} diff --git a/packages/backend/src/server/api/endpoints/channels/followed.ts b/packages/backend/src/server/api/endpoints/channels/followed.ts index e4aa4d161..5a8ab26af 100644 --- a/packages/backend/src/server/api/endpoints/channels/followed.ts +++ b/packages/backend/src/server/api/endpoints/channels/followed.ts @@ -1,6 +1,9 @@ -import define from '../../define.js'; -import { Channels, ChannelFollowings } from '@/models/index.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ChannelFollowingsRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['channels', 'account'], @@ -31,13 +34,24 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const query = makePaginationQuery(ChannelFollowings.createQueryBuilder(), ps.sinceId, ps.untilId) - .andWhere({ followerId: me.id }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, - const followings = await query - .take(ps.limit) - .getMany(); + private channelEntityService: ChannelEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.channelFollowingsRepository.createQueryBuilder(), ps.sinceId, ps.untilId) + .andWhere({ followerId: me.id }); - return await Promise.all(followings.map(x => Channels.pack(x.followeeId, me))); -}); + const followings = await query + .take(ps.limit) + .getMany(); + + return await Promise.all(followings.map(x => this.channelEntityService.pack(x.followeeId, me))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/owned.ts b/packages/backend/src/server/api/endpoints/channels/owned.ts index ed7e41cac..8b8b5819e 100644 --- a/packages/backend/src/server/api/endpoints/channels/owned.ts +++ b/packages/backend/src/server/api/endpoints/channels/owned.ts @@ -1,6 +1,9 @@ -import define from '../../define.js'; -import { Channels } from '@/models/index.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ChannelsRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['channels', 'account'], @@ -31,13 +34,24 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const query = makePaginationQuery(Channels.createQueryBuilder(), ps.sinceId, ps.untilId) - .andWhere({ userId: me.id }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, - const channels = await query - .take(ps.limit) - .getMany(); + private channelEntityService: ChannelEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder(), ps.sinceId, ps.untilId) + .andWhere({ userId: me.id }); - return await Promise.all(channels.map(x => Channels.pack(x, me))); -}); + const channels = await query + .take(ps.limit) + .getMany(); + + return await Promise.all(channels.map(x => this.channelEntityService.pack(x, me))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/show.ts b/packages/backend/src/server/api/endpoints/channels/show.ts index 87665a986..54ae31790 100644 --- a/packages/backend/src/server/api/endpoints/channels/show.ts +++ b/packages/backend/src/server/api/endpoints/channels/show.ts @@ -1,6 +1,9 @@ -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ChannelsRepository } from '@/models/index.js'; +import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { Channels } from '@/models/index.js'; export const meta = { tags: ['channels'], @@ -31,14 +34,24 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const channel = await Channels.findOneBy({ - id: ps.channelId, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, - if (channel == null) { - throw new ApiError(meta.errors.noSuchChannel); + private channelEntityService: ChannelEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const channel = await this.channelsRepository.findOneBy({ + id: ps.channelId, + }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + + return await this.channelEntityService.pack(channel, me); + }); } - - return await Channels.pack(channel, me); -}); +} diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index deaa29901..1c7f1360b 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -1,8 +1,11 @@ -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ChannelsRepository, NotesRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { Notes, Channels } from '@/models/index.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; -import { activeUsersChart } from '@/services/chart/index.js'; export const meta = { tags: ['notes', 'channels'], @@ -42,35 +45,50 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const channel = await Channels.findOneBy({ - id: ps.channelId, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, - if (channel == null) { - throw new ApiError(meta.errors.noSuchChannel); + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + private noteEntityService: NoteEntityService, + private queryService: QueryService, + private activeUsersChart: ActiveUsersChart, + ) { + super(meta, paramDef, async (ps, me) => { + const channel = await this.channelsRepository.findOneBy({ + id: ps.channelId, + }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + + //#region Construct query + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('note.channelId = :channelId', { channelId: channel.id }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') + .leftJoinAndSelect('note.channel', 'channel'); + //#endregion + + const timeline = await query.take(ps.limit).getMany(); + + if (me) this.activeUsersChart.read(me); + + return await this.noteEntityService.packMany(timeline, me); + }); } - - //#region Construct query - const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('note.channelId = :channelId', { channelId: channel.id }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') - .leftJoinAndSelect('note.channel', 'channel'); - //#endregion - - const timeline = await query.take(ps.limit).getMany(); - - if (user) activeUsersChart.read(user); - - return await Notes.packMany(timeline, user); -}); +} diff --git a/packages/backend/src/server/api/endpoints/channels/unfollow.ts b/packages/backend/src/server/api/endpoints/channels/unfollow.ts index e065d897a..b464c5509 100644 --- a/packages/backend/src/server/api/endpoints/channels/unfollow.ts +++ b/packages/backend/src/server/api/endpoints/channels/unfollow.ts @@ -1,7 +1,9 @@ -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ChannelFollowingsRepository, ChannelsRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { Channels, ChannelFollowings } from '@/models/index.js'; -import { publishUserEvent } from '@/services/stream.js'; export const meta = { tags: ['channels'], @@ -28,19 +30,32 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const channel = await Channels.findOneBy({ - id: ps.channelId, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, - if (channel == null) { - throw new ApiError(meta.errors.noSuchChannel); + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + const channel = await this.channelsRepository.findOneBy({ + id: ps.channelId, + }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + + await this.channelFollowingsRepository.delete({ + followerId: me.id, + followeeId: channel.id, + }); + + this.globalEventService.publishUserEvent(me.id, 'unfollowChannel', channel); + }); } - - await ChannelFollowings.delete({ - followerId: user.id, - followeeId: channel.id, - }); - - publishUserEvent(user.id, 'unfollowChannel', channel); -}); +} diff --git a/packages/backend/src/server/api/endpoints/channels/update.ts b/packages/backend/src/server/api/endpoints/channels/update.ts index 13104f324..ba62e9d37 100644 --- a/packages/backend/src/server/api/endpoints/channels/update.ts +++ b/packages/backend/src/server/api/endpoints/channels/update.ts @@ -1,6 +1,9 @@ -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFilesRepository, ChannelsRepository } from '@/models/index.js'; +import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { Channels, DriveFiles } from '@/models/index.js'; export const meta = { tags: ['channels'], @@ -48,39 +51,52 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const channel = await Channels.findOneBy({ - id: ps.channelId, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, - if (channel == null) { - throw new ApiError(meta.errors.noSuchChannel); - } + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, - if (channel.userId !== me.id) { - throw new ApiError(meta.errors.accessDenied); - } + private channelEntityService: ChannelEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const channel = await this.channelsRepository.findOneBy({ + id: ps.channelId, + }); - // eslint:disable-next-line:no-unnecessary-initializer - let banner = undefined; - if (ps.bannerId != null) { - banner = await DriveFiles.findOneBy({ - id: ps.bannerId, - userId: me.id, + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + + if (channel.userId !== me.id) { + throw new ApiError(meta.errors.accessDenied); + } + + // eslint:disable-next-line:no-unnecessary-initializer + let banner = undefined; + if (ps.bannerId != null) { + banner = await this.driveFilesRepository.findOneBy({ + id: ps.bannerId, + userId: me.id, + }); + + if (banner == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } else if (ps.bannerId === null) { + banner = null; + } + + await this.channelsRepository.update(channel.id, { + ...(ps.name !== undefined ? { name: ps.name } : {}), + ...(ps.description !== undefined ? { description: ps.description } : {}), + ...(banner ? { bannerId: banner.id } : {}), + }); + + return await this.channelEntityService.pack(channel.id, me); }); - - if (banner == null) { - throw new ApiError(meta.errors.noSuchFile); - } - } else if (ps.bannerId === null) { - banner = null; } - - await Channels.update(channel.id, { - ...(ps.name !== undefined ? { name: ps.name } : {}), - ...(ps.description !== undefined ? { description: ps.description } : {}), - ...(banner ? { bannerId: banner.id } : {}), - }); - - return await Channels.pack(channel.id, me); -}); +} diff --git a/packages/backend/src/server/api/endpoints/charts/active-users.ts b/packages/backend/src/server/api/endpoints/charts/active-users.ts index ea2379429..862ef8926 100644 --- a/packages/backend/src/server/api/endpoints/charts/active-users.ts +++ b/packages/backend/src/server/api/endpoints/charts/active-users.ts @@ -1,11 +1,13 @@ -import { getJsonSchema } from '@/services/chart/core.js'; -import { activeUsersChart } from '@/services/chart/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { getJsonSchema } from '@/core/chart/core.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; +import { schema } from '@/core/chart/charts/entities/active-users.js'; export const meta = { tags: ['charts', 'users'], - res: getJsonSchema(activeUsersChart.schema), + res: getJsonSchema(schema), allowGet: true, cacheSec: 60 * 60, @@ -22,6 +24,13 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - return await activeUsersChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null); -}); +@Injectable() +export default class extends Endpoint { + constructor( + private activeUsersChart: ActiveUsersChart, + ) { + super(meta, paramDef, async (ps, me) => { + return await this.activeUsersChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/charts/ap-request.ts b/packages/backend/src/server/api/endpoints/charts/ap-request.ts index 06dee250e..1d5b8f05f 100644 --- a/packages/backend/src/server/api/endpoints/charts/ap-request.ts +++ b/packages/backend/src/server/api/endpoints/charts/ap-request.ts @@ -1,11 +1,13 @@ -import { getJsonSchema } from '@/services/chart/core.js'; -import { apRequestChart } from '@/services/chart/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { getJsonSchema } from '@/core/chart/core.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import ApRequestChart from '@/core/chart/charts/ap-request.js'; +import { schema } from '@/core/chart/charts/entities/ap-request.js'; export const meta = { tags: ['charts'], - res: getJsonSchema(apRequestChart.schema), + res: getJsonSchema(schema), allowGet: true, cacheSec: 60 * 60, @@ -22,6 +24,13 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - return await apRequestChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null); -}); +@Injectable() +export default class extends Endpoint { + constructor( + private apRequestChart: ApRequestChart, + ) { + super(meta, paramDef, async (ps, me) => { + return await this.apRequestChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/charts/drive.ts b/packages/backend/src/server/api/endpoints/charts/drive.ts index dd2c2d683..ec28fa75d 100644 --- a/packages/backend/src/server/api/endpoints/charts/drive.ts +++ b/packages/backend/src/server/api/endpoints/charts/drive.ts @@ -1,11 +1,13 @@ -import { getJsonSchema } from '@/services/chart/core.js'; -import { driveChart } from '@/services/chart/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { getJsonSchema } from '@/core/chart/core.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import DriveChart from '@/core/chart/charts/drive.js'; +import { schema } from '@/core/chart/charts/entities/drive.js'; export const meta = { tags: ['charts', 'drive'], - res: getJsonSchema(driveChart.schema), + res: getJsonSchema(schema), allowGet: true, cacheSec: 60 * 60, @@ -22,6 +24,13 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - return await driveChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null); -}); +@Injectable() +export default class extends Endpoint { + constructor( + private driveChart: DriveChart, + ) { + super(meta, paramDef, async (ps, me) => { + return await this.driveChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/charts/federation.ts b/packages/backend/src/server/api/endpoints/charts/federation.ts index 8c35b3c46..6c24cbbb7 100644 --- a/packages/backend/src/server/api/endpoints/charts/federation.ts +++ b/packages/backend/src/server/api/endpoints/charts/federation.ts @@ -1,11 +1,13 @@ -import { getJsonSchema } from '@/services/chart/core.js'; -import { federationChart } from '@/services/chart/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { getJsonSchema } from '@/core/chart/core.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import FederationChart from '@/core/chart/charts/federation.js'; +import { schema } from '@/core/chart/charts/entities/federation.js'; export const meta = { tags: ['charts'], - res: getJsonSchema(federationChart.schema), + res: getJsonSchema(schema), allowGet: true, cacheSec: 60 * 60, @@ -22,6 +24,13 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - return await federationChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null); -}); +@Injectable() +export default class extends Endpoint { + constructor( + private federationChart: FederationChart, + ) { + super(meta, paramDef, async (ps, me) => { + return await this.federationChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/charts/hashtag.ts b/packages/backend/src/server/api/endpoints/charts/hashtag.ts index 77e24a62c..71e5bab76 100644 --- a/packages/backend/src/server/api/endpoints/charts/hashtag.ts +++ b/packages/backend/src/server/api/endpoints/charts/hashtag.ts @@ -1,11 +1,13 @@ -import { getJsonSchema } from '@/services/chart/core.js'; -import { hashtagChart } from '@/services/chart/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { getJsonSchema } from '@/core/chart/core.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import HashtagChart from '@/core/chart/charts/hashtag.js'; +import { schema } from '@/core/chart/charts/entities/hashtag.js'; export const meta = { tags: ['charts', 'hashtags'], - res: getJsonSchema(hashtagChart.schema), + res: getJsonSchema(schema), allowGet: true, cacheSec: 60 * 60, @@ -23,6 +25,13 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - return await hashtagChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.tag); -}); +@Injectable() +export default class extends Endpoint { + constructor( + private hashtagChart: HashtagChart, + ) { + super(meta, paramDef, async (ps, me) => { + return await this.hashtagChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.tag); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/charts/instance.ts b/packages/backend/src/server/api/endpoints/charts/instance.ts index 817d51ad0..a6a538ea5 100644 --- a/packages/backend/src/server/api/endpoints/charts/instance.ts +++ b/packages/backend/src/server/api/endpoints/charts/instance.ts @@ -1,11 +1,13 @@ -import { getJsonSchema } from '@/services/chart/core.js'; -import { instanceChart } from '@/services/chart/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { getJsonSchema } from '@/core/chart/core.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import InstanceChart from '@/core/chart/charts/instance.js'; +import { schema } from '@/core/chart/charts/entities/instance.js'; export const meta = { tags: ['charts'], - res: getJsonSchema(instanceChart.schema), + res: getJsonSchema(schema), allowGet: true, cacheSec: 60 * 60, @@ -23,6 +25,13 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - return await instanceChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.host); -}); +@Injectable() +export default class extends Endpoint { + constructor( + private instanceChart: InstanceChart, + ) { + super(meta, paramDef, async (ps, me) => { + return await this.instanceChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.host); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/charts/notes.ts b/packages/backend/src/server/api/endpoints/charts/notes.ts index 951adf540..8d03f2eaf 100644 --- a/packages/backend/src/server/api/endpoints/charts/notes.ts +++ b/packages/backend/src/server/api/endpoints/charts/notes.ts @@ -1,11 +1,13 @@ -import { getJsonSchema } from '@/services/chart/core.js'; -import { notesChart } from '@/services/chart/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { getJsonSchema } from '@/core/chart/core.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import NotesChart from '@/core/chart/charts/notes.js'; +import { schema } from '@/core/chart/charts/entities/notes.js'; export const meta = { tags: ['charts', 'notes'], - res: getJsonSchema(notesChart.schema), + res: getJsonSchema(schema), allowGet: true, cacheSec: 60 * 60, @@ -22,6 +24,13 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - return await notesChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null); -}); +@Injectable() +export default class extends Endpoint { + constructor( + private notesChart: NotesChart, + ) { + super(meta, paramDef, async (ps, me) => { + return await this.notesChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/charts/user/drive.ts b/packages/backend/src/server/api/endpoints/charts/user/drive.ts index f165b4022..87d56f38b 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/drive.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/drive.ts @@ -1,11 +1,13 @@ -import { getJsonSchema } from '@/services/chart/core.js'; -import { perUserDriveChart } from '@/services/chart/index.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { getJsonSchema } from '@/core/chart/core.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import PerUserDriveChart from '@/core/chart/charts/per-user-drive.js'; +import { schema } from '@/core/chart/charts/entities/per-user-drive.js'; export const meta = { tags: ['charts', 'drive', 'users'], - res: getJsonSchema(perUserDriveChart.schema), + res: getJsonSchema(schema), allowGet: true, cacheSec: 60 * 60, @@ -23,6 +25,13 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - return await perUserDriveChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId); -}); +@Injectable() +export default class extends Endpoint { + constructor( + private perUserDriveChart: PerUserDriveChart, + ) { + super(meta, paramDef, async (ps, me) => { + return await this.perUserDriveChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/charts/user/following.ts b/packages/backend/src/server/api/endpoints/charts/user/following.ts index f5d42e21c..7a61544ae 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/following.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/following.ts @@ -1,11 +1,13 @@ -import define from '../../../define.js'; -import { getJsonSchema } from '@/services/chart/core.js'; -import { perUserFollowingChart } from '@/services/chart/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { getJsonSchema } from '@/core/chart/core.js'; +import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; +import { schema } from '@/core/chart/charts/entities/per-user-following.js'; export const meta = { tags: ['charts', 'users', 'following'], - res: getJsonSchema(perUserFollowingChart.schema), + res: getJsonSchema(schema), allowGet: true, cacheSec: 60 * 60, @@ -23,6 +25,13 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - return await perUserFollowingChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId); -}); +@Injectable() +export default class extends Endpoint { + constructor( + private perUserFollowingChart: PerUserFollowingChart, + ) { + super(meta, paramDef, async (ps, me) => { + return await this.perUserFollowingChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/charts/user/notes.ts b/packages/backend/src/server/api/endpoints/charts/user/notes.ts index aefe550d4..fdc385191 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/notes.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/notes.ts @@ -1,11 +1,13 @@ -import { getJsonSchema } from '@/services/chart/core.js'; -import { perUserNotesChart } from '@/services/chart/index.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { getJsonSchema } from '@/core/chart/core.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js'; +import { schema } from '@/core/chart/charts/entities/per-user-notes.js'; export const meta = { tags: ['charts', 'users', 'notes'], - res: getJsonSchema(perUserNotesChart.schema), + res: getJsonSchema(schema), allowGet: true, cacheSec: 60 * 60, @@ -23,6 +25,13 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - return await perUserNotesChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId); -}); +@Injectable() +export default class extends Endpoint { + constructor( + private perUserNotesChart: PerUserNotesChart, + ) { + super(meta, paramDef, async (ps, me) => { + return await this.perUserNotesChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/charts/user/reactions.ts b/packages/backend/src/server/api/endpoints/charts/user/reactions.ts index 6bc6b56bf..f0f3e520d 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/reactions.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/reactions.ts @@ -1,11 +1,13 @@ -import { getJsonSchema } from '@/services/chart/core.js'; -import { perUserReactionsChart } from '@/services/chart/index.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { getJsonSchema } from '@/core/chart/core.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import PerUserReactionsChart from '@/core/chart/charts/per-user-reactions.js'; +import { schema } from '@/core/chart/charts/entities/per-user-reactions.js'; export const meta = { tags: ['charts', 'users', 'reactions'], - res: getJsonSchema(perUserReactionsChart.schema), + res: getJsonSchema(schema), allowGet: true, cacheSec: 60 * 60, @@ -23,6 +25,13 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - return await perUserReactionsChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId); -}); +@Injectable() +export default class extends Endpoint { + constructor( + private perUserReactionsChart: PerUserReactionsChart, + ) { + super(meta, paramDef, async (ps, me) => { + return await this.perUserReactionsChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/charts/users.ts b/packages/backend/src/server/api/endpoints/charts/users.ts index 338e8fd33..d09f2512e 100644 --- a/packages/backend/src/server/api/endpoints/charts/users.ts +++ b/packages/backend/src/server/api/endpoints/charts/users.ts @@ -1,11 +1,13 @@ -import { getJsonSchema } from '@/services/chart/core.js'; -import { usersChart } from '@/services/chart/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { getJsonSchema } from '@/core/chart/core.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import UsersChart from '@/core/chart/charts/users.js'; +import { schema } from '@/core/chart/charts/entities/users.js'; export const meta = { tags: ['charts', 'users'], - res: getJsonSchema(usersChart.schema), + res: getJsonSchema(schema), allowGet: true, cacheSec: 60 * 60, @@ -22,6 +24,13 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - return await usersChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null); -}); +@Injectable() +export default class extends Endpoint { + constructor( + private usersChart: UsersChart, + ) { + super(meta, paramDef, async (ps, me) => { + return await this.usersChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/clips/add-note.ts b/packages/backend/src/server/api/endpoints/clips/add-note.ts index 5d72f5c1b..c733d2865 100644 --- a/packages/backend/src/server/api/endpoints/clips/add-note.ts +++ b/packages/backend/src/server/api/endpoints/clips/add-note.ts @@ -1,8 +1,10 @@ -import define from '../../define.js'; -import { ClipNotes, Clips } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { IdService } from '@/core/IdService.js'; +import { DI } from '@/di-symbols.js'; +import { ClipNotesRepository, ClipsRepository } from '@/models/index.js'; import { ApiError } from '../../error.js'; -import { genId } from '@/misc/gen-id.js'; -import { getNote } from '../../common/getters.js'; +import { GetterService } from '../../common/GetterService.js'; export const meta = { tags: ['account', 'notes', 'clips'], @@ -42,33 +44,47 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const clip = await Clips.findOneBy({ - id: ps.clipId, - userId: user.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.clipsRepository) + private clipsRepository: ClipsRepository, - if (clip == null) { - throw new ApiError(meta.errors.noSuchClip); + @Inject(DI.clipNotesRepository) + private clipNotesRepository: ClipNotesRepository, + + private idService: IdService, + private getterService: GetterService, + ) { + super(meta, paramDef, async (ps, me) => { + const clip = await this.clipsRepository.findOneBy({ + id: ps.clipId, + userId: me.id, + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + const note = await this.getterService.getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + const exist = await this.clipNotesRepository.findOneBy({ + noteId: note.id, + clipId: clip.id, + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyClipped); + } + + await this.clipNotesRepository.insert({ + id: this.idService.genId(), + noteId: note.id, + clipId: clip.id, + }); + }); } - - const note = await getNote(ps.noteId).catch(e => { - if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw e; - }); - - const exist = await ClipNotes.findOneBy({ - noteId: note.id, - clipId: clip.id, - }); - - if (exist != null) { - throw new ApiError(meta.errors.alreadyClipped); - } - - await ClipNotes.insert({ - id: genId(), - noteId: note.id, - clipId: clip.id, - }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/clips/create.ts b/packages/backend/src/server/api/endpoints/clips/create.ts index 4afe4222a..8eca3d66d 100644 --- a/packages/backend/src/server/api/endpoints/clips/create.ts +++ b/packages/backend/src/server/api/endpoints/clips/create.ts @@ -1,6 +1,9 @@ -import define from '../../define.js'; -import { genId } from '@/misc/gen-id.js'; -import { Clips } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { IdService } from '@/core/IdService.js'; +import { ClipsRepository } from '@/models/index.js'; +import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['clips'], @@ -27,15 +30,26 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const clip = await Clips.insert({ - id: genId(), - createdAt: new Date(), - userId: user.id, - name: ps.name, - isPublic: ps.isPublic, - description: ps.description, - }).then(x => Clips.findOneByOrFail(x.identifiers[0])); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.clipsRepository) + private clipsRepository: ClipsRepository, - return await Clips.pack(clip); -}); + private clipEntityService: ClipEntityService, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const clip = await this.clipsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: me.id, + name: ps.name, + isPublic: ps.isPublic, + description: ps.description, + }).then(x => this.clipsRepository.findOneByOrFail(x.identifiers[0])); + + return await this.clipEntityService.pack(clip); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/clips/delete.ts b/packages/backend/src/server/api/endpoints/clips/delete.ts index b6c0eb702..ea361ae9c 100644 --- a/packages/backend/src/server/api/endpoints/clips/delete.ts +++ b/packages/backend/src/server/api/endpoints/clips/delete.ts @@ -1,6 +1,8 @@ -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ClipsRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { Clips } from '@/models/index.js'; export const meta = { tags: ['clips'], @@ -27,15 +29,23 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const clip = await Clips.findOneBy({ - id: ps.clipId, - userId: user.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.clipsRepository) + private clipsRepository: ClipsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const clip = await this.clipsRepository.findOneBy({ + id: ps.clipId, + userId: me.id, + }); - if (clip == null) { - throw new ApiError(meta.errors.noSuchClip); + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + await this.clipsRepository.delete(clip.id); + }); } - - await Clips.delete(clip.id); -}); +} diff --git a/packages/backend/src/server/api/endpoints/clips/list.ts b/packages/backend/src/server/api/endpoints/clips/list.ts index 378811eba..b57affd1c 100644 --- a/packages/backend/src/server/api/endpoints/clips/list.ts +++ b/packages/backend/src/server/api/endpoints/clips/list.ts @@ -1,5 +1,8 @@ -import define from '../../define.js'; -import { Clips } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ClipsRepository } from '@/models/index.js'; +import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['clips', 'account'], @@ -26,10 +29,20 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const clips = await Clips.findBy({ - userId: me.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.clipsRepository) + private clipsRepository: ClipsRepository, - return await Promise.all(clips.map(x => Clips.pack(x))); -}); + private clipEntityService: ClipEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const clips = await this.clipsRepository.findBy({ + userId: me.id, + }); + + return await Promise.all(clips.map(x => this.clipEntityService.pack(x))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/clips/notes.ts b/packages/backend/src/server/api/endpoints/clips/notes.ts index 4ace747ef..428249893 100644 --- a/packages/backend/src/server/api/endpoints/clips/notes.ts +++ b/packages/backend/src/server/api/endpoints/clips/notes.ts @@ -1,10 +1,10 @@ -import define from '../../define.js'; -import { ClipNotes, Clips, Notes } from '@/models/index.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; -import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; -import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { NotesRepository, ClipsRepository, ClipNotesRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; export const meta = { tags: ['account', 'notes', 'clips'], @@ -44,43 +44,60 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const clip = await Clips.findOneBy({ - id: ps.clipId, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.clipsRepository) + private clipsRepository: ClipsRepository, - if (clip == null) { - throw new ApiError(meta.errors.noSuchClip); + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.clipNotesRepository) + private clipNotesRepository: ClipNotesRepository, + + private noteEntityService: NoteEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const clip = await this.clipsRepository.findOneBy({ + id: ps.clipId, + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + if (!clip.isPublic && (me == null || (clip.userId !== me.id))) { + throw new ApiError(meta.errors.noSuchClip); + } + + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .innerJoin(this.clipNotesRepository.metadata.targetName, 'clipNote', 'clipNote.noteId = note.id') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') + .andWhere('clipNote.clipId = :clipId', { clipId: clip.id }); + + if (me) { + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + } + + const notes = await query + .take(ps.limit) + .getMany(); + + return await this.noteEntityService.packMany(notes, me); + }); } - - if (!clip.isPublic && (user == null || (clip.userId !== user.id))) { - throw new ApiError(meta.errors.noSuchClip); - } - - const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .innerJoin(ClipNotes.metadata.targetName, 'clipNote', 'clipNote.noteId = note.id') - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') - .andWhere('clipNote.clipId = :clipId', { clipId: clip.id }); - - if (user) { - generateVisibilityQuery(query, user); - generateMutedUserQuery(query, user); - generateBlockedUserQuery(query, user); - } - - const notes = await query - .take(ps.limit) - .getMany(); - - return await Notes.packMany(notes, user); -}); +} diff --git a/packages/backend/src/server/api/endpoints/clips/remove-note.ts b/packages/backend/src/server/api/endpoints/clips/remove-note.ts index 8b90e31f6..3fc60e363 100644 --- a/packages/backend/src/server/api/endpoints/clips/remove-note.ts +++ b/packages/backend/src/server/api/endpoints/clips/remove-note.ts @@ -1,7 +1,9 @@ -import define from '../../define.js'; -import { ClipNotes, Clips } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ClipNotesRepository, ClipsRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { getNote } from '../../common/getters.js'; +import { GetterService } from '../../common/GetterService.js'; export const meta = { tags: ['account', 'notes', 'clips'], @@ -35,23 +37,36 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const clip = await Clips.findOneBy({ - id: ps.clipId, - userId: user.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.clipsRepository) + private clipsRepository: ClipsRepository, - if (clip == null) { - throw new ApiError(meta.errors.noSuchClip); + @Inject(DI.clipNotesRepository) + private clipNotesRepository: ClipNotesRepository, + + private getterService: GetterService, + ) { + super(meta, paramDef, async (ps, me) => { + const clip = await this.clipsRepository.findOneBy({ + id: ps.clipId, + userId: me.id, + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + 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; + }); + + await this.clipNotesRepository.delete({ + noteId: note.id, + clipId: clip.id, + }); + }); } - - const note = await getNote(ps.noteId).catch(e => { - if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw e; - }); - - await ClipNotes.delete({ - noteId: note.id, - clipId: clip.id, - }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/clips/show.ts b/packages/backend/src/server/api/endpoints/clips/show.ts index c3d73c168..4e9354005 100644 --- a/packages/backend/src/server/api/endpoints/clips/show.ts +++ b/packages/backend/src/server/api/endpoints/clips/show.ts @@ -1,6 +1,9 @@ -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ClipsRepository } from '@/models/index.js'; +import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { Clips } from '@/models/index.js'; export const meta = { tags: ['clips', 'account'], @@ -33,19 +36,29 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - // Fetch the clip - const clip = await Clips.findOneBy({ - id: ps.clipId, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.clipsRepository) + private clipsRepository: ClipsRepository, - if (clip == null) { - throw new ApiError(meta.errors.noSuchClip); + private clipEntityService: ClipEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch the clip + const clip = await this.clipsRepository.findOneBy({ + id: ps.clipId, + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + if (!clip.isPublic && (me == null || (clip.userId !== me.id))) { + throw new ApiError(meta.errors.noSuchClip); + } + + return await this.clipEntityService.pack(clip); + }); } - - if (!clip.isPublic && (me == null || (clip.userId !== me.id))) { - throw new ApiError(meta.errors.noSuchClip); - } - - return await Clips.pack(clip); -}); +} diff --git a/packages/backend/src/server/api/endpoints/clips/update.ts b/packages/backend/src/server/api/endpoints/clips/update.ts index b67d844f6..9880505d0 100644 --- a/packages/backend/src/server/api/endpoints/clips/update.ts +++ b/packages/backend/src/server/api/endpoints/clips/update.ts @@ -1,6 +1,9 @@ -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ClipsRepository } from '@/models/index.js'; +import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { Clips } from '@/models/index.js'; export const meta = { tags: ['clips'], @@ -36,22 +39,32 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - // Fetch the clip - const clip = await Clips.findOneBy({ - id: ps.clipId, - userId: user.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.clipsRepository) + private clipsRepository: ClipsRepository, - if (clip == null) { - throw new ApiError(meta.errors.noSuchClip); + private clipEntityService: ClipEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch the clip + const clip = await this.clipsRepository.findOneBy({ + id: ps.clipId, + userId: me.id, + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + await this.clipsRepository.update(clip.id, { + name: ps.name, + description: ps.description, + isPublic: ps.isPublic, + }); + + return await this.clipEntityService.pack(clip.id); + }); } - - await Clips.update(clip.id, { - name: ps.name, - description: ps.description, - isPublic: ps.isPublic, - }); - - return await Clips.pack(clip.id); -}); +} diff --git a/packages/backend/src/server/api/endpoints/drive.ts b/packages/backend/src/server/api/endpoints/drive.ts index 82497adef..6f40225f1 100644 --- a/packages/backend/src/server/api/endpoints/drive.ts +++ b/packages/backend/src/server/api/endpoints/drive.ts @@ -1,6 +1,7 @@ -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { DriveFiles } from '@/models/index.js'; -import define from '../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { MetaService } from '@/core/MetaService.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; export const meta = { tags: ['drive', 'account'], @@ -32,14 +33,22 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const instance = await fetchMeta(true); +@Injectable() +export default class extends Endpoint { + constructor( + private metaService: MetaService, + private driveFileEntityService: DriveFileEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const instance = await this.metaService.fetch(true); - // Calculate drive usage - const usage = await DriveFiles.calcDriveUsageOf(user.id); + // Calculate drive usage + const usage = await this.driveFileEntityService.calcDriveUsageOf(me.id); - return { - capacity: 1024 * 1024 * (user.driveCapacityOverrideMb || instance.localDriveCapacityMb), - usage: usage, - }; -}); + return { + capacity: 1024 * 1024 * (me.driveCapacityOverrideMb ?? instance.localDriveCapacityMb), + usage: usage, + }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/drive/files.ts b/packages/backend/src/server/api/endpoints/drive/files.ts index 40e6c16c9..56055d134 100644 --- a/packages/backend/src/server/api/endpoints/drive/files.ts +++ b/packages/backend/src/server/api/endpoints/drive/files.ts @@ -1,6 +1,9 @@ -import define from '../../define.js'; -import { DriveFiles } from '@/models/index.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFilesRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['drive'], @@ -33,25 +36,36 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const query = makePaginationQuery(DriveFiles.createQueryBuilder('file'), ps.sinceId, ps.untilId) - .andWhere('file.userId = :userId', { userId: user.id }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, - if (ps.folderId) { - query.andWhere('file.folderId = :folderId', { folderId: ps.folderId }); - } else { - query.andWhere('file.folderId IS NULL'); + private driveFileEntityService: DriveFileEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.driveFilesRepository.createQueryBuilder('file'), ps.sinceId, ps.untilId) + .andWhere('file.userId = :userId', { userId: me.id }); + + if (ps.folderId) { + query.andWhere('file.folderId = :folderId', { folderId: ps.folderId }); + } else { + query.andWhere('file.folderId IS NULL'); + } + + if (ps.type) { + if (ps.type.endsWith('/*')) { + query.andWhere('file.type like :type', { type: ps.type.replace('/*', '/') + '%' }); + } else { + query.andWhere('file.type = :type', { type: ps.type }); + } + } + + const files = await query.take(ps.limit).getMany(); + + return await this.driveFileEntityService.packMany(files, { detail: false, self: true }); + }); } - - if (ps.type) { - if (ps.type.endsWith('/*')) { - query.andWhere('file.type like :type', { type: ps.type.replace('/*', '/') + '%' }); - } else { - query.andWhere('file.type = :type', { type: ps.type }); - } - } - - const files = await query.take(ps.limit).getMany(); - - return await DriveFiles.packMany(files, { detail: false, self: true }); -}); +} 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 415a8cc69..9f11eb8b5 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 @@ -1,6 +1,9 @@ -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { NotesRepository, DriveFilesRepository } from '@/models/index.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import { DriveFiles, Notes } from '@/models/index.js'; export const meta = { tags: ['drive', 'notes'], @@ -39,22 +42,35 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - // Fetch file - const file = await DriveFiles.findOneBy({ - id: ps.fileId, - userId: user.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, - if (file == null) { - throw new ApiError(meta.errors.noSuchFile); + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private noteEntityService: NoteEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch file + const file = await this.driveFilesRepository.findOneBy({ + id: ps.fileId, + userId: me.id, + }); + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + + const notes = await this.notesRepository.createQueryBuilder('note') + .where(':file = ANY(note.fileIds)', { file: file.id }) + .getMany(); + + return await this.noteEntityService.packMany(notes, me, { + detail: true, + }); + }); } - - const notes = await Notes.createQueryBuilder('note') - .where(':file = ANY(note.fileIds)', { file: file.id }) - .getMany(); - - return await Notes.packMany(notes, user, { - detail: true, - }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/drive/files/check-existence.ts b/packages/backend/src/server/api/endpoints/drive/files/check-existence.ts index bbae9bf4e..176031d80 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/check-existence.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/check-existence.ts @@ -1,5 +1,7 @@ -import define from '../../../define.js'; -import { DriveFiles } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFilesRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['drive'], @@ -25,11 +27,19 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const file = await DriveFiles.findOneBy({ - md5: ps.md5, - userId: user.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const file = await this.driveFilesRepository.findOneBy({ + md5: ps.md5, + userId: me.id, + }); - return file != null; -}); + return file != null; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/drive/files/create.ts b/packages/backend/src/server/api/endpoints/drive/files/create.ts index ddcbd6288..bff83876d 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/create.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/create.ts @@ -1,11 +1,13 @@ import ms from 'ms'; -import { addFile } from '@/services/drive/add-file.js'; -import { DriveFiles } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { DriveFilesRepository } from '@/models/index.js'; import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import define from '../../../define.js'; -import { apiLogger } from '../../../logger.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { MetaService } from '@/core/MetaService.js'; +import { DriveService } from '@/core/DriveService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -64,48 +66,60 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user, _, file, cleanup, ip, headers) => { - // Get 'name' parameter - let name = ps.name || file.originalname; - if (name !== undefined && name !== null) { - name = name.trim(); - if (name.length === 0) { - name = null; - } else if (name === 'blob') { - name = null; - } else if (!DriveFiles.validateFileName(name)) { - throw new ApiError(meta.errors.invalidFileName); - } - } else { - name = null; - } +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, - const meta = await fetchMeta(); + private driveFileEntityService: DriveFileEntityService, + private metaService: MetaService, + private driveService: DriveService, + ) { + super(meta, paramDef, async (ps, me, _, file, cleanup, ip, headers) => { + // Get 'name' parameter + let name = ps.name ?? file.originalname; + if (name !== undefined && name !== null) { + name = name.trim(); + if (name.length === 0) { + name = null; + } else if (name === 'blob') { + name = null; + } else if (!this.driveFileEntityService.validateFileName(name)) { + throw new ApiError(meta.errors.invalidFileName); + } + } else { + name = null; + } - try { - // Create file - const driveFile = await addFile({ - user, - path: file.path, - name, - comment: ps.comment, - folderId: ps.folderId, - force: ps.force, - sensitive: ps.isSensitive, - requestIp: meta.enableIpLogging ? ip : null, - requestHeaders: meta.enableIpLogging ? headers : null, - }); - return await DriveFiles.pack(driveFile, { self: true }); - } catch (e) { - if (e instanceof Error || typeof e === 'string') { - apiLogger.error(e); - } - if (e instanceof IdentifiableError) { - if (e.id === '282f77bf-5816-4f72-9264-aa14d8261a21') throw new ApiError(meta.errors.inappropriate); - if (e.id === 'c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6') throw new ApiError(meta.errors.noFreeSpace); - } - throw new ApiError(); - } finally { + const meta = await this.metaService.fetch(); + + try { + // Create file + const driveFile = await this.driveService.addFile({ + user: me, + path: file.path, + name, + comment: ps.comment, + folderId: ps.folderId, + force: ps.force, + sensitive: ps.isSensitive, + requestIp: meta.enableIpLogging ? ip : null, + requestHeaders: meta.enableIpLogging ? headers : null, + }); + return await this.driveFileEntityService.pack(driveFile, { self: true }); + } catch (err) { + if (err instanceof Error || typeof err === 'string') { + console.error(err); + } + if (err instanceof IdentifiableError) { + if (err.id === '282f77bf-5816-4f72-9264-aa14d8261a21') throw new ApiError(meta.errors.inappropriate); + if (err.id === 'c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6') throw new ApiError(meta.errors.noFreeSpace); + } + throw new ApiError(); + } finally { cleanup!(); + } + }); } -}); +} diff --git a/packages/backend/src/server/api/endpoints/drive/files/delete.ts b/packages/backend/src/server/api/endpoints/drive/files/delete.ts index 6108ae7da..9d2ea6011 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/delete.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/delete.ts @@ -1,8 +1,10 @@ -import { deleteFile } from '@/services/drive/delete-file.js'; -import { publishDriveStream } from '@/services/stream.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFilesRepository } from '@/models/index.js'; +import { DriveService } from '@/core/DriveService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import { DriveFiles, Users } from '@/models/index.js'; export const meta = { tags: ['drive'], @@ -37,20 +39,31 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const file = await DriveFiles.findOneBy({ id: ps.fileId }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, - if (file == null) { - throw new ApiError(meta.errors.noSuchFile); + private driveService: DriveService, + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + + if ((!me.isAdmin && !me.isModerator) && (file.userId !== me.id)) { + throw new ApiError(meta.errors.accessDenied); + } + + // Delete + await this.driveService.deleteFile(file); + + // Publish fileDeleted event + this.globalEventService.publishDriveStream(me.id, 'fileDeleted', file.id); + }); } - - if ((!user.isAdmin && !user.isModerator) && (file.userId !== user.id)) { - throw new ApiError(meta.errors.accessDenied); - } - - // Delete - await deleteFile(file); - - // Publish fileDeleted event - publishDriveStream(user.id, 'fileDeleted', file.id); -}); +} diff --git a/packages/backend/src/server/api/endpoints/drive/files/find-by-hash.ts b/packages/backend/src/server/api/endpoints/drive/files/find-by-hash.ts index f2bc7348c..6299ca8f6 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/find-by-hash.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/find-by-hash.ts @@ -1,5 +1,8 @@ -import { DriveFiles } from '@/models/index.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { DriveFilesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['drive'], @@ -30,11 +33,21 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const files = await DriveFiles.findBy({ - md5: ps.md5, - userId: user.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, - return await DriveFiles.packMany(files, { self: true }); -}); + private driveFileEntityService: DriveFileEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const files = await this.driveFilesRepository.findBy({ + md5: ps.md5, + userId: me.id, + }); + + return await this.driveFileEntityService.packMany(files, { self: true }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/drive/files/find.ts b/packages/backend/src/server/api/endpoints/drive/files/find.ts index 245fb45a6..e4cd5213d 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/find.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/find.ts @@ -1,6 +1,9 @@ -import define from '../../../define.js'; -import { DriveFiles } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; import { IsNull } from 'typeorm'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFilesRepository } from '@/models/index.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { requireCredential: true, @@ -32,12 +35,22 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const files = await DriveFiles.findBy({ - name: ps.name, - userId: user.id, - folderId: ps.folderId ?? IsNull(), - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, - return await Promise.all(files.map(file => DriveFiles.pack(file, { self: true }))); -}); + private driveFileEntityService: DriveFileEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const files = await this.driveFilesRepository.findBy({ + name: ps.name, + userId: me.id, + folderId: ps.folderId ?? IsNull(), + }); + + return await Promise.all(files.map(file => this.driveFileEntityService.pack(file, { self: true }))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/drive/files/show.ts b/packages/backend/src/server/api/endpoints/drive/files/show.ts index 2c604c54c..bae4d7d66 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/show.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/show.ts @@ -1,6 +1,9 @@ -import { DriveFile } from '@/models/entities/drive-file.js'; -import { DriveFiles, Users } from '@/models/index.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import { DriveFilesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -52,34 +55,44 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - let file: DriveFile | null = null; +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, - if (ps.fileId) { - file = await DriveFiles.findOneBy({ id: ps.fileId }); - } else if (ps.url) { - file = await DriveFiles.findOne({ - where: [{ - url: ps.url, - }, { - webpublicUrl: ps.url, - }, { - thumbnailUrl: ps.url, - }], + private driveFileEntityService: DriveFileEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + let file: DriveFile | null = null; + + if (ps.fileId) { + file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); + } else if (ps.url) { + file = await this.driveFilesRepository.findOne({ + where: [{ + url: ps.url, + }, { + webpublicUrl: ps.url, + }, { + thumbnailUrl: ps.url, + }], + }); + } + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + + if ((!me.isAdmin && !me.isModerator) && (file.userId !== me.id)) { + throw new ApiError(meta.errors.accessDenied); + } + + return await this.driveFileEntityService.pack(file, { + detail: true, + withUser: true, + self: true, + }); }); } - - if (file == null) { - throw new ApiError(meta.errors.noSuchFile); - } - - if ((!user.isAdmin && !user.isModerator) && (file.userId !== user.id)) { - throw new ApiError(meta.errors.accessDenied); - } - - return await DriveFiles.pack(file, { - detail: true, - withUser: true, - self: true, - }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/drive/files/update.ts b/packages/backend/src/server/api/endpoints/drive/files/update.ts index fa2ec8519..03e3663f0 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/update.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/update.ts @@ -1,7 +1,10 @@ -import { publishDriveStream } from '@/services/stream.js'; -import { DriveFiles, DriveFolders, Users } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { DriveFilesRepository, DriveFoldersRepository } from '@/models/index.js'; import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js'; -import define from '../../../define.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -59,54 +62,68 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const file = await DriveFiles.findOneBy({ id: ps.fileId }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, - if (file == null) { - throw new ApiError(meta.errors.noSuchFile); - } + @Inject(DI.driveFoldersRepository) + private driveFoldersRepository: DriveFoldersRepository, - if ((!user.isAdmin && !user.isModerator) && (file.userId !== user.id)) { - throw new ApiError(meta.errors.accessDenied); - } + private driveFileEntityService: DriveFileEntityService, + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); - if (ps.name) file.name = ps.name; - if (!DriveFiles.validateFileName(file.name)) { - throw new ApiError(meta.errors.invalidFileName); - } - - if (ps.comment !== undefined) file.comment = ps.comment; - - if (ps.isSensitive !== undefined) file.isSensitive = ps.isSensitive; - - if (ps.folderId !== undefined) { - if (ps.folderId === null) { - file.folderId = null; - } else { - const folder = await DriveFolders.findOneBy({ - id: ps.folderId, - userId: user.id, - }); - - if (folder == null) { - throw new ApiError(meta.errors.noSuchFolder); + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); } - file.folderId = folder.id; - } + if ((!me.isAdmin && !me.isModerator) && (file.userId !== me.id)) { + throw new ApiError(meta.errors.accessDenied); + } + + if (ps.name) file.name = ps.name; + if (!this.driveFileEntityService.validateFileName(file.name)) { + throw new ApiError(meta.errors.invalidFileName); + } + + if (ps.comment !== undefined) file.comment = ps.comment; + + if (ps.isSensitive !== undefined) file.isSensitive = ps.isSensitive; + + if (ps.folderId !== undefined) { + if (ps.folderId === null) { + file.folderId = null; + } else { + const folder = await this.driveFoldersRepository.findOneBy({ + id: ps.folderId, + userId: me.id, + }); + + if (folder == null) { + throw new ApiError(meta.errors.noSuchFolder); + } + + file.folderId = folder.id; + } + } + + await this.driveFilesRepository.update(file.id, { + name: file.name, + comment: file.comment, + folderId: file.folderId, + isSensitive: file.isSensitive, + }); + + const fileObj = await this.driveFileEntityService.pack(file, { self: true }); + + // Publish fileUpdated event + this.globalEventService.publishDriveStream(me.id, 'fileUpdated', fileObj); + + return fileObj; + }); } - - await DriveFiles.update(file.id, { - name: file.name, - comment: file.comment, - folderId: file.folderId, - isSensitive: file.isSensitive, - }); - - const fileObj = await DriveFiles.pack(file, { self: true }); - - // Publish fileUpdated event - publishDriveStream(user.id, 'fileUpdated', fileObj); - - return fileObj; -}); +} diff --git a/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts b/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts index eb8071c3c..f4f8df3c2 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts @@ -1,9 +1,12 @@ import ms from 'ms'; -import { uploadFromUrl } from '@/services/drive/upload-from-url.js'; -import { DriveFiles } from '@/models/index.js'; -import { publishMainStream } from '@/services/stream.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { DriveFilesRepository } from '@/models/index.js'; import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js'; -import define from '../../../define.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { DriveService } from '@/core/DriveService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['drive'], @@ -34,13 +37,25 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user, _1, _2, _3, ip, headers) => { - uploadFromUrl({ url: ps.url, user, folderId: ps.folderId, sensitive: ps.isSensitive, force: ps.force, comment: ps.comment, requestIp: ip, requestHeaders: headers }).then(file => { - DriveFiles.pack(file, { self: true }).then(packedFile => { - publishMainStream(user.id, 'urlUploadFinished', { - marker: ps.marker, - file: packedFile, +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private driveFileEntityService: DriveFileEntityService, + private driveService: DriveService, + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, user, _1, _2, _3, ip, headers) => { + this.driveService.uploadFromUrl({ url: ps.url, user, folderId: ps.folderId, sensitive: ps.isSensitive, force: ps.force, comment: ps.comment, requestIp: ip, requestHeaders: headers }).then(file => { + this.driveFileEntityService.pack(file, { self: true }).then(packedFile => { + this.globalEventService.publishMainStream(user.id, 'urlUploadFinished', { + marker: ps.marker, + file: packedFile, + }); + }); }); }); - }); -}); + } +} diff --git a/packages/backend/src/server/api/endpoints/drive/folders.ts b/packages/backend/src/server/api/endpoints/drive/folders.ts index d4d530ba9..703dc83ec 100644 --- a/packages/backend/src/server/api/endpoints/drive/folders.ts +++ b/packages/backend/src/server/api/endpoints/drive/folders.ts @@ -1,6 +1,9 @@ -import define from '../../define.js'; -import { DriveFolders } from '@/models/index.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFoldersRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { DriveFolderEntityService } from '@/core/entities/DriveFolderEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['drive'], @@ -32,17 +35,28 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const query = makePaginationQuery(DriveFolders.createQueryBuilder('folder'), ps.sinceId, ps.untilId) - .andWhere('folder.userId = :userId', { userId: user.id }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.driveFoldersRepository) + private driveFoldersRepository: DriveFoldersRepository, - if (ps.folderId) { - query.andWhere('folder.parentId = :parentId', { parentId: ps.folderId }); - } else { - query.andWhere('folder.parentId IS NULL'); + private driveFolderEntityService: DriveFolderEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.driveFoldersRepository.createQueryBuilder('folder'), ps.sinceId, ps.untilId) + .andWhere('folder.userId = :userId', { userId: me.id }); + + if (ps.folderId) { + query.andWhere('folder.parentId = :parentId', { parentId: ps.folderId }); + } else { + query.andWhere('folder.parentId IS NULL'); + } + + const folders = await query.take(ps.limit).getMany(); + + return await Promise.all(folders.map(folder => this.driveFolderEntityService.pack(folder))); + }); } - - const folders = await query.take(ps.limit).getMany(); - - return await Promise.all(folders.map(folder => DriveFolders.pack(folder))); -}); +} diff --git a/packages/backend/src/server/api/endpoints/drive/folders/create.ts b/packages/backend/src/server/api/endpoints/drive/folders/create.ts index 3d7f514c8..7604eaf48 100644 --- a/packages/backend/src/server/api/endpoints/drive/folders/create.ts +++ b/packages/backend/src/server/api/endpoints/drive/folders/create.ts @@ -1,8 +1,11 @@ -import { publishDriveStream } from '@/services/stream.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFoldersRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { DriveFolderEntityService } from '@/core/entities/DriveFolderEntityService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import { DriveFolders } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; export const meta = { tags: ['drive'], @@ -29,41 +32,53 @@ export const meta = { export const paramDef = { type: 'object', properties: { - name: { type: 'string', default: "Untitled", maxLength: 200 }, + name: { type: 'string', default: 'Untitled', maxLength: 200 }, parentId: { type: 'string', format: 'misskey:id', nullable: true }, }, required: [], } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - // If the parent folder is specified - let parent = null; - if (ps.parentId) { - // Fetch parent folder - parent = await DriveFolders.findOneBy({ - id: ps.parentId, - userId: user.id, +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.driveFoldersRepository) + private driveFoldersRepository: DriveFoldersRepository, + + private driveFolderEntityService: DriveFolderEntityService, + private idService: IdService, + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + // If the parent folder is specified + let parent = null; + if (ps.parentId) { + // Fetch parent folder + parent = await this.driveFoldersRepository.findOneBy({ + id: ps.parentId, + userId: me.id, + }); + + if (parent == null) { + throw new ApiError(meta.errors.noSuchFolder); + } + } + + // Create folder + const folder = await this.driveFoldersRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + name: ps.name, + parentId: parent !== null ? parent.id : null, + userId: me.id, + }).then(x => this.driveFoldersRepository.findOneByOrFail(x.identifiers[0])); + + const folderObj = await this.driveFolderEntityService.pack(folder); + + // Publish folderCreated event + this.globalEventService.publishDriveStream(me.id, 'folderCreated', folderObj); + + return folderObj; }); - - if (parent == null) { - throw new ApiError(meta.errors.noSuchFolder); - } } - - // Create folder - const folder = await DriveFolders.insert({ - id: genId(), - createdAt: new Date(), - name: ps.name, - parentId: parent !== null ? parent.id : null, - userId: user.id, - }).then(x => DriveFolders.findOneByOrFail(x.identifiers[0])); - - const folderObj = await DriveFolders.pack(folder); - - // Publish folderCreated event - publishDriveStream(user.id, 'folderCreated', folderObj); - - return folderObj; -}); +} diff --git a/packages/backend/src/server/api/endpoints/drive/folders/delete.ts b/packages/backend/src/server/api/endpoints/drive/folders/delete.ts index ab9d411ec..dcbaecf8a 100644 --- a/packages/backend/src/server/api/endpoints/drive/folders/delete.ts +++ b/packages/backend/src/server/api/endpoints/drive/folders/delete.ts @@ -1,7 +1,9 @@ -import define from '../../../define.js'; -import { publishDriveStream } from '@/services/stream.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFoldersRepository, DriveFilesRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import { DriveFolders, DriveFiles } from '@/models/index.js'; export const meta = { tags: ['drive'], @@ -34,28 +36,41 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - // Get folder - const folder = await DriveFolders.findOneBy({ - id: ps.folderId, - userId: user.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, - if (folder == null) { - throw new ApiError(meta.errors.noSuchFolder); + @Inject(DI.driveFoldersRepository) + private driveFoldersRepository: DriveFoldersRepository, + + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + // Get folder + const folder = await this.driveFoldersRepository.findOneBy({ + id: ps.folderId, + userId: me.id, + }); + + if (folder == null) { + throw new ApiError(meta.errors.noSuchFolder); + } + + const [childFoldersCount, childFilesCount] = await Promise.all([ + this.driveFoldersRepository.countBy({ parentId: folder.id }), + this.driveFilesRepository.countBy({ folderId: folder.id }), + ]); + + if (childFoldersCount !== 0 || childFilesCount !== 0) { + throw new ApiError(meta.errors.hasChildFilesOrFolders); + } + + await this.driveFoldersRepository.delete(folder.id); + + // Publish folderCreated event + this.globalEventService.publishDriveStream(me.id, 'folderDeleted', folder.id); + }); } - - const [childFoldersCount, childFilesCount] = await Promise.all([ - DriveFolders.countBy({ parentId: folder.id }), - DriveFiles.countBy({ folderId: folder.id }), - ]); - - if (childFoldersCount !== 0 || childFilesCount !== 0) { - throw new ApiError(meta.errors.hasChildFilesOrFolders); - } - - await DriveFolders.delete(folder.id); - - // Publish folderCreated event - publishDriveStream(user.id, 'folderDeleted', folder.id); -}); +} diff --git a/packages/backend/src/server/api/endpoints/drive/folders/find.ts b/packages/backend/src/server/api/endpoints/drive/folders/find.ts index 1feab273a..96a87344a 100644 --- a/packages/backend/src/server/api/endpoints/drive/folders/find.ts +++ b/packages/backend/src/server/api/endpoints/drive/folders/find.ts @@ -1,6 +1,9 @@ -import define from '../../../define.js'; -import { DriveFolders } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; import { IsNull } from 'typeorm'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFoldersRepository } from '@/models/index.js'; +import { DriveFolderEntityService } from '@/core/entities/DriveFolderEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['drive'], @@ -30,12 +33,22 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const folders = await DriveFolders.findBy({ - name: ps.name, - userId: user.id, - parentId: ps.parentId ?? IsNull(), - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.driveFoldersRepository) + private driveFoldersRepository: DriveFoldersRepository, - return await Promise.all(folders.map(folder => DriveFolders.pack(folder))); -}); + private driveFolderEntityService: DriveFolderEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const folders = await this.driveFoldersRepository.findBy({ + name: ps.name, + userId: me.id, + parentId: ps.parentId ?? IsNull(), + }); + + return await Promise.all(folders.map(folder => this.driveFolderEntityService.pack(folder))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/drive/folders/show.ts b/packages/backend/src/server/api/endpoints/drive/folders/show.ts index 1e7aa2b16..4c25bc705 100644 --- a/packages/backend/src/server/api/endpoints/drive/folders/show.ts +++ b/packages/backend/src/server/api/endpoints/drive/folders/show.ts @@ -1,6 +1,9 @@ -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFoldersRepository } from '@/models/index.js'; +import { DriveFolderEntityService } from '@/core/entities/DriveFolderEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import { DriveFolders } from '@/models/index.js'; export const meta = { tags: ['drive'], @@ -33,18 +36,28 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - // Get folder - const folder = await DriveFolders.findOneBy({ - id: ps.folderId, - userId: user.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.driveFoldersRepository) + private driveFoldersRepository: DriveFoldersRepository, - if (folder == null) { - throw new ApiError(meta.errors.noSuchFolder); + private driveFolderEntityService: DriveFolderEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + // Get folder + const folder = await this.driveFoldersRepository.findOneBy({ + id: ps.folderId, + userId: me.id, + }); + + if (folder == null) { + throw new ApiError(meta.errors.noSuchFolder); + } + + return await this.driveFolderEntityService.pack(folder, { + detail: true, + }); + }); } - - return await DriveFolders.pack(folder, { - detail: true, - }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/drive/folders/update.ts b/packages/backend/src/server/api/endpoints/drive/folders/update.ts index 1aa2e8429..4fcd37bbb 100644 --- a/packages/backend/src/server/api/endpoints/drive/folders/update.ts +++ b/packages/backend/src/server/api/endpoints/drive/folders/update.ts @@ -1,7 +1,10 @@ -import { publishDriveStream } from '@/services/stream.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFoldersRepository } from '@/models/index.js'; +import { DriveFolderEntityService } from '@/core/entities/DriveFolderEntityService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import { DriveFolders } from '@/models/index.js'; export const meta = { tags: ['drive'], @@ -48,71 +51,82 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - // Fetch folder - const folder = await DriveFolders.findOneBy({ - id: ps.folderId, - userId: user.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.driveFoldersRepository) + private driveFoldersRepository: DriveFoldersRepository, - if (folder == null) { - throw new ApiError(meta.errors.noSuchFolder); - } - - if (ps.name) folder.name = ps.name; - - if (ps.parentId !== undefined) { - if (ps.parentId === folder.id) { - throw new ApiError(meta.errors.recursiveNesting); - } else if (ps.parentId === null) { - folder.parentId = null; - } else { - // Get parent folder - const parent = await DriveFolders.findOneBy({ - id: ps.parentId, - userId: user.id, + private driveFolderEntityService: DriveFolderEntityService, + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch folder + const folder = await this.driveFoldersRepository.findOneBy({ + id: ps.folderId, + userId: me.id, }); - if (parent == null) { - throw new ApiError(meta.errors.noSuchParentFolder); + if (folder == null) { + throw new ApiError(meta.errors.noSuchFolder); } - // Check if the circular reference will occur - async function checkCircle(folderId: string): Promise { - // Fetch folder - const folder2 = await DriveFolders.findOneBy({ - id: folderId, - }); + if (ps.name) folder.name = ps.name; - if (folder2!.id === folder!.id) { - return true; - } else if (folder2!.parentId) { - return await checkCircle(folder2!.parentId); - } else { - return false; - } - } - - if (parent.parentId !== null) { - if (await checkCircle(parent.parentId)) { + if (ps.parentId !== undefined) { + if (ps.parentId === folder.id) { throw new ApiError(meta.errors.recursiveNesting); + } else if (ps.parentId === null) { + folder.parentId = null; + } else { + // Get parent folder + const parent = await this.driveFoldersRepository.findOneBy({ + id: ps.parentId, + userId: me.id, + }); + + if (parent == null) { + throw new ApiError(meta.errors.noSuchParentFolder); + } + + // Check if the circular reference will occur + const checkCircle = async (folderId: string): Promise => { + // Fetch folder + const folder2 = await this.driveFoldersRepository.findOneBy({ + id: folderId, + }); + + if (folder2!.id === folder!.id) { + return true; + } else if (folder2!.parentId) { + return await checkCircle(folder2!.parentId); + } else { + return false; + } + }; + + if (parent.parentId !== null) { + if (await checkCircle(parent.parentId)) { + throw new ApiError(meta.errors.recursiveNesting); + } + } + + folder.parentId = parent.id; } } - folder.parentId = parent.id; - } + // Update + this.driveFoldersRepository.update(folder.id, { + name: folder.name, + parentId: folder.parentId, + }); + + const folderObj = await this.driveFolderEntityService.pack(folder); + + // Publish folderUpdated event + this.globalEventService.publishDriveStream(me.id, 'folderUpdated', folderObj); + + return folderObj; + }); } - - // Update - DriveFolders.update(folder.id, { - name: folder.name, - parentId: folder.parentId, - }); - - const folderObj = await DriveFolders.pack(folder); - - // Publish folderUpdated event - publishDriveStream(user.id, 'folderUpdated', folderObj); - - return folderObj; -}); +} diff --git a/packages/backend/src/server/api/endpoints/drive/stream.ts b/packages/backend/src/server/api/endpoints/drive/stream.ts index 99e8d024f..aba73c209 100644 --- a/packages/backend/src/server/api/endpoints/drive/stream.ts +++ b/packages/backend/src/server/api/endpoints/drive/stream.ts @@ -1,6 +1,9 @@ -import define from '../../define.js'; -import { DriveFiles } from '@/models/index.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFilesRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['drive'], @@ -32,19 +35,30 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const query = makePaginationQuery(DriveFiles.createQueryBuilder('file'), ps.sinceId, ps.untilId) - .andWhere('file.userId = :userId', { userId: user.id }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, - if (ps.type) { - if (ps.type.endsWith('/*')) { - query.andWhere('file.type like :type', { type: ps.type.replace('/*', '/') + '%' }); - } else { - query.andWhere('file.type = :type', { type: ps.type }); - } + private driveFileEntityService: DriveFileEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.driveFilesRepository.createQueryBuilder('file'), ps.sinceId, ps.untilId) + .andWhere('file.userId = :userId', { userId: me.id }); + + if (ps.type) { + if (ps.type.endsWith('/*')) { + query.andWhere('file.type like :type', { type: ps.type.replace('/*', '/') + '%' }); + } else { + query.andWhere('file.type = :type', { type: ps.type }); + } + } + + const files = await query.take(ps.limit).getMany(); + + return await this.driveFileEntityService.packMany(files, { detail: false, self: true }); + }); } - - const files = await query.take(ps.limit).getMany(); - - return await DriveFiles.packMany(files, { detail: false, self: true }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/email-address/available.ts b/packages/backend/src/server/api/endpoints/email-address/available.ts index 07064ce9f..8a497a514 100644 --- a/packages/backend/src/server/api/endpoints/email-address/available.ts +++ b/packages/backend/src/server/api/endpoints/email-address/available.ts @@ -1,5 +1,6 @@ -import define from '../../define.js'; -import { validateEmailForAccount } from '@/services/validate-email-for-account.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { EmailService } from '@/core/EmailService.js'; export const meta = { tags: ['users'], @@ -31,6 +32,13 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - return await validateEmailForAccount(ps.emailAddress); -}); +@Injectable() +export default class extends Endpoint { + constructor( + private emailService: EmailService, + ) { + super(meta, paramDef, async (ps, me) => { + return await this.emailService.validateEmailForAccount(ps.emailAddress); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/endpoint.ts b/packages/backend/src/server/api/endpoints/endpoint.ts index c17412677..a337a05f8 100644 --- a/packages/backend/src/server/api/endpoints/endpoint.ts +++ b/packages/backend/src/server/api/endpoints/endpoint.ts @@ -1,4 +1,5 @@ -import define from '../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; import endpoints from '../endpoints.js'; export const meta = { @@ -16,13 +17,19 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - const ep = endpoints.find(x => x.name === ps.endpoint); - if (ep == null) return null; - return { - params: Object.entries(ep.params.properties || {}).map(([k, v]) => ({ - name: k, - type: v.type.charAt(0).toUpperCase() + v.type.slice(1), - })), - }; -}); +@Injectable() +export default class extends Endpoint { + constructor( + ) { + super(meta, paramDef, async (ps) => { + const ep = endpoints.find(x => x.name === ps.endpoint); + if (ep == null) return null; + return { + params: Object.entries(ep.params.properties ?? {}).map(([k, v]) => ({ + name: k, + type: v.type.charAt(0).toUpperCase() + v.type.slice(1), + })), + }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/endpoints.ts b/packages/backend/src/server/api/endpoints/endpoints.ts index b20da96eb..91fc3ec98 100644 --- a/packages/backend/src/server/api/endpoints/endpoints.ts +++ b/packages/backend/src/server/api/endpoints/endpoints.ts @@ -1,4 +1,5 @@ -import define from '../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; import endpoints from '../endpoints.js'; export const meta = { @@ -29,6 +30,12 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async () => { - return endpoints.map(x => x.name); -}); +@Injectable() +export default class extends Endpoint { + constructor( + ) { + super(meta, paramDef, async () => { + return endpoints.map(x => x.name); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/export-custom-emojis.ts b/packages/backend/src/server/api/endpoints/export-custom-emojis.ts index 5fe622932..ead6b037c 100644 --- a/packages/backend/src/server/api/endpoints/export-custom-emojis.ts +++ b/packages/backend/src/server/api/endpoints/export-custom-emojis.ts @@ -1,6 +1,7 @@ import ms from 'ms'; -import { createExportCustomEmojisJob } from '@/queue/index.js'; -import define from '../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueueService } from '@/core/QueueService.js'; export const meta = { secure: true, @@ -18,6 +19,13 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - createExportCustomEmojisJob(user); -}); +@Injectable() +export default class extends Endpoint { + constructor( + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + this.queueService.createExportCustomEmojisJob(me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/federation/followers.ts b/packages/backend/src/server/api/endpoints/federation/followers.ts index 7b1197d1e..e5222fcbf 100644 --- a/packages/backend/src/server/api/endpoints/federation/followers.ts +++ b/packages/backend/src/server/api/endpoints/federation/followers.ts @@ -1,6 +1,9 @@ -import define from '../../define.js'; -import { Followings } from '@/models/index.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { FollowingsRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { FollowingEntityService } from '@/core/entities/FollowingEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['federation'], @@ -30,13 +33,24 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const query = makePaginationQuery(Followings.createQueryBuilder('following'), ps.sinceId, ps.untilId) - .andWhere(`following.followeeHost = :host`, { host: ps.host }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, - const followings = await query - .take(ps.limit) - .getMany(); + private followingEntityService: FollowingEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId) + .andWhere('following.followeeHost = :host', { host: ps.host }); - return await Followings.packMany(followings, me, { populateFollowee: true }); -}); + const followings = await query + .take(ps.limit) + .getMany(); + + return await this.followingEntityService.packMany(followings, me, { populateFollowee: true }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/federation/following.ts b/packages/backend/src/server/api/endpoints/federation/following.ts index ed1f142d8..a20c5a31b 100644 --- a/packages/backend/src/server/api/endpoints/federation/following.ts +++ b/packages/backend/src/server/api/endpoints/federation/following.ts @@ -1,6 +1,9 @@ -import define from '../../define.js'; -import { Followings } from '@/models/index.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { FollowingsRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { FollowingEntityService } from '@/core/entities/FollowingEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['federation'], @@ -30,13 +33,24 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const query = makePaginationQuery(Followings.createQueryBuilder('following'), ps.sinceId, ps.untilId) - .andWhere(`following.followerHost = :host`, { host: ps.host }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, - const followings = await query - .take(ps.limit) - .getMany(); + private followingEntityService: FollowingEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId) + .andWhere('following.followerHost = :host', { host: ps.host }); - return await Followings.packMany(followings, me, { populateFollowee: true }); -}); + const followings = await query + .take(ps.limit) + .getMany(); + + return await this.followingEntityService.packMany(followings, me, { populateFollowee: true }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/federation/instances.ts b/packages/backend/src/server/api/endpoints/federation/instances.ts index 07e5c07c6..e7f8cefff 100644 --- a/packages/backend/src/server/api/endpoints/federation/instances.ts +++ b/packages/backend/src/server/api/endpoints/federation/instances.ts @@ -1,7 +1,9 @@ -import config from '@/config/index.js'; -import define from '../../define.js'; -import { Instances } from '@/models/index.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { InstancesRepository } from '@/models/index.js'; +import { InstanceEntityService } from '@/core/entities/InstanceEntityService.js'; +import { MetaService } from '@/core/MetaService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['federation'], @@ -37,82 +39,93 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const query = Instances.createQueryBuilder('instance'); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, - switch (ps.sort) { - case '+pubSub': query.orderBy('instance.followingCount', 'DESC').orderBy('instance.followersCount', 'DESC'); break; - case '-pubSub': query.orderBy('instance.followingCount', 'ASC').orderBy('instance.followersCount', 'ASC'); break; - case '+notes': query.orderBy('instance.notesCount', 'DESC'); break; - case '-notes': query.orderBy('instance.notesCount', 'ASC'); break; - case '+users': query.orderBy('instance.usersCount', 'DESC'); break; - case '-users': query.orderBy('instance.usersCount', 'ASC'); break; - case '+following': query.orderBy('instance.followingCount', 'DESC'); break; - case '-following': query.orderBy('instance.followingCount', 'ASC'); break; - case '+followers': query.orderBy('instance.followersCount', 'DESC'); break; - case '-followers': query.orderBy('instance.followersCount', 'ASC'); break; - case '+caughtAt': query.orderBy('instance.caughtAt', 'DESC'); break; - case '-caughtAt': query.orderBy('instance.caughtAt', 'ASC'); break; - case '+lastCommunicatedAt': query.orderBy('instance.lastCommunicatedAt', 'DESC'); break; - case '-lastCommunicatedAt': query.orderBy('instance.lastCommunicatedAt', 'ASC'); break; + private instanceEntityService: InstanceEntityService, + private metaService: MetaService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.instancesRepository.createQueryBuilder('instance'); - default: query.orderBy('instance.id', 'DESC'); break; + switch (ps.sort) { + case '+pubSub': query.orderBy('instance.followingCount', 'DESC').orderBy('instance.followersCount', 'DESC'); break; + case '-pubSub': query.orderBy('instance.followingCount', 'ASC').orderBy('instance.followersCount', 'ASC'); break; + case '+notes': query.orderBy('instance.notesCount', 'DESC'); break; + case '-notes': query.orderBy('instance.notesCount', 'ASC'); break; + case '+users': query.orderBy('instance.usersCount', 'DESC'); break; + case '-users': query.orderBy('instance.usersCount', 'ASC'); break; + case '+following': query.orderBy('instance.followingCount', 'DESC'); break; + case '-following': query.orderBy('instance.followingCount', 'ASC'); break; + case '+followers': query.orderBy('instance.followersCount', 'DESC'); break; + case '-followers': query.orderBy('instance.followersCount', 'ASC'); break; + case '+caughtAt': query.orderBy('instance.caughtAt', 'DESC'); break; + case '-caughtAt': query.orderBy('instance.caughtAt', 'ASC'); break; + case '+lastCommunicatedAt': query.orderBy('instance.lastCommunicatedAt', 'DESC'); break; + case '-lastCommunicatedAt': query.orderBy('instance.lastCommunicatedAt', 'ASC'); break; + + default: query.orderBy('instance.id', 'DESC'); break; + } + + if (typeof ps.blocked === 'boolean') { + const meta = await this.metaService.fetch(true); + if (ps.blocked) { + query.andWhere('instance.host IN (:...blocks)', { blocks: meta.blockedHosts }); + } else { + query.andWhere('instance.host NOT IN (:...blocks)', { blocks: meta.blockedHosts }); + } + } + + if (typeof ps.notResponding === 'boolean') { + if (ps.notResponding) { + query.andWhere('instance.isNotResponding = TRUE'); + } else { + query.andWhere('instance.isNotResponding = FALSE'); + } + } + + if (typeof ps.suspended === 'boolean') { + if (ps.suspended) { + query.andWhere('instance.isSuspended = TRUE'); + } else { + query.andWhere('instance.isSuspended = FALSE'); + } + } + + if (typeof ps.federating === 'boolean') { + if (ps.federating) { + query.andWhere('((instance.followingCount > 0) OR (instance.followersCount > 0))'); + } else { + query.andWhere('((instance.followingCount = 0) AND (instance.followersCount = 0))'); + } + } + + if (typeof ps.subscribing === 'boolean') { + if (ps.subscribing) { + query.andWhere('instance.followersCount > 0'); + } else { + query.andWhere('instance.followersCount = 0'); + } + } + + if (typeof ps.publishing === 'boolean') { + if (ps.publishing) { + query.andWhere('instance.followingCount > 0'); + } else { + query.andWhere('instance.followingCount = 0'); + } + } + + if (ps.host) { + query.andWhere('instance.host like :host', { host: '%' + ps.host.toLowerCase() + '%' }); + } + + const instances = await query.take(ps.limit).skip(ps.offset).getMany(); + + return await this.instanceEntityService.packMany(instances); + }); } - - if (typeof ps.blocked === 'boolean') { - const meta = await fetchMeta(true); - if (ps.blocked) { - query.andWhere('instance.host IN (:...blocks)', { blocks: meta.blockedHosts }); - } else { - query.andWhere('instance.host NOT IN (:...blocks)', { blocks: meta.blockedHosts }); - } - } - - if (typeof ps.notResponding === 'boolean') { - if (ps.notResponding) { - query.andWhere('instance.isNotResponding = TRUE'); - } else { - query.andWhere('instance.isNotResponding = FALSE'); - } - } - - if (typeof ps.suspended === 'boolean') { - if (ps.suspended) { - query.andWhere('instance.isSuspended = TRUE'); - } else { - query.andWhere('instance.isSuspended = FALSE'); - } - } - - if (typeof ps.federating === 'boolean') { - if (ps.federating) { - query.andWhere('((instance.followingCount > 0) OR (instance.followersCount > 0))'); - } else { - query.andWhere('((instance.followingCount = 0) AND (instance.followersCount = 0))'); - } - } - - if (typeof ps.subscribing === 'boolean') { - if (ps.subscribing) { - query.andWhere('instance.followersCount > 0'); - } else { - query.andWhere('instance.followersCount = 0'); - } - } - - if (typeof ps.publishing === 'boolean') { - if (ps.publishing) { - query.andWhere('instance.followingCount > 0'); - } else { - query.andWhere('instance.followingCount = 0'); - } - } - - if (ps.host) { - query.andWhere('instance.host like :host', { host: '%' + ps.host.toLowerCase() + '%' }); - } - - const instances = await query.take(ps.limit).skip(ps.offset).getMany(); - - return await Instances.packMany(instances); -}); +} diff --git a/packages/backend/src/server/api/endpoints/federation/show-instance.ts b/packages/backend/src/server/api/endpoints/federation/show-instance.ts index 2fbb8a15c..f855b5453 100644 --- a/packages/backend/src/server/api/endpoints/federation/show-instance.ts +++ b/packages/backend/src/server/api/endpoints/federation/show-instance.ts @@ -1,6 +1,9 @@ -import define from '../../define.js'; -import { Instances } from '@/models/index.js'; -import { toPuny } from '@/misc/convert-host.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { InstancesRepository } from '@/models/index.js'; +import { InstanceEntityService } from '@/core/entities/InstanceEntityService.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['federation'], @@ -26,9 +29,20 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const instance = await Instances - .findOneBy({ host: toPuny(ps.host) }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, - return instance ? await Instances.pack(instance) : null; -}); + private utilityService: UtilityService, + private instanceEntityService: InstanceEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const instance = await this.instancesRepository + .findOneBy({ host: this.utilityService.toPuny(ps.host) }); + + return instance ? await this.instanceEntityService.pack(instance) : null; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/federation/stats.ts b/packages/backend/src/server/api/endpoints/federation/stats.ts index e02c7b97e..d07a08637 100644 --- a/packages/backend/src/server/api/endpoints/federation/stats.ts +++ b/packages/backend/src/server/api/endpoints/federation/stats.ts @@ -1,7 +1,10 @@ import { IsNull, MoreThan, Not } from 'typeorm'; -import { Followings, Instances } from '@/models/index.js'; -import { awaitAll } from '@/prelude/await-all.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { FollowingsRepository, InstancesRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { InstanceEntityService } from '@/core/entities/InstanceEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['federation'], @@ -21,45 +24,58 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - const [topSubInstances, topPubInstances, allSubCount, allPubCount] = await Promise.all([ - Instances.find({ - where: { - followersCount: MoreThan(0), - }, - order: { - followersCount: 'DESC', - }, - take: ps.limit, - }), - Instances.find({ - where: { - followingCount: MoreThan(0), - }, - order: { - followingCount: 'DESC', - }, - take: ps.limit, - }), - Followings.count({ - where: { - followeeHost: Not(IsNull()), - }, - }), - Followings.count({ - where: { - followerHost: Not(IsNull()), - }, - }), - ]); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, - const gotSubCount = topSubInstances.map(x => x.followersCount).reduce((a, b) => a + b, 0); - const gotPubCount = topPubInstances.map(x => x.followingCount).reduce((a, b) => a + b, 0); + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, - return await awaitAll({ - topSubInstances: Instances.packMany(topSubInstances), - otherFollowersCount: Math.max(0, allSubCount - gotSubCount), - topPubInstances: Instances.packMany(topPubInstances), - otherFollowingCount: Math.max(0, allPubCount - gotPubCount), - }); -}); + private instanceEntityService: InstanceEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const [topSubInstances, topPubInstances, allSubCount, allPubCount] = await Promise.all([ + this.instancesRepository.find({ + where: { + followersCount: MoreThan(0), + }, + order: { + followersCount: 'DESC', + }, + take: ps.limit, + }), + this.instancesRepository.find({ + where: { + followingCount: MoreThan(0), + }, + order: { + followingCount: 'DESC', + }, + take: ps.limit, + }), + this.followingsRepository.count({ + where: { + followeeHost: Not(IsNull()), + }, + }), + this.followingsRepository.count({ + where: { + followerHost: Not(IsNull()), + }, + }), + ]); + + const gotSubCount = topSubInstances.map(x => x.followersCount).reduce((a, b) => a + b, 0); + const gotPubCount = topPubInstances.map(x => x.followingCount).reduce((a, b) => a + b, 0); + + return await awaitAll({ + topSubInstances: this.instanceEntityService.packMany(topSubInstances), + otherFollowersCount: Math.max(0, allSubCount - gotSubCount), + topPubInstances: this.instanceEntityService.packMany(topPubInstances), + otherFollowingCount: Math.max(0, allPubCount - gotPubCount), + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts b/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts index 409cc7695..57497bbf6 100644 --- a/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts +++ b/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts @@ -1,6 +1,7 @@ -import define from '../../define.js'; -import { getRemoteUser } from '../../common/getters.js'; -import { updatePerson } from '@/remote/activitypub/models/person.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApPersonService } from '@/core/remote/activitypub/models/ApPersonService.js'; +import { GetterService } from '../../common/GetterService.js'; export const meta = { tags: ['federation'], @@ -17,7 +18,15 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - const user = await getRemoteUser(ps.userId); - await updatePerson(user.uri!); -}); +@Injectable() +export default class extends Endpoint { + constructor( + private getterService: GetterService, + private apPersonService: ApPersonService, + ) { + super(meta, paramDef, async (ps) => { + const user = await this.getterService.getRemoteUser(ps.userId); + await this.apPersonService.updatePerson(user.uri!); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/federation/users.ts b/packages/backend/src/server/api/endpoints/federation/users.ts index 65ad9f88d..0400cacd0 100644 --- a/packages/backend/src/server/api/endpoints/federation/users.ts +++ b/packages/backend/src/server/api/endpoints/federation/users.ts @@ -1,6 +1,9 @@ -import define from '../../define.js'; -import { Users } from '@/models/index.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UsersRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['federation'], @@ -30,13 +33,24 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const query = makePaginationQuery(Users.createQueryBuilder('user'), ps.sinceId, ps.untilId) - .andWhere(`user.host = :host`, { host: ps.host }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - const users = await query - .take(ps.limit) - .getMany(); + private userEntityService: UserEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.usersRepository.createQueryBuilder('user'), ps.sinceId, ps.untilId) + .andWhere('user.host = :host', { host: ps.host }); - return await Users.packMany(users, me, { detail: true }); -}); + const users = await query + .take(ps.limit) + .getMany(); + + return await this.userEntityService.packMany(users, me, { detail: true }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/fetch-rss.ts b/packages/backend/src/server/api/endpoints/fetch-rss.ts index 05fa22a9e..9e6a3cc71 100644 --- a/packages/backend/src/server/api/endpoints/fetch-rss.ts +++ b/packages/backend/src/server/api/endpoints/fetch-rss.ts @@ -1,7 +1,9 @@ import Parser from 'rss-parser'; -import { getResponse } from '@/misc/fetch.js'; -import config from '@/config/index.js'; -import define from '../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; const rssParser = new Parser(); @@ -22,18 +24,28 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - const res = await getResponse({ - url: ps.url, - method: 'GET', - headers: Object.assign({ - 'User-Agent': config.userAgent, - Accept: 'application/rss+xml, */*', - }), - timeout: 5000, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.config) + private config: Config, - const text = await res.text(); + private httpRequestService: HttpRequestService, + ) { + super(meta, paramDef, async (ps, me) => { + const res = await this.httpRequestService.getResponse({ + url: ps.url, + method: 'GET', + headers: Object.assign({ + 'User-Agent': config.userAgent, + Accept: 'application/rss+xml, */*', + }), + timeout: 5000, + }); - return rssParser.parseString(text); -}); + const text = await res.text(); + + return rssParser.parseString(text); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/following/create.ts b/packages/backend/src/server/api/endpoints/following/create.ts index 02a030cd5..3a06c63d5 100644 --- a/packages/backend/src/server/api/endpoints/following/create.ts +++ b/packages/backend/src/server/api/endpoints/following/create.ts @@ -1,10 +1,13 @@ import ms from 'ms'; -import create from '@/services/following/create.js'; -import define from '../../define.js'; -import { ApiError } from '../../error.js'; -import { getUser } from '../../common/getters.js'; -import { Followings, Users } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UsersRepository, FollowingsRepository } from '@/models/index.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; +import { GetterService } from '../../common/GetterService.js'; export const meta = { tags: ['following', 'users'], @@ -66,39 +69,54 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const follower = user; +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - // 自分自身 - if (user.id === ps.userId) { - throw new ApiError(meta.errors.followeeIsYourself); + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + private userEntityService: UserEntityService, + private getterService: GetterService, + private userFollowingService: UserFollowingService, + ) { + super(meta, paramDef, async (ps, me) => { + const follower = me; + + // 自分自身 + if (me.id === ps.userId) { + throw new ApiError(meta.errors.followeeIsYourself); + } + + // Get followee + const followee = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + // Check if already following + const exist = await this.followingsRepository.findOneBy({ + followerId: follower.id, + followeeId: followee.id, + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyFollowing); + } + + try { + await this.userFollowingService.follow(follower, followee); + } catch (e) { + if (e instanceof IdentifiableError) { + if (e.id === '710e8fb0-b8c3-4922-be49-d5d93d8e6a6e') throw new ApiError(meta.errors.blocking); + if (e.id === '3338392a-f764-498d-8855-db939dcf8c48') throw new ApiError(meta.errors.blocked); + } + throw e; + } + + return await this.userEntityService.pack(followee.id, me); + }); } - - // Get followee - const followee = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw e; - }); - - // Check if already following - const exist = await Followings.findOneBy({ - followerId: follower.id, - followeeId: followee.id, - }); - - if (exist != null) { - throw new ApiError(meta.errors.alreadyFollowing); - } - - try { - await create(follower, followee); - } catch (e) { - if (e instanceof IdentifiableError) { - if (e.id === '710e8fb0-b8c3-4922-be49-d5d93d8e6a6e') throw new ApiError(meta.errors.blocking); - if (e.id === '3338392a-f764-498d-8855-db939dcf8c48') throw new ApiError(meta.errors.blocked); - } - throw e; - } - - return await Users.pack(followee.id, user); -}); +} diff --git a/packages/backend/src/server/api/endpoints/following/delete.ts b/packages/backend/src/server/api/endpoints/following/delete.ts index 2f41b16e9..07366bc82 100644 --- a/packages/backend/src/server/api/endpoints/following/delete.ts +++ b/packages/backend/src/server/api/endpoints/following/delete.ts @@ -1,9 +1,12 @@ import ms from 'ms'; -import deleteFollowing from '@/services/following/delete.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UsersRepository, FollowingsRepository } from '@/models/index.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { getUser } from '../../common/getters.js'; -import { Followings, Users } from '@/models/index.js'; +import { GetterService } from '../../common/GetterService.js'; export const meta = { tags: ['following', 'users'], @@ -53,31 +56,46 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const follower = user; +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - // Check if the followee is yourself - if (user.id === ps.userId) { - throw new ApiError(meta.errors.followeeIsYourself); + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + private userEntityService: UserEntityService, + private getterService: GetterService, + private userFollowingService: UserFollowingService, + ) { + super(meta, paramDef, async (ps, me) => { + const follower = me; + + // Check if the followee is yourself + if (me.id === ps.userId) { + throw new ApiError(meta.errors.followeeIsYourself); + } + + // Get followee + const followee = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + // Check not following + const exist = await this.followingsRepository.findOneBy({ + followerId: follower.id, + followeeId: followee.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notFollowing); + } + + await this.userFollowingService.unfollow(follower, followee); + + return await this.userEntityService.pack(followee.id, me); + }); } - - // Get followee - const followee = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw e; - }); - - // Check not following - const exist = await Followings.findOneBy({ - followerId: follower.id, - followeeId: followee.id, - }); - - if (exist == null) { - throw new ApiError(meta.errors.notFollowing); - } - - await deleteFollowing(follower, followee); - - return await Users.pack(followee.id, user); -}); +} diff --git a/packages/backend/src/server/api/endpoints/following/invalidate.ts b/packages/backend/src/server/api/endpoints/following/invalidate.ts index 18ec5affe..8285189d6 100644 --- a/packages/backend/src/server/api/endpoints/following/invalidate.ts +++ b/packages/backend/src/server/api/endpoints/following/invalidate.ts @@ -1,9 +1,12 @@ import ms from 'ms'; -import deleteFollowing from '@/services/following/delete.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UsersRepository, FollowingsRepository } from '@/models/index.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { getUser } from '../../common/getters.js'; -import { Followings, Users } from '@/models/index.js'; +import { GetterService } from '../../common/GetterService.js'; export const meta = { tags: ['following', 'users'], @@ -53,31 +56,46 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const followee = user; +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - // Check if the follower is yourself - if (user.id === ps.userId) { - throw new ApiError(meta.errors.followerIsYourself); + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + private userEntityService: UserEntityService, + private getterService: GetterService, + private userFollowingService: UserFollowingService, + ) { + super(meta, paramDef, async (ps, me) => { + const followee = me; + + // Check if the follower is yourself + if (me.id === ps.userId) { + throw new ApiError(meta.errors.followerIsYourself); + } + + // Get follower + const follower = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + // Check not following + const exist = await this.followingsRepository.findOneBy({ + followerId: follower.id, + followeeId: followee.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notFollowing); + } + + await this.userFollowingService.unfollow(follower, followee); + + return await this.userEntityService.pack(followee.id, me); + }); } - - // Get follower - const follower = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw e; - }); - - // Check not following - const exist = await Followings.findOneBy({ - followerId: follower.id, - followeeId: followee.id, - }); - - if (exist == null) { - throw new ApiError(meta.errors.notFollowing); - } - - await deleteFollowing(follower, followee); - - return await Users.pack(followee.id, user); -}); +} diff --git a/packages/backend/src/server/api/endpoints/following/requests/accept.ts b/packages/backend/src/server/api/endpoints/following/requests/accept.ts index e5df55375..5f082c087 100644 --- a/packages/backend/src/server/api/endpoints/following/requests/accept.ts +++ b/packages/backend/src/server/api/endpoints/following/requests/accept.ts @@ -1,7 +1,8 @@ -import acceptFollowRequest from '@/services/following/requests/accept.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GetterService } from '@/server/api/common/GetterService.js'; +import { UserFollowingService } from '@/core/UserFollowingService.js'; import { ApiError } from '../../../error.js'; -import { getUser } from '../../../common/getters.js'; export const meta = { tags: ['following', 'account'], @@ -33,17 +34,25 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - // Fetch follower - const follower = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw e; - }); +@Injectable() +export default class extends Endpoint { + constructor( + private getterService: GetterService, + private userFollowingService: UserFollowingService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch follower + const follower = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); - await acceptFollowRequest(user, follower).catch(e => { - if (e.id === '8884c2dd-5795-4ac9-b27e-6a01d38190f9') throw new ApiError(meta.errors.noFollowRequest); - throw e; - }); + await this.userFollowingService.acceptFollowRequest(me, follower).catch(err => { + if (err.id === '8884c2dd-5795-4ac9-b27e-6a01d38190f9') throw new ApiError(meta.errors.noFollowRequest); + throw err; + }); - return; -}); + return; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/following/requests/cancel.ts b/packages/backend/src/server/api/endpoints/following/requests/cancel.ts index 80d37fb07..0b79a8064 100644 --- a/packages/backend/src/server/api/endpoints/following/requests/cancel.ts +++ b/packages/backend/src/server/api/endpoints/following/requests/cancel.ts @@ -1,9 +1,13 @@ -import cancelFollowRequest from '@/services/following/requests/cancel.js'; -import define from '../../../define.js'; -import { ApiError } from '../../../error.js'; -import { getUser } from '../../../common/getters.js'; -import { Users } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { FollowingsRepository } from '@/models/index.js'; +import type { UsersRepository } from '@/models/index.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { GetterService } from '@/server/api/common/GetterService.js'; +import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../../error.js'; export const meta = { tags: ['following', 'account'], @@ -42,21 +46,33 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - // Fetch followee - const followee = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw e; - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, - try { - await cancelFollowRequest(followee, user); - } catch (e) { - if (e instanceof IdentifiableError) { - if (e.id === '17447091-ce07-46dd-b331-c1fd4f15b1e7') throw new ApiError(meta.errors.followRequestNotFound); - } - throw e; + private userEntityService: UserEntityService, + private getterService: GetterService, + private userFollowingService: UserFollowingService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch followee + const followee = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + try { + await this.userFollowingService.cancelFollowRequest(followee, me); + } catch (err) { + if (err instanceof IdentifiableError) { + if (err.id === '17447091-ce07-46dd-b331-c1fd4f15b1e7') throw new ApiError(meta.errors.followRequestNotFound); + } + throw err; + } + + return await this.userEntityService.pack(followee.id, me); + }); } - - return await Users.pack(followee.id, user); -}); +} diff --git a/packages/backend/src/server/api/endpoints/following/requests/list.ts b/packages/backend/src/server/api/endpoints/following/requests/list.ts index a8f42c481..c36d4a077 100644 --- a/packages/backend/src/server/api/endpoints/following/requests/list.ts +++ b/packages/backend/src/server/api/endpoints/following/requests/list.ts @@ -1,5 +1,8 @@ -import define from '../../../define.js'; -import { FollowRequests } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { FollowRequestsRepository } from '@/models/index.js'; +import { FollowRequestEntityService } from '@/core/entities/FollowRequestEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['following', 'account'], @@ -42,10 +45,20 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const reqs = await FollowRequests.findBy({ - followeeId: user.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.followRequestsRepository) + private followRequestsRepository: FollowRequestsRepository, - return await Promise.all(reqs.map(req => FollowRequests.pack(req))); -}); + private followRequestEntityService: FollowRequestEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const reqs = await this.followRequestsRepository.findBy({ + followeeId: me.id, + }); + + return await Promise.all(reqs.map(req => this.followRequestEntityService.pack(req))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/following/requests/reject.ts b/packages/backend/src/server/api/endpoints/following/requests/reject.ts index cebe60428..663c659bf 100644 --- a/packages/backend/src/server/api/endpoints/following/requests/reject.ts +++ b/packages/backend/src/server/api/endpoints/following/requests/reject.ts @@ -1,7 +1,8 @@ -import { rejectFollowRequest } from '@/services/following/reject.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GetterService } from '@/server/api/common/GetterService.js'; +import { UserFollowingService } from '@/core/UserFollowingService.js'; import { ApiError } from '../../../error.js'; -import { getUser } from '../../../common/getters.js'; export const meta = { tags: ['following', 'account'], @@ -28,14 +29,22 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - // Fetch follower - const follower = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw e; - }); +@Injectable() +export default class extends Endpoint { + constructor( + private getterService: GetterService, + private userFollowingService: UserFollowingService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch follower + const follower = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); - await rejectFollowRequest(user, follower); + await this.userFollowingService.rejectFollowRequest(me, follower); - return; -}); + return; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/gallery/featured.ts b/packages/backend/src/server/api/endpoints/gallery/featured.ts index e6acd3691..3b892ef52 100644 --- a/packages/backend/src/server/api/endpoints/gallery/featured.ts +++ b/packages/backend/src/server/api/endpoints/gallery/featured.ts @@ -1,5 +1,8 @@ -import define from '../../define.js'; -import { GalleryPosts } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GalleryPostsRepository } from '@/models/index.js'; +import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['gallery'], @@ -24,13 +27,23 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const query = GalleryPosts.createQueryBuilder('post') - .andWhere('post.createdAt > :date', { date: new Date(Date.now() - (1000 * 60 * 60 * 24 * 3)) }) - .andWhere('post.likedCount > 0') - .orderBy('post.likedCount', 'DESC'); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.galleryPostsRepository) + private galleryPostsRepository: GalleryPostsRepository, - const posts = await query.take(10).getMany(); + private galleryPostEntityService: GalleryPostEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.galleryPostsRepository.createQueryBuilder('post') + .andWhere('post.createdAt > :date', { date: new Date(Date.now() - (1000 * 60 * 60 * 24 * 3)) }) + .andWhere('post.likedCount > 0') + .orderBy('post.likedCount', 'DESC'); - return await GalleryPosts.packMany(posts, me); -}); + const posts = await query.take(10).getMany(); + + return await this.galleryPostEntityService.packMany(posts, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/gallery/popular.ts b/packages/backend/src/server/api/endpoints/gallery/popular.ts index c4c8982fc..551ea6483 100644 --- a/packages/backend/src/server/api/endpoints/gallery/popular.ts +++ b/packages/backend/src/server/api/endpoints/gallery/popular.ts @@ -1,5 +1,8 @@ -import define from '../../define.js'; -import { GalleryPosts } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GalleryPostsRepository } from '@/models/index.js'; +import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['gallery'], @@ -24,12 +27,22 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const query = GalleryPosts.createQueryBuilder('post') - .andWhere('post.likedCount > 0') - .orderBy('post.likedCount', 'DESC'); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.galleryPostsRepository) + private galleryPostsRepository: GalleryPostsRepository, - const posts = await query.take(10).getMany(); + private galleryPostEntityService: GalleryPostEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.galleryPostsRepository.createQueryBuilder('post') + .andWhere('post.likedCount > 0') + .orderBy('post.likedCount', 'DESC'); - return await GalleryPosts.packMany(posts, me); -}); + const posts = await query.take(10).getMany(); + + return await this.galleryPostEntityService.packMany(posts, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/gallery/posts.ts b/packages/backend/src/server/api/endpoints/gallery/posts.ts index 428ba9cc7..4afcbce81 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts.ts @@ -1,6 +1,9 @@ -import define from '../../define.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; -import { GalleryPosts } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GalleryPostsRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['gallery'], @@ -27,11 +30,22 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const query = makePaginationQuery(GalleryPosts.createQueryBuilder('post'), ps.sinceId, ps.untilId) - .innerJoinAndSelect('post.user', 'user'); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.galleryPostsRepository) + private galleryPostsRepository: GalleryPostsRepository, - const posts = await query.take(ps.limit).getMany(); + private galleryPostEntityService: GalleryPostEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.galleryPostsRepository.createQueryBuilder('post'), ps.sinceId, ps.untilId) + .innerJoinAndSelect('post.user', 'user'); - return await GalleryPosts.packMany(posts, me); -}); + const posts = await query.take(ps.limit).getMany(); + + return await this.galleryPostEntityService.packMany(posts, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/create.ts b/packages/backend/src/server/api/endpoints/gallery/posts/create.ts index 8074a3b34..9e8bcac13 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/create.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/create.ts @@ -1,10 +1,13 @@ import ms from 'ms'; -import define from '../../../define.js'; -import { DriveFiles, GalleryPosts } from '@/models/index.js'; -import { genId } from '../../../../../misc/gen-id.js'; -import { GalleryPost } from '@/models/entities/gallery-post.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFilesRepository, GalleryPostsRepository } from '@/models/index.js'; +import { GalleryPost } from '@/models/entities/GalleryPost.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import { IdService } from '@/core/IdService.js'; +import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import { DriveFile } from '@/models/entities/drive-file.js'; export const meta = { tags: ['gallery'], @@ -43,28 +46,42 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const files = (await Promise.all(ps.fileIds.map(fileId => - DriveFiles.findOneBy({ - id: fileId, - userId: user.id, - }) - ))).filter((file): file is DriveFile => file != null); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.galleryPostsRepository) + private galleryPostsRepository: GalleryPostsRepository, - if (files.length === 0) { - throw new Error(); + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private galleryPostEntityService: GalleryPostEntityService, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const files = (await Promise.all(ps.fileIds.map(fileId => + this.driveFilesRepository.findOneBy({ + id: fileId, + userId: me.id, + }), + ))).filter((file): file is DriveFile => file != null); + + if (files.length === 0) { + throw new Error(); + } + + const post = await this.galleryPostsRepository.insert(new GalleryPost({ + id: this.idService.genId(), + createdAt: new Date(), + updatedAt: new Date(), + title: ps.title, + description: ps.description, + userId: me.id, + isSensitive: ps.isSensitive, + fileIds: files.map(file => file.id), + })).then(x => this.galleryPostsRepository.findOneByOrFail(x.identifiers[0])); + + return await this.galleryPostEntityService.pack(post, me); + }); } - - const post = await GalleryPosts.insert(new GalleryPost({ - id: genId(), - createdAt: new Date(), - updatedAt: new Date(), - title: ps.title, - description: ps.description, - userId: user.id, - isSensitive: ps.isSensitive, - fileIds: files.map(file => file.id), - })).then(x => GalleryPosts.findOneByOrFail(x.identifiers[0])); - - return await GalleryPosts.pack(post, user); -}); +} diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts b/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts index b00ee0e2a..ad5f95c85 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts @@ -1,6 +1,8 @@ -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GalleryPostsRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import { GalleryPosts } from '@/models/index.js'; export const meta = { tags: ['gallery'], @@ -27,15 +29,23 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const post = await GalleryPosts.findOneBy({ - id: ps.postId, - userId: user.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.galleryPostsRepository) + private galleryPostsRepository: GalleryPostsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const post = await this.galleryPostsRepository.findOneBy({ + id: ps.postId, + userId: me.id, + }); - if (post == null) { - throw new ApiError(meta.errors.noSuchPost); + if (post == null) { + throw new ApiError(meta.errors.noSuchPost); + } + + await this.galleryPostsRepository.delete(post.id); + }); } - - await GalleryPosts.delete(post.id); -}); +} diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/like.ts b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts index b858114ae..8aca98119 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/like.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts @@ -1,7 +1,9 @@ -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GalleryLikesRepository, GalleryPostsRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import { GalleryPosts, GalleryLikes } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; export const meta = { tags: ['gallery'], @@ -40,33 +42,46 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const post = await GalleryPosts.findOneBy({ id: ps.postId }); - if (post == null) { - throw new ApiError(meta.errors.noSuchPost); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.galleryPostsRepository) + private galleryPostsRepository: GalleryPostsRepository, + + @Inject(DI.galleryLikesRepository) + private galleryLikesRepository: GalleryLikesRepository, + + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const post = await this.galleryPostsRepository.findOneBy({ id: ps.postId }); + if (post == null) { + throw new ApiError(meta.errors.noSuchPost); + } + + if (post.userId === me.id) { + throw new ApiError(meta.errors.yourPost); + } + + // if already liked + const exist = await this.galleryLikesRepository.findOneBy({ + postId: post.id, + userId: me.id, + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyLiked); + } + + // Create like + await this.galleryLikesRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + postId: post.id, + userId: me.id, + }); + + this.galleryPostsRepository.increment({ id: post.id }, 'likedCount', 1); + }); } - - if (post.userId === user.id) { - throw new ApiError(meta.errors.yourPost); - } - - // if already liked - const exist = await GalleryLikes.findOneBy({ - postId: post.id, - userId: user.id, - }); - - if (exist != null) { - throw new ApiError(meta.errors.alreadyLiked); - } - - // Create like - await GalleryLikes.insert({ - id: genId(), - createdAt: new Date(), - postId: post.id, - userId: user.id, - }); - - GalleryPosts.increment({ id: post.id }, 'likedCount', 1); -}); +} diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/show.ts b/packages/backend/src/server/api/endpoints/gallery/posts/show.ts index 4f6dafd7c..723906d60 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/show.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/show.ts @@ -1,6 +1,9 @@ -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GalleryPostsRepository } from '@/models/index.js'; +import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import { GalleryPosts } from '@/models/index.js'; export const meta = { tags: ['gallery'], @@ -31,14 +34,24 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const post = await GalleryPosts.findOneBy({ - id: ps.postId, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.galleryPostsRepository) + private galleryPostsRepository: GalleryPostsRepository, - if (post == null) { - throw new ApiError(meta.errors.noSuchPost); + private galleryPostEntityService: GalleryPostEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const post = await this.galleryPostsRepository.findOneBy({ + id: ps.postId, + }); + + if (post == null) { + throw new ApiError(meta.errors.noSuchPost); + } + + return await this.galleryPostEntityService.pack(post, me); + }); } - - return await GalleryPosts.pack(post, me); -}); +} diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts b/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts index d136239e5..d87858299 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts @@ -1,6 +1,8 @@ -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GalleryPostsRepository, GalleryLikesRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import { GalleryPosts, GalleryLikes } from '@/models/index.js'; export const meta = { tags: ['gallery'], @@ -33,23 +35,34 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const post = await GalleryPosts.findOneBy({ id: ps.postId }); - if (post == null) { - throw new ApiError(meta.errors.noSuchPost); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.galleryPostsRepository) + private galleryPostsRepository: GalleryPostsRepository, + + @Inject(DI.galleryLikesRepository) + private galleryLikesRepository: GalleryLikesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const post = await this.galleryPostsRepository.findOneBy({ id: ps.postId }); + if (post == null) { + throw new ApiError(meta.errors.noSuchPost); + } + + const exist = await this.galleryLikesRepository.findOneBy({ + postId: post.id, + userId: me.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notLiked); + } + + // Delete like + await this.galleryLikesRepository.delete(exist.id); + + this.galleryPostsRepository.decrement({ id: post.id }, 'likedCount', 1); + }); } - - const exist = await GalleryLikes.findOneBy({ - postId: post.id, - userId: user.id, - }); - - if (exist == null) { - throw new ApiError(meta.errors.notLiked); - } - - // Delete like - await GalleryLikes.delete(exist.id); - - GalleryPosts.decrement({ id: post.id }, 'likedCount', 1); -}); +} diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/update.ts b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts index 82fe38078..1900afaeb 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/update.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts @@ -1,9 +1,12 @@ import ms from 'ms'; -import define from '../../../define.js'; -import { DriveFiles, GalleryPosts } from '@/models/index.js'; -import { GalleryPost } from '@/models/entities/gallery-post.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFilesRepository, GalleryPostsRepository } from '@/models/index.js'; +import { GalleryPost } from '@/models/entities/GalleryPost.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import { DriveFile } from '@/models/entities/drive-file.js'; export const meta = { tags: ['gallery'], @@ -43,30 +46,43 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const files = (await Promise.all(ps.fileIds.map(fileId => - DriveFiles.findOneBy({ - id: fileId, - userId: user.id, - }) - ))).filter((file): file is DriveFile => file != null); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.galleryPostsRepository) + private galleryPostsRepository: GalleryPostsRepository, - if (files.length === 0) { - throw new Error(); + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private galleryPostEntityService: GalleryPostEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const files = (await Promise.all(ps.fileIds.map(fileId => + this.driveFilesRepository.findOneBy({ + id: fileId, + userId: me.id, + }), + ))).filter((file): file is DriveFile => file != null); + + if (files.length === 0) { + throw new Error(); + } + + await this.galleryPostsRepository.update({ + id: ps.postId, + userId: me.id, + }, { + updatedAt: new Date(), + title: ps.title, + description: ps.description, + isSensitive: ps.isSensitive, + fileIds: files.map(file => file.id), + }); + + const post = await this.galleryPostsRepository.findOneByOrFail({ id: ps.postId }); + + return await this.galleryPostEntityService.pack(post, me); + }); } - - await GalleryPosts.update({ - id: ps.postId, - userId: user.id, - }, { - updatedAt: new Date(), - title: ps.title, - description: ps.description, - isSensitive: ps.isSensitive, - fileIds: files.map(file => file.id), - }); - - const post = await GalleryPosts.findOneByOrFail({ id: ps.postId }); - - return await GalleryPosts.pack(post, user); -}); +} diff --git a/packages/backend/src/server/api/endpoints/get-online-users-count.ts b/packages/backend/src/server/api/endpoints/get-online-users-count.ts index 56c550297..2d9bf29dd 100644 --- a/packages/backend/src/server/api/endpoints/get-online-users-count.ts +++ b/packages/backend/src/server/api/endpoints/get-online-users-count.ts @@ -1,7 +1,9 @@ import { MoreThan } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; import { USER_ONLINE_THRESHOLD } from '@/const.js'; -import { Users } from '@/models/index.js'; -import define from '../define.js'; +import { UsersRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['meta'], @@ -16,12 +18,20 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async () => { - const count = await Users.countBy({ - lastActiveDate: MoreThan(new Date(Date.now() - USER_ONLINE_THRESHOLD)), - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + ) { + super(meta, paramDef, async () => { + const count = await this.usersRepository.countBy({ + lastActiveDate: MoreThan(new Date(Date.now() - USER_ONLINE_THRESHOLD)), + }); - return { - count, - }; -}); + return { + count, + }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/hashtags/list.ts b/packages/backend/src/server/api/endpoints/hashtags/list.ts index 50e36386c..a7e7e6ba3 100644 --- a/packages/backend/src/server/api/endpoints/hashtags/list.ts +++ b/packages/backend/src/server/api/endpoints/hashtags/list.ts @@ -1,5 +1,8 @@ -import define from '../../define.js'; -import { Hashtags } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { HashtagsRepository } from '@/models/index.js'; +import { HashtagEntityService } from '@/core/entities/HashtagEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['hashtags'], @@ -30,39 +33,49 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const query = Hashtags.createQueryBuilder('tag'); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.hashtagsRepository) + private hashtagsRepository: HashtagsRepository, - if (ps.attachedToUserOnly) query.andWhere('tag.attachedUsersCount != 0'); - if (ps.attachedToLocalUserOnly) query.andWhere('tag.attachedLocalUsersCount != 0'); - if (ps.attachedToRemoteUserOnly) query.andWhere('tag.attachedRemoteUsersCount != 0'); + private hashtagEntityService: HashtagEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.hashtagsRepository.createQueryBuilder('tag'); - switch (ps.sort) { - case '+mentionedUsers': query.orderBy('tag.mentionedUsersCount', 'DESC'); break; - case '-mentionedUsers': query.orderBy('tag.mentionedUsersCount', 'ASC'); break; - case '+mentionedLocalUsers': query.orderBy('tag.mentionedLocalUsersCount', 'DESC'); break; - case '-mentionedLocalUsers': query.orderBy('tag.mentionedLocalUsersCount', 'ASC'); break; - case '+mentionedRemoteUsers': query.orderBy('tag.mentionedRemoteUsersCount', 'DESC'); break; - case '-mentionedRemoteUsers': query.orderBy('tag.mentionedRemoteUsersCount', 'ASC'); break; - case '+attachedUsers': query.orderBy('tag.attachedUsersCount', 'DESC'); break; - case '-attachedUsers': query.orderBy('tag.attachedUsersCount', 'ASC'); break; - case '+attachedLocalUsers': query.orderBy('tag.attachedLocalUsersCount', 'DESC'); break; - case '-attachedLocalUsers': query.orderBy('tag.attachedLocalUsersCount', 'ASC'); break; - case '+attachedRemoteUsers': query.orderBy('tag.attachedRemoteUsersCount', 'DESC'); break; - case '-attachedRemoteUsers': query.orderBy('tag.attachedRemoteUsersCount', 'ASC'); break; + if (ps.attachedToUserOnly) query.andWhere('tag.attachedUsersCount != 0'); + if (ps.attachedToLocalUserOnly) query.andWhere('tag.attachedLocalUsersCount != 0'); + if (ps.attachedToRemoteUserOnly) query.andWhere('tag.attachedRemoteUsersCount != 0'); + + switch (ps.sort) { + case '+mentionedUsers': query.orderBy('tag.mentionedUsersCount', 'DESC'); break; + case '-mentionedUsers': query.orderBy('tag.mentionedUsersCount', 'ASC'); break; + case '+mentionedLocalUsers': query.orderBy('tag.mentionedLocalUsersCount', 'DESC'); break; + case '-mentionedLocalUsers': query.orderBy('tag.mentionedLocalUsersCount', 'ASC'); break; + case '+mentionedRemoteUsers': query.orderBy('tag.mentionedRemoteUsersCount', 'DESC'); break; + case '-mentionedRemoteUsers': query.orderBy('tag.mentionedRemoteUsersCount', 'ASC'); break; + case '+attachedUsers': query.orderBy('tag.attachedUsersCount', 'DESC'); break; + case '-attachedUsers': query.orderBy('tag.attachedUsersCount', 'ASC'); break; + case '+attachedLocalUsers': query.orderBy('tag.attachedLocalUsersCount', 'DESC'); break; + case '-attachedLocalUsers': query.orderBy('tag.attachedLocalUsersCount', 'ASC'); break; + case '+attachedRemoteUsers': query.orderBy('tag.attachedRemoteUsersCount', 'DESC'); break; + case '-attachedRemoteUsers': query.orderBy('tag.attachedRemoteUsersCount', 'ASC'); break; + } + + query.select([ + 'tag.name', + 'tag.mentionedUsersCount', + 'tag.mentionedLocalUsersCount', + 'tag.mentionedRemoteUsersCount', + 'tag.attachedUsersCount', + 'tag.attachedLocalUsersCount', + 'tag.attachedRemoteUsersCount', + ]); + + const tags = await query.take(ps.limit).getMany(); + + return this.hashtagEntityService.packMany(tags); + }); } - - query.select([ - 'tag.name', - 'tag.mentionedUsersCount', - 'tag.mentionedLocalUsersCount', - 'tag.mentionedRemoteUsersCount', - 'tag.attachedUsersCount', - 'tag.attachedLocalUsersCount', - 'tag.attachedRemoteUsersCount', - ]); - - const tags = await query.take(ps.limit).getMany(); - - return Hashtags.packMany(tags); -}); +} diff --git a/packages/backend/src/server/api/endpoints/hashtags/search.ts b/packages/backend/src/server/api/endpoints/hashtags/search.ts index c28984477..3fb77bef9 100644 --- a/packages/backend/src/server/api/endpoints/hashtags/search.ts +++ b/packages/backend/src/server/api/endpoints/hashtags/search.ts @@ -1,5 +1,7 @@ -import define from '../../define.js'; -import { Hashtags } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { HashtagsRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['hashtags'], @@ -27,14 +29,22 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - const hashtags = await Hashtags.createQueryBuilder('tag') - .where('tag.name like :q', { q: ps.query.toLowerCase() + '%' }) - .orderBy('tag.count', 'DESC') - .groupBy('tag.id') - .take(ps.limit) - .skip(ps.offset) - .getMany(); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.hashtagsRepository) + private hashtagsRepository: HashtagsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const hashtags = await this.hashtagsRepository.createQueryBuilder('tag') + .where('tag.name like :q', { q: ps.query.toLowerCase() + '%' }) + .orderBy('tag.count', 'DESC') + .groupBy('tag.id') + .take(ps.limit) + .skip(ps.offset) + .getMany(); - return hashtags.map(tag => tag.name); -}); + return hashtags.map(tag => tag.name); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/hashtags/show.ts b/packages/backend/src/server/api/endpoints/hashtags/show.ts index 5b78f6ac7..59170f6d0 100644 --- a/packages/backend/src/server/api/endpoints/hashtags/show.ts +++ b/packages/backend/src/server/api/endpoints/hashtags/show.ts @@ -1,7 +1,10 @@ -import define from '../../define.js'; -import { ApiError } from '../../error.js'; -import { Hashtags } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { HashtagsRepository } from '@/models/index.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; +import { HashtagEntityService } from '@/core/entities/HashtagEntityService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['hashtags'], @@ -32,11 +35,21 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const hashtag = await Hashtags.findOneBy({ name: normalizeForSearch(ps.tag) }); - if (hashtag == null) { - throw new ApiError(meta.errors.noSuchHashtag); - } +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.hashtagsRepository) + private hashtagsRepository: HashtagsRepository, - return await Hashtags.pack(hashtag); -}); + private hashtagEntityService: HashtagEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const hashtag = await this.hashtagsRepository.findOneBy({ name: normalizeForSearch(ps.tag) }); + if (hashtag == null) { + throw new ApiError(meta.errors.noSuchHashtag); + } + + return await this.hashtagEntityService.pack(hashtag); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/hashtags/trend.ts b/packages/backend/src/server/api/endpoints/hashtags/trend.ts index 9cdbc8941..7e483ea21 100644 --- a/packages/backend/src/server/api/endpoints/hashtags/trend.ts +++ b/packages/backend/src/server/api/endpoints/hashtags/trend.ts @@ -1,10 +1,12 @@ import { Brackets } from 'typeorm'; -import define from '../../define.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { Notes } from '@/models/index.js'; -import { Note } from '@/models/entities/note.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { NotesRepository } from '@/models/index.js'; +import type { Note } from '@/models/entities/Note.js'; import { safeForSql } from '@/misc/safe-for-sql.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; +import { MetaService } from '@/core/MetaService.js'; +import { DI } from '@/di-symbols.js'; /* トレンドに載るためには「『直近a分間のユニーク投稿数が今からa分前~今からb分前の間のユニーク投稿数のn倍以上』のハッシュタグの上位5位以内に入る」ことが必要 @@ -60,94 +62,104 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async () => { - const instance = await fetchMeta(true); - const hiddenTags = instance.hiddenTags.map(t => normalizeForSearch(t)); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, - const now = new Date(); // 5分単位で丸めた現在日時 - now.setMinutes(Math.round(now.getMinutes() / 5) * 5, 0, 0); + private metaService: MetaService, + ) { + super(meta, paramDef, async () => { + const instance = await this.metaService.fetch(true); + const hiddenTags = instance.hiddenTags.map(t => normalizeForSearch(t)); - const tagNotes = await Notes.createQueryBuilder('note') - .where(`note.createdAt > :date`, { date: new Date(now.getTime() - rangeA) }) - .andWhere(new Brackets(qb => { qb - .where(`note.visibility = 'public'`) - .orWhere(`note.visibility = 'home'`); - })) - .andWhere(`note.tags != '{}'`) - .select(['note.tags', 'note.userId']) - .cache(60000) // 1 min - .getMany(); + const now = new Date(); // 5分単位で丸めた現在日時 + now.setMinutes(Math.round(now.getMinutes() / 5) * 5, 0, 0); - if (tagNotes.length === 0) { - return []; - } + const tagNotes = await this.notesRepository.createQueryBuilder('note') + .where('note.createdAt > :date', { date: new Date(now.getTime() - rangeA) }) + .andWhere(new Brackets(qb => { qb + .where('note.visibility = \'public\'') + .orWhere('note.visibility = \'home\''); + })) + .andWhere('note.tags != \'{}\'') + .select(['note.tags', 'note.userId']) + .cache(60000) // 1 min + .getMany(); - const tags: { + if (tagNotes.length === 0) { + return []; + } + + const tags: { name: string; users: Note['userId'][]; }[] = []; - for (const note of tagNotes) { - for (const tag of note.tags) { - if (hiddenTags.includes(tag)) continue; + for (const note of tagNotes) { + for (const tag of note.tags) { + if (hiddenTags.includes(tag)) continue; - const x = tags.find(x => x.name === tag); - if (x) { - if (!x.users.includes(note.userId)) { - x.users.push(note.userId); + const x = tags.find(x => x.name === tag); + if (x) { + if (!x.users.includes(note.userId)) { + x.users.push(note.userId); + } + } else { + tags.push({ + name: tag, + users: [note.userId], + }); + } } - } else { - tags.push({ - name: tag, - users: [note.userId], - }); } - } + + // タグを人気順に並べ替え + const hots = tags + .sort((a, b) => b.users.length - a.users.length) + .map(tag => tag.name) + .slice(0, max); + + //#region 2(または3)で話題と判定されたタグそれぞれについて過去の投稿数グラフを取得する + const countPromises: Promise[] = []; + + const range = 20; + + // 10分 + const interval = 1000 * 60 * 10; + + for (let i = 0; i < range; i++) { + countPromises.push(Promise.all(hots.map(tag => this.notesRepository.createQueryBuilder('note') + .select('count(distinct note.userId)') + .where(`'{"${safeForSql(tag) ? tag : 'aichan_kawaii'}"}' <@ note.tags`) + .andWhere('note.createdAt < :lt', { lt: new Date(now.getTime() - (interval * i)) }) + .andWhere('note.createdAt > :gt', { gt: new Date(now.getTime() - (interval * (i + 1))) }) + .cache(60000) // 1 min + .getRawOne() + .then(x => parseInt(x.count, 10)), + ))); + } + + const countsLog = await Promise.all(countPromises); + //#endregion + + const totalCounts = await Promise.all(hots.map(tag => this.notesRepository.createQueryBuilder('note') + .select('count(distinct note.userId)') + .where(`'{"${safeForSql(tag) ? tag : 'aichan_kawaii'}"}' <@ note.tags`) + .andWhere('note.createdAt > :gt', { gt: new Date(now.getTime() - rangeA) }) + .cache(60000 * 60) // 60 min + .getRawOne() + .then(x => parseInt(x.count, 10)), + )); + + const stats = hots.map((tag, i) => ({ + tag, + chart: countsLog.map(counts => counts[i]), + usersCount: totalCounts[i], + })); + + return stats; + }); } - - // タグを人気順に並べ替え - const hots = tags - .sort((a, b) => b.users.length - a.users.length) - .map(tag => tag.name) - .slice(0, max); - - //#region 2(または3)で話題と判定されたタグそれぞれについて過去の投稿数グラフを取得する - const countPromises: Promise[] = []; - - const range = 20; - - // 10分 - const interval = 1000 * 60 * 10; - - for (let i = 0; i < range; i++) { - countPromises.push(Promise.all(hots.map(tag => Notes.createQueryBuilder('note') - .select('count(distinct note.userId)') - .where(`'{"${safeForSql(tag) ? tag : 'aichan_kawaii'}"}' <@ note.tags`) - .andWhere('note.createdAt < :lt', { lt: new Date(now.getTime() - (interval * i)) }) - .andWhere('note.createdAt > :gt', { gt: new Date(now.getTime() - (interval * (i + 1))) }) - .cache(60000) // 1 min - .getRawOne() - .then(x => parseInt(x.count, 10)) - ))); - } - - const countsLog = await Promise.all(countPromises); - //#endregion - - const totalCounts = await Promise.all(hots.map(tag => Notes.createQueryBuilder('note') - .select('count(distinct note.userId)') - .where(`'{"${safeForSql(tag) ? tag : 'aichan_kawaii'}"}' <@ note.tags`) - .andWhere('note.createdAt > :gt', { gt: new Date(now.getTime() - rangeA) }) - .cache(60000 * 60) // 60 min - .getRawOne() - .then(x => parseInt(x.count, 10)) - )); - - const stats = hots.map((tag, i) => ({ - tag, - chart: countsLog.map(counts => counts[i]), - usersCount: totalCounts[i], - })); - - return stats; -}); +} diff --git a/packages/backend/src/server/api/endpoints/hashtags/users.ts b/packages/backend/src/server/api/endpoints/hashtags/users.ts index a5df21a7e..10a88fbef 100644 --- a/packages/backend/src/server/api/endpoints/hashtags/users.ts +++ b/packages/backend/src/server/api/endpoints/hashtags/users.ts @@ -1,6 +1,9 @@ -import define from '../../define.js'; -import { Users } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UsersRepository } from '@/models/index.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { requireCredential: false, @@ -24,39 +27,49 @@ export const paramDef = { tag: { type: 'string' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] }, - state: { type: 'string', enum: ['all', 'alive'], default: "all" }, - origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: "local" }, + state: { type: 'string', enum: ['all', 'alive'], default: 'all' }, + origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' }, }, required: ['tag', 'sort'], } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const query = Users.createQueryBuilder('user') - .where(':tag = ANY(user.tags)', { tag: normalizeForSearch(ps.tag) }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private userEntityService: UserEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.usersRepository.createQueryBuilder('user') + .where(':tag = ANY(user.tags)', { tag: normalizeForSearch(ps.tag) }); - const recent = new Date(Date.now() - (1000 * 60 * 60 * 24 * 5)); + const recent = new Date(Date.now() - (1000 * 60 * 60 * 24 * 5)); - if (ps.state === 'alive') { - query.andWhere('user.updatedAt > :date', { date: recent }); + if (ps.state === 'alive') { + query.andWhere('user.updatedAt > :date', { date: recent }); + } + + if (ps.origin === 'local') { + query.andWhere('user.host IS NULL'); + } else if (ps.origin === 'remote') { + query.andWhere('user.host IS NOT NULL'); + } + + switch (ps.sort) { + case '+follower': query.orderBy('user.followersCount', 'DESC'); break; + case '-follower': query.orderBy('user.followersCount', 'ASC'); break; + case '+createdAt': query.orderBy('user.createdAt', 'DESC'); break; + case '-createdAt': query.orderBy('user.createdAt', 'ASC'); break; + case '+updatedAt': query.orderBy('user.updatedAt', 'DESC'); break; + case '-updatedAt': query.orderBy('user.updatedAt', 'ASC'); break; + } + + const users = await query.take(ps.limit).getMany(); + + return await this.userEntityService.packMany(users, me, { detail: true }); + }); } - - if (ps.origin === 'local') { - query.andWhere('user.host IS NULL'); - } else if (ps.origin === 'remote') { - query.andWhere('user.host IS NOT NULL'); - } - - switch (ps.sort) { - case '+follower': query.orderBy('user.followersCount', 'DESC'); break; - case '-follower': query.orderBy('user.followersCount', 'ASC'); break; - case '+createdAt': query.orderBy('user.createdAt', 'DESC'); break; - case '-createdAt': query.orderBy('user.createdAt', 'ASC'); break; - case '+updatedAt': query.orderBy('user.updatedAt', 'DESC'); break; - case '-updatedAt': query.orderBy('user.updatedAt', 'ASC'); break; - } - - const users = await query.take(ps.limit).getMany(); - - return await Users.packMany(users, me, { detail: true }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/i.ts b/packages/backend/src/server/api/endpoints/i.ts index 22aedfeee..815b3168b 100644 --- a/packages/backend/src/server/api/endpoints/i.ts +++ b/packages/backend/src/server/api/endpoints/i.ts @@ -1,5 +1,8 @@ -import { Users } from '@/models/index.js'; -import define from '../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UsersRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['account'], @@ -20,12 +23,22 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user, token) => { - const isSecure = token == null; +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - // ここで渡ってきている user はキャッシュされていて古い可能性もあるので id だけ渡す - return await Users.pack(user.id, user, { - detail: true, - includeSecrets: isSecure, - }); -}); + private userEntityService: UserEntityService, + ) { + super(meta, paramDef, async (ps, user, token) => { + const isSecure = token == null; + + // ここで渡ってきている user はキャッシュされていて古い可能性もあるので id だけ渡す + return await this.userEntityService.pack(user.id, user, { + detail: true, + includeSecrets: isSecure, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/2fa/done.ts b/packages/backend/src/server/api/endpoints/i/2fa/done.ts index 35806b2bc..bcf3931b0 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/done.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/done.ts @@ -1,6 +1,8 @@ import * as speakeasy from 'speakeasy'; -import define from '../../../define.js'; -import { UserProfiles } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserProfilesRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; export const meta = { requireCredential: true, @@ -17,27 +19,35 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const token = ps.token.replace(/\s/g, ''); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const token = ps.token.replace(/\s/g, ''); - const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); - if (profile.twoFactorTempSecret == null) { - throw new Error('二段階認証の設定が開始されていません'); + if (profile.twoFactorTempSecret == null) { + throw new Error('二段階認証の設定が開始されていません'); + } + + const verified = (speakeasy as any).totp.verify({ + secret: profile.twoFactorTempSecret, + encoding: 'base32', + token: token, + }); + + if (!verified) { + throw new Error('not verified'); + } + + await this.userProfilesRepository.update(me.id, { + twoFactorSecret: profile.twoFactorTempSecret, + twoFactorEnabled: true, + }); + }); } - - const verified = (speakeasy as any).totp.verify({ - secret: profile.twoFactorTempSecret, - encoding: 'base32', - token: token, - }); - - if (!verified) { - throw new Error('not verified'); - } - - await UserProfiles.update(user.id, { - twoFactorSecret: profile.twoFactorTempSecret, - twoFactorEnabled: true, - }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts index 1afb34bfd..f2f4c2044 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts @@ -1,19 +1,16 @@ -import bcrypt from 'bcryptjs'; import { promisify } from 'node:util'; +import bcrypt from 'bcryptjs'; import * as cbor from 'cbor'; -import define from '../../../define.js'; -import { - UserProfiles, - UserSecurityKeys, - AttestationChallenges, - Users, -} from '@/models/index.js'; -import config from '@/config/index.js'; -import { procedures, hash } from '../../../2fa.js'; -import { publishMainStream } from '@/services/stream.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { TwoFactorAuthenticationService } from '@/core/TwoFactorAuthenticationService.js'; +import { AttestationChallengesRepository, UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js'; const cborDecodeFirst = promisify(cbor.decodeFirst) as any; -const rpIdHashReal = hash(Buffer.from(config.hostname, 'utf-8')); export const meta = { requireCredential: true, @@ -34,110 +31,135 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.config) + private config: Config, - // Compare password - const same = await bcrypt.compare(ps.password, profile.password!); + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, - if (!same) { - throw new Error('incorrect password'); - } + @Inject(DI.userSecurityKeysRepository) + private userSecurityKeysRepository: UserSecurityKeysRepository, - if (!profile.twoFactorEnabled) { - throw new Error('2fa not enabled'); - } + @Inject(DI.attestationChallengesRepository) + private attestationChallengesRepository: AttestationChallengesRepository, - const clientData = JSON.parse(ps.clientDataJSON); - - if (clientData.type !== 'webauthn.create') { - throw new Error('not a creation attestation'); - } - if (clientData.origin !== config.scheme + '://' + config.host) { - throw new Error('origin mismatch'); - } - - const clientDataJSONHash = hash(Buffer.from(ps.clientDataJSON, 'utf-8')); - - const attestation = await cborDecodeFirst(ps.attestationObject); - - const rpIdHash = attestation.authData.slice(0, 32); - if (!rpIdHashReal.equals(rpIdHash)) { - throw new Error('rpIdHash mismatch'); - } - - const flags = attestation.authData[32]; - - // eslint:disable-next-line:no-bitwise - if (!(flags & 1)) { - throw new Error('user not present'); - } - - const authData = Buffer.from(attestation.authData); - const credentialIdLength = authData.readUInt16BE(53); - const credentialId = authData.slice(55, 55 + credentialIdLength); - const publicKeyData = authData.slice(55 + credentialIdLength); - const publicKey: Map = await cborDecodeFirst(publicKeyData); - if (publicKey.get(3) !== -7) { - throw new Error('alg mismatch'); - } - - if (!(procedures as any)[attestation.fmt]) { - throw new Error('unsupported fmt'); - } - - const verificationData = (procedures as any)[attestation.fmt].verify({ - attStmt: attestation.attStmt, - authenticatorData: authData, - clientDataHash: clientDataJSONHash, - credentialId, - publicKey, - rpIdHash, - }); - if (!verificationData.valid) throw new Error('signature invalid'); - - const attestationChallenge = await AttestationChallenges.findOneBy({ - userId: user.id, - id: ps.challengeId, - registrationChallenge: true, - challenge: hash(clientData.challenge).toString('hex'), - }); - - if (!attestationChallenge) { - throw new Error('non-existent challenge'); - } - - await AttestationChallenges.delete({ - userId: user.id, - id: ps.challengeId, - }); - - // Expired challenge (> 5min old) - if ( - new Date().getTime() - attestationChallenge.createdAt.getTime() >= - 5 * 60 * 1000 + private userEntityService: UserEntityService, + private globalEventService: GlobalEventService, + private twoFactorAuthenticationService: TwoFactorAuthenticationService, ) { - throw new Error('expired challenge'); + super(meta, paramDef, async (ps, me) => { + const rpIdHashReal = this.twoFactorAuthenticationService.hash(Buffer.from(this.config.hostname, 'utf-8')); + + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + if (!profile.twoFactorEnabled) { + throw new Error('2fa not enabled'); + } + + const clientData = JSON.parse(ps.clientDataJSON); + + if (clientData.type !== 'webauthn.create') { + throw new Error('not a creation attestation'); + } + if (clientData.origin !== this.config.scheme + '://' + this.config.host) { + throw new Error('origin mismatch'); + } + + const clientDataJSONHash = this.twoFactorAuthenticationService.hash(Buffer.from(ps.clientDataJSON, 'utf-8')); + + const attestation = await cborDecodeFirst(ps.attestationObject); + + const rpIdHash = attestation.authData.slice(0, 32); + if (!rpIdHashReal.equals(rpIdHash)) { + throw new Error('rpIdHash mismatch'); + } + + const flags = attestation.authData[32]; + + // eslint:disable-next-line:no-bitwise + if (!(flags & 1)) { + throw new Error('user not present'); + } + + const authData = Buffer.from(attestation.authData); + const credentialIdLength = authData.readUInt16BE(53); + const credentialId = authData.slice(55, 55 + credentialIdLength); + const publicKeyData = authData.slice(55 + credentialIdLength); + const publicKey: Map = await cborDecodeFirst(publicKeyData); + if (publicKey.get(3) !== -7) { + throw new Error('alg mismatch'); + } + + const procedures = this.twoFactorAuthenticationService.getProcedures(); + + if (!(procedures as any)[attestation.fmt]) { + throw new Error('unsupported fmt'); + } + + const verificationData = (procedures as any)[attestation.fmt].verify({ + attStmt: attestation.attStmt, + authenticatorData: authData, + clientDataHash: clientDataJSONHash, + credentialId, + publicKey, + rpIdHash, + }); + if (!verificationData.valid) throw new Error('signature invalid'); + + const attestationChallenge = await this.attestationChallengesRepository.findOneBy({ + userId: me.id, + id: ps.challengeId, + registrationChallenge: true, + challenge: this.twoFactorAuthenticationService.hash(clientData.challenge).toString('hex'), + }); + + if (!attestationChallenge) { + throw new Error('non-existent challenge'); + } + + await this.attestationChallengesRepository.delete({ + userId: me.id, + id: ps.challengeId, + }); + + // Expired challenge (> 5min old) + if ( + new Date().getTime() - attestationChallenge.createdAt.getTime() >= + 5 * 60 * 1000 + ) { + throw new Error('expired challenge'); + } + + const credentialIdString = credentialId.toString('hex'); + + await this.userSecurityKeysRepository.insert({ + userId: me.id, + id: credentialIdString, + lastUsed: new Date(), + name: ps.name, + publicKey: verificationData.publicKey.toString('hex'), + }); + + // Publish meUpdated event + this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, { + detail: true, + includeSecrets: true, + })); + + return { + id: credentialIdString, + name: ps.name, + }; + }); } - - const credentialIdString = credentialId.toString('hex'); - - await UserSecurityKeys.insert({ - userId: user.id, - id: credentialIdString, - lastUsed: new Date(), - name: ps.name, - publicKey: verificationData.publicKey.toString('hex'), - }); - - // Publish meUpdated event - publishMainStream(user.id, 'meUpdated', await Users.pack(user.id, user, { - detail: true, - includeSecrets: true, - })); - - return { - id: credentialIdString, - name: ps.name, - }; -}); +} diff --git a/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts b/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts index 4bfa24f97..3eb9f43c2 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts @@ -1,5 +1,7 @@ -import define from '../../../define.js'; -import { UserProfiles } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserProfilesRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; export const meta = { requireCredential: true, @@ -16,8 +18,16 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - await UserProfiles.update(user.id, { - usePasswordLessLogin: ps.value, - }); -}); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + await this.userProfilesRepository.update(me.id, { + usePasswordLessLogin: ps.value, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts index e906b8204..df37db4c6 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts @@ -1,10 +1,12 @@ -import bcrypt from 'bcryptjs'; -import define from '../../../define.js'; -import { UserProfiles, AttestationChallenges } from '@/models/index.js'; import { promisify } from 'node:util'; import * as crypto from 'node:crypto'; -import { genId } from '@/misc/gen-id.js'; -import { hash } from '../../../2fa.js'; +import bcrypt from 'bcryptjs'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserProfilesRepository, AttestationChallengesRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { TwoFactorAuthenticationService } from '@/core/TwoFactorAuthenticationService.js'; +import { DI } from '@/di-symbols.js'; const randomBytes = promisify(crypto.randomBytes); @@ -23,39 +25,53 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, - // Compare password - const same = await bcrypt.compare(ps.password, profile.password!); + @Inject(DI.attestationChallengesRepository) + private attestationChallengesRepository: AttestationChallengesRepository, - if (!same) { - throw new Error('incorrect password'); + private idService: IdService, + private twoFactorAuthenticationService: TwoFactorAuthenticationService, + ) { + super(meta, paramDef, async (ps, me) => { + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + if (!profile.twoFactorEnabled) { + throw new Error('2fa not enabled'); + } + + // 32 byte challenge + const entropy = await randomBytes(32); + const challenge = entropy.toString('base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + + const challengeId = this.idService.genId(); + + await this.attestationChallengesRepository.insert({ + userId: me.id, + id: challengeId, + challenge: this.twoFactorAuthenticationService.hash(Buffer.from(challenge, 'utf-8')).toString('hex'), + createdAt: new Date(), + registrationChallenge: true, + }); + + return { + challengeId, + challenge, + }; + }); } - - if (!profile.twoFactorEnabled) { - throw new Error('2fa not enabled'); - } - - // 32 byte challenge - const entropy = await randomBytes(32); - const challenge = entropy.toString('base64') - .replace(/=/g, '') - .replace(/\+/g, '-') - .replace(/\//g, '_'); - - const challengeId = genId(); - - await AttestationChallenges.insert({ - userId: user.id, - id: challengeId, - challenge: hash(Buffer.from(challenge, 'utf-8')).toString('hex'), - createdAt: new Date(), - registrationChallenge: true, - }); - - return { - challengeId, - challenge, - }; -}); +} diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register.ts b/packages/backend/src/server/api/endpoints/i/2fa/register.ts index 33f571772..e20911f35 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/register.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/register.ts @@ -1,9 +1,11 @@ import bcrypt from 'bcryptjs'; import * as speakeasy from 'speakeasy'; import * as QRCode from 'qrcode'; -import config from '@/config/index.js'; -import { UserProfiles } from '@/models/index.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UserProfilesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; export const meta = { requireCredential: true, @@ -20,39 +22,50 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.config) + private config: Config, - // Compare password - const same = await bcrypt.compare(ps.password, profile.password!); + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); - if (!same) { - throw new Error('incorrect password'); + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + // Generate user's secret key + const secret = speakeasy.generateSecret({ + length: 32, + }); + + await this.userProfilesRepository.update(me.id, { + twoFactorTempSecret: secret.base32, + }); + + // Get the data URL of the authenticator URL + const url = speakeasy.otpauthURL({ + secret: secret.base32, + encoding: 'base32', + label: me.username, + issuer: this.config.host, + }); + const dataUrl = await QRCode.toDataURL(url); + + return { + qr: dataUrl, + url, + secret: secret.base32, + label: me.username, + issuer: this.config.host, + }; + }); } - - // Generate user's secret key - const secret = speakeasy.generateSecret({ - length: 32, - }); - - await UserProfiles.update(user.id, { - twoFactorTempSecret: secret.base32, - }); - - // Get the data URL of the authenticator URL - const url = speakeasy.otpauthURL({ - secret: secret.base32, - encoding: 'base32', - label: user.username, - issuer: config.host, - }); - const dataUrl = await QRCode.toDataURL(url); - - return { - qr: dataUrl, - url, - secret: secret.base32, - label: user.username, - issuer: config.host, - }; -}); +} diff --git a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts index eb2f75308..1889dd789 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts @@ -1,7 +1,11 @@ import bcrypt from 'bcryptjs'; -import define from '../../../define.js'; -import { UserProfiles, UserSecurityKeys, Users } from '@/models/index.js'; -import { publishMainStream } from '@/services/stream.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js'; +import type { UsersRepository } from '@/models/index.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { requireCredential: true, @@ -19,27 +23,41 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userSecurityKeysRepository) + private userSecurityKeysRepository: UserSecurityKeysRepository, - // Compare password - const same = await bcrypt.compare(ps.password, profile.password!); + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, - if (!same) { - throw new Error('incorrect password'); + private userEntityService: UserEntityService, + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + // Make sure we only delete the user's own creds + await this.userSecurityKeysRepository.delete({ + userId: me.id, + id: ps.credentialId, + }); + + // Publish meUpdated event + this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, { + detail: true, + includeSecrets: true, + })); + + return {}; + }); } - - // Make sure we only delete the user's own creds - await UserSecurityKeys.delete({ - userId: user.id, - id: ps.credentialId, - }); - - // Publish meUpdated event - publishMainStream(user.id, 'meUpdated', await Users.pack(user.id, user, { - detail: true, - includeSecrets: true, - })); - - return {}; -}); +} diff --git a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts index 45e7a9863..4607e5d98 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts @@ -1,6 +1,8 @@ import bcrypt from 'bcryptjs'; -import define from '../../../define.js'; -import { UserProfiles } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserProfilesRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; export const meta = { requireCredential: true, @@ -17,18 +19,26 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); - // Compare password - const same = await bcrypt.compare(ps.password, profile.password!); + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); - if (!same) { - throw new Error('incorrect password'); + if (!same) { + throw new Error('incorrect password'); + } + + await this.userProfilesRepository.update(me.id, { + twoFactorSecret: null, + twoFactorEnabled: false, + }); + }); } - - await UserProfiles.update(user.id, { - twoFactorSecret: null, - twoFactorEnabled: false, - }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/i/apps.ts b/packages/backend/src/server/api/endpoints/i/apps.ts index eca955884..8d5851659 100644 --- a/packages/backend/src/server/api/endpoints/i/apps.ts +++ b/packages/backend/src/server/api/endpoints/i/apps.ts @@ -1,5 +1,7 @@ -import define from '../../define.js'; -import { AccessTokens } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AccessTokensRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; export const meta = { requireCredential: true, @@ -16,25 +18,33 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const query = AccessTokens.createQueryBuilder('token') - .where('token.userId = :userId', { userId: user.id }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.accessTokensRepository) + private accessTokensRepository: AccessTokensRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.accessTokensRepository.createQueryBuilder('token') + .where('token.userId = :userId', { userId: me.id }); - switch (ps.sort) { - case '+createdAt': query.orderBy('token.createdAt', 'DESC'); break; - case '-createdAt': query.orderBy('token.createdAt', 'ASC'); break; - case '+lastUsedAt': query.orderBy('token.lastUsedAt', 'DESC'); break; - case '-lastUsedAt': query.orderBy('token.lastUsedAt', 'ASC'); break; - default: query.orderBy('token.id', 'ASC'); break; + switch (ps.sort) { + case '+createdAt': query.orderBy('token.createdAt', 'DESC'); break; + case '-createdAt': query.orderBy('token.createdAt', 'ASC'); break; + case '+lastUsedAt': query.orderBy('token.lastUsedAt', 'DESC'); break; + case '-lastUsedAt': query.orderBy('token.lastUsedAt', 'ASC'); break; + default: query.orderBy('token.id', 'ASC'); break; + } + + const tokens = await query.getMany(); + + return await Promise.all(tokens.map(token => ({ + id: token.id, + name: token.name, + createdAt: token.createdAt, + lastUsedAt: token.lastUsedAt, + permission: token.permission, + }))); + }); } - - const tokens = await query.getMany(); - - return await Promise.all(tokens.map(token => ({ - id: token.id, - name: token.name, - createdAt: token.createdAt, - lastUsedAt: token.lastUsedAt, - permission: token.permission, - }))); -}); +} diff --git a/packages/backend/src/server/api/endpoints/i/authorized-apps.ts b/packages/backend/src/server/api/endpoints/i/authorized-apps.ts index 68bd103a6..a5592d20d 100644 --- a/packages/backend/src/server/api/endpoints/i/authorized-apps.ts +++ b/packages/backend/src/server/api/endpoints/i/authorized-apps.ts @@ -1,5 +1,8 @@ -import define from '../../define.js'; -import { AccessTokens, Apps } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AccessTokensRepository } from '@/models/index.js'; +import { AppEntityService } from '@/core/entities/AppEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { requireCredential: true, @@ -12,26 +15,36 @@ export const paramDef = { properties: { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, offset: { type: 'integer', default: 0 }, - sort: { type: 'string', enum: ['desc', 'asc'], default: "desc" }, + sort: { type: 'string', enum: ['desc', 'asc'], default: 'desc' }, }, required: [], } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - // Get tokens - const tokens = await AccessTokens.find({ - where: { - userId: user.id, - }, - take: ps.limit, - skip: ps.offset, - order: { - id: ps.sort === 'asc' ? 1 : -1, - }, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.accessTokensRepository) + private accessTokensRepository: AccessTokensRepository, - return await Promise.all(tokens.map(token => Apps.pack(token.appId, user, { - detail: true, - }))); -}); + private appEntityService: AppEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + // Get tokens + const tokens = await this.accessTokensRepository.find({ + where: { + userId: me.id, + }, + take: ps.limit, + skip: ps.offset, + order: { + id: ps.sort === 'asc' ? 1 : -1, + }, + }); + + return await Promise.all(tokens.map(token => this.appEntityService.pack(token.appId, me, { + detail: true, + }))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/change-password.ts b/packages/backend/src/server/api/endpoints/i/change-password.ts index f9f6a33a8..cc5b712ec 100644 --- a/packages/backend/src/server/api/endpoints/i/change-password.ts +++ b/packages/backend/src/server/api/endpoints/i/change-password.ts @@ -1,6 +1,8 @@ import bcrypt from 'bcryptjs'; -import define from '../../define.js'; -import { UserProfiles } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserProfilesRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; export const meta = { requireCredential: true, @@ -18,21 +20,29 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); - // Compare password - const same = await bcrypt.compare(ps.currentPassword, profile.password!); + // Compare password + const same = await bcrypt.compare(ps.currentPassword, profile.password!); - if (!same) { - throw new Error('incorrect password'); + if (!same) { + throw new Error('incorrect password'); + } + + // Generate hash of password + const salt = await bcrypt.genSalt(8); + const hash = await bcrypt.hash(ps.newPassword, salt); + + await this.userProfilesRepository.update(me.id, { + password: hash, + }); + }); } - - // Generate hash of password - const salt = await bcrypt.genSalt(8); - const hash = await bcrypt.hash(ps.newPassword, salt); - - await UserProfiles.update(user.id, { - password: hash, - }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/i/delete-account.ts b/packages/backend/src/server/api/endpoints/i/delete-account.ts index ede4a9d03..a1804599d 100644 --- a/packages/backend/src/server/api/endpoints/i/delete-account.ts +++ b/packages/backend/src/server/api/endpoints/i/delete-account.ts @@ -1,7 +1,9 @@ import bcrypt from 'bcryptjs'; -import { UserProfiles, Users } from '@/models/index.js'; -import { deleteAccount } from '@/services/delete-account.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UsersRepository, UserProfilesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DeleteAccountService } from '@/core/DeleteAccountService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { requireCredential: true, @@ -18,19 +20,32 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - const userDetailed = await Users.findOneByOrFail({ id: user.id }); - if (userDetailed.isDeleted) { - return; +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + private deleteAccountService: DeleteAccountService, + ) { + super(meta, paramDef, async (ps, me) => { + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); + const userDetailed = await this.usersRepository.findOneByOrFail({ id: me.id }); + if (userDetailed.isDeleted) { + return; + } + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + await this.deleteAccountService.deleteAccount(me); + }); } - - // Compare password - const same = await bcrypt.compare(ps.password, profile.password!); - - if (!same) { - throw new Error('incorrect password'); - } - - await deleteAccount(user); -}); +} diff --git a/packages/backend/src/server/api/endpoints/i/export-blocking.ts b/packages/backend/src/server/api/endpoints/i/export-blocking.ts index aed4c2e0a..770708e68 100644 --- a/packages/backend/src/server/api/endpoints/i/export-blocking.ts +++ b/packages/backend/src/server/api/endpoints/i/export-blocking.ts @@ -1,6 +1,7 @@ -import define from '../../define.js'; -import { createExportBlockingJob } from '@/queue/index.js'; +import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueueService } from '@/core/QueueService.js'; export const meta = { secure: true, @@ -18,6 +19,13 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - createExportBlockingJob(user); -}); +@Injectable() +export default class extends Endpoint { + constructor( + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + this.queueService.createExportBlockingJob(me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/export-following.ts b/packages/backend/src/server/api/endpoints/i/export-following.ts index 058d77b3c..fcaa59b12 100644 --- a/packages/backend/src/server/api/endpoints/i/export-following.ts +++ b/packages/backend/src/server/api/endpoints/i/export-following.ts @@ -1,6 +1,7 @@ -import define from '../../define.js'; -import { createExportFollowingJob } from '@/queue/index.js'; +import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueueService } from '@/core/QueueService.js'; export const meta = { secure: true, @@ -21,6 +22,13 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - createExportFollowingJob(user, ps.excludeMuting, ps.excludeInactive); -}); +@Injectable() +export default class extends Endpoint { + constructor( + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + this.queueService.createExportFollowingJob(me, ps.excludeMuting, ps.excludeInactive); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/export-mute.ts b/packages/backend/src/server/api/endpoints/i/export-mute.ts index c0216fac0..37bef0a11 100644 --- a/packages/backend/src/server/api/endpoints/i/export-mute.ts +++ b/packages/backend/src/server/api/endpoints/i/export-mute.ts @@ -1,6 +1,7 @@ -import define from '../../define.js'; -import { createExportMuteJob } from '@/queue/index.js'; +import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueueService } from '@/core/QueueService.js'; export const meta = { secure: true, @@ -18,6 +19,13 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - createExportMuteJob(user); -}); +@Injectable() +export default class extends Endpoint { + constructor( + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + this.queueService.createExportMuteJob(me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/export-notes.ts b/packages/backend/src/server/api/endpoints/i/export-notes.ts index 4b85a4555..9d2505e40 100644 --- a/packages/backend/src/server/api/endpoints/i/export-notes.ts +++ b/packages/backend/src/server/api/endpoints/i/export-notes.ts @@ -1,6 +1,7 @@ -import define from '../../define.js'; -import { createExportNotesJob } from '@/queue/index.js'; +import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueueService } from '@/core/QueueService.js'; export const meta = { secure: true, @@ -18,6 +19,13 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - createExportNotesJob(user); -}); +@Injectable() +export default class extends Endpoint { + constructor( + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + this.queueService.createExportNotesJob(me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/export-user-lists.ts b/packages/backend/src/server/api/endpoints/i/export-user-lists.ts index fa5c1f5e5..0f8e4bca7 100644 --- a/packages/backend/src/server/api/endpoints/i/export-user-lists.ts +++ b/packages/backend/src/server/api/endpoints/i/export-user-lists.ts @@ -1,6 +1,7 @@ -import define from '../../define.js'; -import { createExportUserListsJob } from '@/queue/index.js'; +import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueueService } from '@/core/QueueService.js'; export const meta = { secure: true, @@ -18,6 +19,13 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - createExportUserListsJob(user); -}); +@Injectable() +export default class extends Endpoint { + constructor( + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + this.queueService.createExportUserListsJob(me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/favorites.ts b/packages/backend/src/server/api/endpoints/i/favorites.ts index 3c420e4d0..350abd9f7 100644 --- a/packages/backend/src/server/api/endpoints/i/favorites.ts +++ b/packages/backend/src/server/api/endpoints/i/favorites.ts @@ -1,6 +1,9 @@ -import define from '../../define.js'; -import { NoteFavorites } from '@/models/index.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { NoteFavoritesRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { NoteFavoriteEntityService } from '@/core/entities/NoteFavoriteEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['account', 'notes', 'favorites'], @@ -31,14 +34,25 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const query = makePaginationQuery(NoteFavorites.createQueryBuilder('favorite'), ps.sinceId, ps.untilId) - .andWhere(`favorite.userId = :meId`, { meId: user.id }) - .leftJoinAndSelect('favorite.note', 'note'); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.noteFavoritesRepository) + private noteFavoritesRepository: NoteFavoritesRepository, - const favorites = await query - .take(ps.limit) - .getMany(); + private noteFavoriteEntityService: NoteFavoriteEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.noteFavoritesRepository.createQueryBuilder('favorite'), ps.sinceId, ps.untilId) + .andWhere('favorite.userId = :meId', { meId: me.id }) + .leftJoinAndSelect('favorite.note', 'note'); - return await NoteFavorites.packMany(favorites, user); -}); + const favorites = await query + .take(ps.limit) + .getMany(); + + return await this.noteFavoriteEntityService.packMany(favorites, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/gallery/likes.ts b/packages/backend/src/server/api/endpoints/i/gallery/likes.ts index a38383f30..ff6bcc01a 100644 --- a/packages/backend/src/server/api/endpoints/i/gallery/likes.ts +++ b/packages/backend/src/server/api/endpoints/i/gallery/likes.ts @@ -1,6 +1,9 @@ -import define from '../../../define.js'; -import { GalleryLikes } from '@/models/index.js'; -import { makePaginationQuery } from '../../../common/make-pagination-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GalleryLikesRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { GalleryLikeEntityService } from '@/core/entities/GalleryLikeEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['account', 'gallery'], @@ -27,7 +30,7 @@ export const meta = { ref: 'GalleryPost', }, }, - } + }, }, } as const; @@ -42,14 +45,25 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const query = makePaginationQuery(GalleryLikes.createQueryBuilder('like'), ps.sinceId, ps.untilId) - .andWhere(`like.userId = :meId`, { meId: user.id }) - .leftJoinAndSelect('like.post', 'post'); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.galleryLikesRepository) + private galleryLikesRepository: GalleryLikesRepository, - const likes = await query - .take(ps.limit) - .getMany(); + private galleryLikeEntityService: GalleryLikeEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.galleryLikesRepository.createQueryBuilder('like'), ps.sinceId, ps.untilId) + .andWhere('like.userId = :meId', { meId: me.id }) + .leftJoinAndSelect('like.post', 'post'); - return await GalleryLikes.packMany(likes, user); -}); + const likes = await query + .take(ps.limit) + .getMany(); + + return await this.galleryLikeEntityService.packMany(likes, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/gallery/posts.ts b/packages/backend/src/server/api/endpoints/i/gallery/posts.ts index b4edb5f73..927be51f7 100644 --- a/packages/backend/src/server/api/endpoints/i/gallery/posts.ts +++ b/packages/backend/src/server/api/endpoints/i/gallery/posts.ts @@ -1,6 +1,9 @@ -import define from '../../../define.js'; -import { GalleryPosts } from '@/models/index.js'; -import { makePaginationQuery } from '../../../common/make-pagination-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GalleryPostsRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['account', 'gallery'], @@ -31,13 +34,24 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const query = makePaginationQuery(GalleryPosts.createQueryBuilder('post'), ps.sinceId, ps.untilId) - .andWhere(`post.userId = :meId`, { meId: user.id }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.galleryPostsRepository) + private galleryPostsRepository: GalleryPostsRepository, - const posts = await query - .take(ps.limit) - .getMany(); + private galleryPostEntityService: GalleryPostEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.galleryPostsRepository.createQueryBuilder('post'), ps.sinceId, ps.untilId) + .andWhere('post.userId = :meId', { meId: me.id }); - return await GalleryPosts.packMany(posts, user); -}); + const posts = await query + .take(ps.limit) + .getMany(); + + return await this.galleryPostEntityService.packMany(posts, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts b/packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts index e7d7518c5..0695abdd8 100644 --- a/packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts +++ b/packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts @@ -1,5 +1,7 @@ -import define from '../../define.js'; -import { MutedNotes } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { MutedNotesRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['account'], @@ -27,11 +29,19 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - return { - count: await MutedNotes.countBy({ - userId: user.id, - reason: 'word', - }), - }; -}); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.mutedNotesRepository) + private mutedNotesRepository: MutedNotesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + return { + count: await this.mutedNotesRepository.countBy({ + userId: me.id, + reason: 'word', + }), + }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/import-blocking.ts b/packages/backend/src/server/api/endpoints/i/import-blocking.ts index 0bcbf37dd..bfba1fc36 100644 --- a/packages/backend/src/server/api/endpoints/i/import-blocking.ts +++ b/packages/backend/src/server/api/endpoints/i/import-blocking.ts @@ -1,8 +1,10 @@ -import define from '../../define.js'; -import { createImportBlockingJob } from '@/queue/index.js'; +import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueueService } from '@/core/QueueService.js'; +import { DriveFilesRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { DriveFiles } from '@/models/index.js'; export const meta = { secure: true, @@ -49,13 +51,23 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const file = await DriveFiles.findOneBy({ id: ps.fileId }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, - if (file == null) throw new ApiError(meta.errors.noSuchFile); - //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); - if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); - if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); - createImportBlockingJob(user, file.id); -}); + if (file == null) throw new ApiError(meta.errors.noSuchFile); + //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); + if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + + this.queueService.createImportBlockingJob(me, file.id); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/import-following.ts b/packages/backend/src/server/api/endpoints/i/import-following.ts index ee2abbea1..c7cb2e033 100644 --- a/packages/backend/src/server/api/endpoints/i/import-following.ts +++ b/packages/backend/src/server/api/endpoints/i/import-following.ts @@ -1,8 +1,10 @@ -import define from '../../define.js'; -import { createImportFollowingJob } from '@/queue/index.js'; +import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueueService } from '@/core/QueueService.js'; +import { DriveFilesRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { DriveFiles } from '@/models/index.js'; export const meta = { secure: true, @@ -48,13 +50,23 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const file = await DriveFiles.findOneBy({ id: ps.fileId }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, - if (file == null) throw new ApiError(meta.errors.noSuchFile); - //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); - if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); - if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); - createImportFollowingJob(user, file.id); -}); + if (file == null) throw new ApiError(meta.errors.noSuchFile); + //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); + if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + + this.queueService.createImportFollowingJob(me, file.id); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/import-muting.ts b/packages/backend/src/server/api/endpoints/i/import-muting.ts index b3b3b3923..060c37c13 100644 --- a/packages/backend/src/server/api/endpoints/i/import-muting.ts +++ b/packages/backend/src/server/api/endpoints/i/import-muting.ts @@ -1,8 +1,10 @@ -import define from '../../define.js'; -import { createImportMutingJob } from '@/queue/index.js'; +import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueueService } from '@/core/QueueService.js'; +import { DriveFilesRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { DriveFiles } from '@/models/index.js'; export const meta = { secure: true, @@ -49,13 +51,23 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const file = await DriveFiles.findOneBy({ id: ps.fileId }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, - if (file == null) throw new ApiError(meta.errors.noSuchFile); - //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); - if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); - if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); - createImportMutingJob(user, file.id); -}); + if (file == null) throw new ApiError(meta.errors.noSuchFile); + //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); + if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + + this.queueService.createImportMutingJob(me, file.id); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts index 64f5ec05f..a5e17283e 100644 --- a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts +++ b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts @@ -1,8 +1,10 @@ -import define from '../../define.js'; -import { createImportUserListsJob } from '@/queue/index.js'; +import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueueService } from '@/core/QueueService.js'; +import { DriveFilesRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { DriveFiles } from '@/models/index.js'; export const meta = { secure: true, @@ -48,13 +50,23 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const file = await DriveFiles.findOneBy({ id: ps.fileId }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, - if (file == null) throw new ApiError(meta.errors.noSuchFile); - //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); - if (file.size > 30000) throw new ApiError(meta.errors.tooBigFile); - if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); - createImportUserListsJob(user, file.id); -}); + if (file == null) throw new ApiError(meta.errors.noSuchFile); + //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); + if (file.size > 30000) throw new ApiError(meta.errors.tooBigFile); + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + + this.queueService.createImportUserListsJob(me, file.id); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts index 2b343dabd..96927dad4 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -1,10 +1,13 @@ import { Brackets } from 'typeorm'; -import { Notifications, Followings, Mutings, Users, UserProfiles } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UsersRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, NotificationsRepository } from '@/models/index.js'; import { notificationTypes } from '@/types.js'; -import read from '@/services/note/read.js'; -import { readNotification } from '../../common/read-notification.js'; -import define from '../../define.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { NoteReadService } from '@/core/NoteReadService.js'; +import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; +import { NotificationService } from '@/core/NotificationService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['account', 'notifications'], @@ -49,96 +52,121 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - // includeTypes が空の場合はクエリしない - if (ps.includeTypes && ps.includeTypes.length === 0) { - return []; +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.notificationsRepository) + private notificationsRepository: NotificationsRepository, + + private notificationEntityService: NotificationEntityService, + private notificationService: NotificationService, + private queryService: QueryService, + private noteReadService: NoteReadService, + ) { + super(meta, paramDef, async (ps, me) => { + // includeTypes が空の場合はクエリしない + if (ps.includeTypes && ps.includeTypes.length === 0) { + return []; + } + // excludeTypes に全指定されている場合はクエリしない + if (notificationTypes.every(type => ps.excludeTypes?.includes(type))) { + return []; + } + const followingQuery = this.followingsRepository.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: me.id }); + + const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') + .select('muting.muteeId') + .where('muting.muterId = :muterId', { muterId: me.id }); + + const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile') + .select('user_profile.mutedInstances') + .where('user_profile.userId = :muterId', { muterId: me.id }); + + const suspendedQuery = this.usersRepository.createQueryBuilder('users') + .select('users.id') + .where('users.isSuspended = TRUE'); + + const query = this.queryService.makePaginationQuery(this.notificationsRepository.createQueryBuilder('notification'), ps.sinceId, ps.untilId) + .andWhere('notification.notifieeId = :meId', { meId: me.id }) + .leftJoinAndSelect('notification.notifier', 'notifier') + .leftJoinAndSelect('notification.note', 'note') + .leftJoinAndSelect('notifier.avatar', 'notifierAvatar') + .leftJoinAndSelect('notifier.banner', 'notifierBanner') + .leftJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + + // muted users + query.andWhere(new Brackets(qb => { qb + .where(`notification.notifierId NOT IN (${ mutingQuery.getQuery() })`) + .orWhere('notification.notifierId IS NULL'); + })); + query.setParameters(mutingQuery.getParameters()); + + // muted instances + query.andWhere(new Brackets(qb => { qb + .andWhere('notifier.host IS NULL') + .orWhere(`NOT (( ${mutingInstanceQuery.getQuery()} )::jsonb ? notifier.host)`); + })); + query.setParameters(mutingInstanceQuery.getParameters()); + + // suspended users + query.andWhere(new Brackets(qb => { qb + .where(`notification.notifierId NOT IN (${ suspendedQuery.getQuery() })`) + .orWhere('notification.notifierId IS NULL'); + })); + + if (ps.following) { + query.andWhere(`((notification.notifierId IN (${ followingQuery.getQuery() })) OR (notification.notifierId = :meId))`, { meId: me.id }); + query.setParameters(followingQuery.getParameters()); + } + + if (ps.includeTypes && ps.includeTypes.length > 0) { + query.andWhere('notification.type IN (:...includeTypes)', { includeTypes: ps.includeTypes }); + } else if (ps.excludeTypes && ps.excludeTypes.length > 0) { + query.andWhere('notification.type NOT IN (:...excludeTypes)', { excludeTypes: ps.excludeTypes }); + } + + if (ps.unreadOnly) { + query.andWhere('notification.isRead = false'); + } + + const notifications = await query.take(ps.limit).getMany(); + + // Mark all as read + if (notifications.length > 0 && ps.markAsRead) { + this.notificationService.readNotification(me.id, notifications.map(x => x.id)); + } + + const notes = notifications.filter(notification => ['mention', 'reply', 'quote'].includes(notification.type)).map(notification => notification.note!); + + if (notes.length > 0) { + this.noteReadService.read(me.id, notes); + } + + return await this.notificationEntityService.packMany(notifications, me.id); + }); } - // excludeTypes に全指定されている場合はクエリしない - if (notificationTypes.every(type => ps.excludeTypes?.includes(type))) { - return []; - } - const followingQuery = Followings.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: user.id }); - - const mutingQuery = Mutings.createQueryBuilder('muting') - .select('muting.muteeId') - .where('muting.muterId = :muterId', { muterId: user.id }); - - const mutingInstanceQuery = UserProfiles.createQueryBuilder('user_profile') - .select('user_profile.mutedInstances') - .where('user_profile.userId = :muterId', { muterId: user.id }); - - const suspendedQuery = Users.createQueryBuilder('users') - .select('users.id') - .where('users.isSuspended = TRUE'); - - const query = makePaginationQuery(Notifications.createQueryBuilder('notification'), ps.sinceId, ps.untilId) - .andWhere('notification.notifieeId = :meId', { meId: user.id }) - .leftJoinAndSelect('notification.notifier', 'notifier') - .leftJoinAndSelect('notification.note', 'note') - .leftJoinAndSelect('notifier.avatar', 'notifierAvatar') - .leftJoinAndSelect('notifier.banner', 'notifierBanner') - .leftJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); - - // muted users - query.andWhere(new Brackets(qb => { qb - .where(`notification.notifierId NOT IN (${ mutingQuery.getQuery() })`) - .orWhere('notification.notifierId IS NULL'); - })); - query.setParameters(mutingQuery.getParameters()); - - // muted instances - query.andWhere(new Brackets(qb => { qb - .andWhere('notifier.host IS NULL') - .orWhere(`NOT (( ${mutingInstanceQuery.getQuery()} )::jsonb ? notifier.host)`); - })); - query.setParameters(mutingInstanceQuery.getParameters()); - - // suspended users - query.andWhere(new Brackets(qb => { qb - .where(`notification.notifierId NOT IN (${ suspendedQuery.getQuery() })`) - .orWhere('notification.notifierId IS NULL'); - })); - - if (ps.following) { - query.andWhere(`((notification.notifierId IN (${ followingQuery.getQuery() })) OR (notification.notifierId = :meId))`, { meId: user.id }); - query.setParameters(followingQuery.getParameters()); - } - - if (ps.includeTypes && ps.includeTypes.length > 0) { - query.andWhere('notification.type IN (:...includeTypes)', { includeTypes: ps.includeTypes }); - } else if (ps.excludeTypes && ps.excludeTypes.length > 0) { - query.andWhere('notification.type NOT IN (:...excludeTypes)', { excludeTypes: ps.excludeTypes }); - } - - if (ps.unreadOnly) { - query.andWhere('notification.isRead = false'); - } - - const notifications = await query.take(ps.limit).getMany(); - - // Mark all as read - if (notifications.length > 0 && ps.markAsRead) { - readNotification(user.id, notifications.map(x => x.id)); - } - - const notes = notifications.filter(notification => ['mention', 'reply', 'quote'].includes(notification.type)).map(notification => notification.note!); - - if (notes.length > 0) { - read(user.id, notes); - } - - return await Notifications.packMany(notifications, user.id); -}); +} diff --git a/packages/backend/src/server/api/endpoints/i/page-likes.ts b/packages/backend/src/server/api/endpoints/i/page-likes.ts index 71e326e2f..9a909eedf 100644 --- a/packages/backend/src/server/api/endpoints/i/page-likes.ts +++ b/packages/backend/src/server/api/endpoints/i/page-likes.ts @@ -1,6 +1,9 @@ -import define from '../../define.js'; -import { PageLikes } from '@/models/index.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { PageLikesRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { PageLikeEntityService } from '@/core/entities/PageLikeEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['account', 'pages'], @@ -26,7 +29,7 @@ export const meta = { ref: 'Page', }, }, - } + }, }, } as const; @@ -41,14 +44,25 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const query = makePaginationQuery(PageLikes.createQueryBuilder('like'), ps.sinceId, ps.untilId) - .andWhere(`like.userId = :meId`, { meId: user.id }) - .leftJoinAndSelect('like.page', 'page'); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.pageLikesRepository) + private pageLikesRepository: PageLikesRepository, - const likes = await query - .take(ps.limit) - .getMany(); + private pageLikeEntityService: PageLikeEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.pageLikesRepository.createQueryBuilder('like'), ps.sinceId, ps.untilId) + .andWhere('like.userId = :meId', { meId: me.id }) + .leftJoinAndSelect('like.page', 'page'); - return PageLikes.packMany(likes, user); -}); + const likes = await query + .take(ps.limit) + .getMany(); + + return this.pageLikeEntityService.packMany(likes, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/pages.ts b/packages/backend/src/server/api/endpoints/i/pages.ts index f28aed3fd..7c4e4a6c7 100644 --- a/packages/backend/src/server/api/endpoints/i/pages.ts +++ b/packages/backend/src/server/api/endpoints/i/pages.ts @@ -1,6 +1,9 @@ -import define from '../../define.js'; -import { Pages } from '@/models/index.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { PagesRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { PageEntityService } from '@/core/entities/PageEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['account', 'pages'], @@ -31,13 +34,24 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const query = makePaginationQuery(Pages.createQueryBuilder('page'), ps.sinceId, ps.untilId) - .andWhere(`page.userId = :meId`, { meId: user.id }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.pagesRepository) + private pagesRepository: PagesRepository, - const pages = await query - .take(ps.limit) - .getMany(); + private pageEntityService: PageEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.pagesRepository.createQueryBuilder('page'), ps.sinceId, ps.untilId) + .andWhere('page.userId = :meId', { meId: me.id }); - return await Pages.packMany(pages); -}); + const pages = await query + .take(ps.limit) + .getMany(); + + return await this.pageEntityService.packMany(pages); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/pin.ts b/packages/backend/src/server/api/endpoints/i/pin.ts index 67b7026be..f31b0dc35 100644 --- a/packages/backend/src/server/api/endpoints/i/pin.ts +++ b/packages/backend/src/server/api/endpoints/i/pin.ts @@ -1,7 +1,9 @@ -import { addPinned } from '@/services/i/pin.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { UsersRepository } from '@/models/index.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { NotePiningService } from '@/core/NotePiningService.js'; import { ApiError } from '../../error.js'; -import { Users } from '@/models/index.js'; export const meta = { tags: ['account', 'notes'], @@ -46,15 +48,23 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - await addPinned(user, ps.noteId).catch(e => { - if (e.id === '70c4e51f-5bea-449c-a030-53bee3cce202') throw new ApiError(meta.errors.noSuchNote); - if (e.id === '15a018eb-58e5-4da1-93be-330fcc5e4e1a') throw new ApiError(meta.errors.pinLimitExceeded); - if (e.id === '23f0cf4e-59a3-4276-a91d-61a5891c1514') throw new ApiError(meta.errors.alreadyPinned); - throw e; - }); +@Injectable() +export default class extends Endpoint { + constructor( + private userEntityService: UserEntityService, + private notePiningService: NotePiningService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.notePiningService.addPinned(me, ps.noteId).catch(err => { + if (err.id === '70c4e51f-5bea-449c-a030-53bee3cce202') throw new ApiError(meta.errors.noSuchNote); + if (err.id === '15a018eb-58e5-4da1-93be-330fcc5e4e1a') throw new ApiError(meta.errors.pinLimitExceeded); + if (err.id === '23f0cf4e-59a3-4276-a91d-61a5891c1514') throw new ApiError(meta.errors.alreadyPinned); + throw err; + }); - return await Users.pack(user.id, user, { - detail: true, - }); -}); + return await this.userEntityService.pack(me.id, me, { + detail: true, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/read-all-messaging-messages.ts b/packages/backend/src/server/api/endpoints/i/read-all-messaging-messages.ts index 7ff6409ca..36c3566f5 100644 --- a/packages/backend/src/server/api/endpoints/i/read-all-messaging-messages.ts +++ b/packages/backend/src/server/api/endpoints/i/read-all-messaging-messages.ts @@ -1,6 +1,8 @@ -import { publishMainStream } from '@/services/stream.js'; -import define from '../../define.js'; -import { MessagingMessages, UserGroupJoinings } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { MessagingMessagesRepository, UserGroupJoiningsRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['account', 'messaging'], @@ -17,25 +19,38 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - // Update documents - await MessagingMessages.update({ - recipientId: user.id, - isRead: false, - }, { - isRead: true, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, - const joinings = await UserGroupJoinings.findBy({ userId: user.id }); + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, - await Promise.all(joinings.map(j => MessagingMessages.createQueryBuilder().update() - .set({ - reads: (() => `array_append("reads", '${user.id}')`) as any, - }) - .where(`groupId = :groupId`, { groupId: j.userGroupId }) - .andWhere('userId != :userId', { userId: user.id }) - .andWhere('NOT (:userId = ANY(reads))', { userId: user.id }) - .execute())); + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + // Update documents + await this.messagingMessagesRepository.update({ + recipientId: me.id, + isRead: false, + }, { + isRead: true, + }); - publishMainStream(user.id, 'readAllMessagingMessages'); -}); + const joinings = await this.userGroupJoiningsRepository.findBy({ userId: me.id }); + + await Promise.all(joinings.map(j => this.messagingMessagesRepository.createQueryBuilder().update() + .set({ + reads: (() => `array_append("reads", '${me.id}')`) as any, + }) + .where('groupId = :groupId', { groupId: j.userGroupId }) + .andWhere('userId != :userId', { userId: me.id }) + .andWhere('NOT (:userId = ANY(reads))', { userId: me.id }) + .execute())); + + this.globalEventService.publishMainStream(me.id, 'readAllMessagingMessages'); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts b/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts index 49f3deb33..b4bb83c6e 100644 --- a/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts +++ b/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts @@ -1,6 +1,8 @@ -import { publishMainStream } from '@/services/stream.js'; -import define from '../../define.js'; -import { NoteUnreads } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { NoteUnreadsRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['account'], @@ -17,13 +19,23 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - // Remove documents - await NoteUnreads.delete({ - userId: user.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.noteUnreadsRepository) + private noteUnreadsRepository: NoteUnreadsRepository, - // 全て既読になったイベントを発行 - publishMainStream(user.id, 'readAllUnreadMentions'); - publishMainStream(user.id, 'readAllUnreadSpecifiedNotes'); -}); + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + // Remove documents + await this.noteUnreadsRepository.delete({ + userId: me.id, + }); + + // 全て既読になったイベントを発行 + this.globalEventService.publishMainStream(me.id, 'readAllUnreadMentions'); + this.globalEventService.publishMainStream(me.id, 'readAllUnreadSpecifiedNotes'); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/read-announcement.ts b/packages/backend/src/server/api/endpoints/i/read-announcement.ts index 45b6e98c8..5a7909674 100644 --- a/packages/backend/src/server/api/endpoints/i/read-announcement.ts +++ b/packages/backend/src/server/api/endpoints/i/read-announcement.ts @@ -1,8 +1,12 @@ -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { IdService } from '@/core/IdService.js'; +import { AnnouncementReadsRepository, AnnouncementsRepository } from '@/models/index.js'; +import type { UsersRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { genId } from '@/misc/gen-id.js'; -import { AnnouncementReads, Announcements, Users } from '@/models/index.js'; -import { publishMainStream } from '@/services/stream.js'; export const meta = { tags: ['account'], @@ -29,33 +33,48 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - // Check if announcement exists - const announcement = await Announcements.findOneBy({ id: ps.announcementId }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.announcementsRepository) + private announcementsRepository: AnnouncementsRepository, - if (announcement == null) { - throw new ApiError(meta.errors.noSuchAnnouncement); + @Inject(DI.announcementReadsRepository) + private announcementReadsRepository: AnnouncementReadsRepository, + + private userEntityService: UserEntityService, + private idService: IdService, + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + // Check if announcement exists + const announcement = await this.announcementsRepository.findOneBy({ id: ps.announcementId }); + + if (announcement == null) { + throw new ApiError(meta.errors.noSuchAnnouncement); + } + + // Check if already read + const read = await this.announcementReadsRepository.findOneBy({ + announcementId: ps.announcementId, + userId: me.id, + }); + + if (read != null) { + return; + } + + // Create read + await this.announcementReadsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + announcementId: ps.announcementId, + userId: me.id, + }); + + if (!await this.userEntityService.getHasUnreadAnnouncement(me.id)) { + this.globalEventService.publishMainStream(me.id, 'readAllAnnouncements'); + } + }); } - - // Check if already read - const read = await AnnouncementReads.findOneBy({ - announcementId: ps.announcementId, - userId: user.id, - }); - - if (read != null) { - return; - } - - // Create read - await AnnouncementReads.insert({ - id: genId(), - createdAt: new Date(), - announcementId: ps.announcementId, - userId: user.id, - }); - - if (!await Users.getHasUnreadAnnouncement(user.id)) { - publishMainStream(user.id, 'readAllAnnouncements'); - } -}); +} diff --git a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts index af929b04e..7796fd97c 100644 --- a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts +++ b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts @@ -1,8 +1,10 @@ import bcrypt from 'bcryptjs'; -import { publishInternalEvent, publishMainStream, publishUserEvent } from '@/services/stream.js'; -import generateUserToken from '../../common/generate-native-user-token.js'; -import define from '../../define.js'; -import { Users, UserProfiles } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UsersRepository, UserProfilesRepository } from '@/models/index.js'; +import generateUserToken from '@/misc/generate-native-user-token.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { requireCredential: true, @@ -19,31 +21,44 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const freshUser = await Users.findOneByOrFail({ id: user.id }); - const oldToken = freshUser.token; +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, - // Compare password - const same = await bcrypt.compare(ps.password, profile.password!); + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + const freshUser = await this.usersRepository.findOneByOrFail({ id: me.id }); + const oldToken = freshUser.token; - if (!same) { - throw new Error('incorrect password'); + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + const newToken = generateUserToken(); + + await this.usersRepository.update(me.id, { + token: newToken, + }); + + // Publish event + this.globalEventService.publishInternalEvent('userTokenRegenerated', { id: me.id, oldToken, newToken }); + this.globalEventService.publishMainStream(me.id, 'myTokenRegenerated'); + + // Terminate streaming + setTimeout(() => { + this.globalEventService.publishUserEvent(me.id, 'terminate', {}); + }, 5000); + }); } - - const newToken = generateUserToken(); - - await Users.update(user.id, { - token: newToken, - }); - - // Publish event - publishInternalEvent('userTokenRegenerated', { id: user.id, oldToken, newToken }); - publishMainStream(user.id, 'myTokenRegenerated'); - - // Terminate streaming - setTimeout(() => { - publishUserEvent(user.id, 'terminate', {}); - }, 5000); -}); +} diff --git a/packages/backend/src/server/api/endpoints/i/registry/get-all.ts b/packages/backend/src/server/api/endpoints/i/registry/get-all.ts index d0b16dbc4..3b4db5fae 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/get-all.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/get-all.ts @@ -1,5 +1,7 @@ -import define from '../../../define.js'; -import { RegistryItems } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { RegistryItemsRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; export const meta = { requireCredential: true, @@ -18,19 +20,27 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const query = RegistryItems.createQueryBuilder('item') - .where('item.domain IS NULL') - .andWhere('item.userId = :userId', { userId: user.id }) - .andWhere('item.scope = :scope', { scope: ps.scope }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.registryItemsRepository) + private registryItemsRepository: RegistryItemsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.registryItemsRepository.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: me.id }) + .andWhere('item.scope = :scope', { scope: ps.scope }); - const items = await query.getMany(); + const items = await query.getMany(); - const res = {} as Record; + const res = {} as Record; - for (const item of items) { - res[item.key] = item.value; + for (const item of items) { + res[item.key] = item.value; + } + + return res; + }); } - - return res; -}); +} diff --git a/packages/backend/src/server/api/endpoints/i/registry/get-detail.ts b/packages/backend/src/server/api/endpoints/i/registry/get-detail.ts index cc5d5a8c6..d24dff95b 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/get-detail.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/get-detail.ts @@ -1,5 +1,7 @@ -import define from '../../../define.js'; -import { RegistryItems } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { RegistryItemsRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -28,21 +30,29 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const query = RegistryItems.createQueryBuilder('item') - .where('item.domain IS NULL') - .andWhere('item.userId = :userId', { userId: user.id }) - .andWhere('item.key = :key', { key: ps.key }) - .andWhere('item.scope = :scope', { scope: ps.scope }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.registryItemsRepository) + private registryItemsRepository: RegistryItemsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.registryItemsRepository.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: me.id }) + .andWhere('item.key = :key', { key: ps.key }) + .andWhere('item.scope = :scope', { scope: ps.scope }); - const item = await query.getOne(); + const item = await query.getOne(); - if (item == null) { - throw new ApiError(meta.errors.noSuchKey); + if (item == null) { + throw new ApiError(meta.errors.noSuchKey); + } + + return { + updatedAt: item.updatedAt, + value: item.value, + }; + }); } - - return { - updatedAt: item.updatedAt, - value: item.value, - }; -}); +} diff --git a/packages/backend/src/server/api/endpoints/i/registry/get.ts b/packages/backend/src/server/api/endpoints/i/registry/get.ts index a79319744..98d94a4c0 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/get.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/get.ts @@ -1,5 +1,7 @@ -import define from '../../../define.js'; -import { RegistryItems } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { RegistryItemsRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -28,18 +30,26 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const query = RegistryItems.createQueryBuilder('item') - .where('item.domain IS NULL') - .andWhere('item.userId = :userId', { userId: user.id }) - .andWhere('item.key = :key', { key: ps.key }) - .andWhere('item.scope = :scope', { scope: ps.scope }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.registryItemsRepository) + private registryItemsRepository: RegistryItemsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.registryItemsRepository.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: me.id }) + .andWhere('item.key = :key', { key: ps.key }) + .andWhere('item.scope = :scope', { scope: ps.scope }); - const item = await query.getOne(); + const item = await query.getOne(); - if (item == null) { - throw new ApiError(meta.errors.noSuchKey); + if (item == null) { + throw new ApiError(meta.errors.noSuchKey); + } + + return item.value; + }); } - - return item.value; -}); +} diff --git a/packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts b/packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts index ac209c06a..d1a05d9d0 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts @@ -1,5 +1,7 @@ -import define from '../../../define.js'; -import { RegistryItems } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { RegistryItemsRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; export const meta = { requireCredential: true, @@ -18,19 +20,25 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const query = RegistryItems.createQueryBuilder('item') - .where('item.domain IS NULL') - .andWhere('item.userId = :userId', { userId: user.id }) - .andWhere('item.scope = :scope', { scope: ps.scope }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.registryItemsRepository) + private registryItemsRepository: RegistryItemsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.registryItemsRepository.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: me.id }) + .andWhere('item.scope = :scope', { scope: ps.scope }); - const items = await query.getMany(); + const items = await query.getMany(); - const res = {} as Record; + const res = {} as Record; - for (const item of items) { - const type = typeof item.value; - res[item.key] = + for (const item of items) { + const type = typeof item.value; + res[item.key] = item.value === null ? 'null' : Array.isArray(item.value) ? 'array' : type === 'number' ? 'number' : @@ -38,7 +46,9 @@ export default define(meta, paramDef, async (ps, user) => { type === 'boolean' ? 'boolean' : type === 'object' ? 'object' : null as never; - } + } - return res; -}); + return res; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/registry/keys.ts b/packages/backend/src/server/api/endpoints/i/registry/keys.ts index 5ea1a9d34..6df5f4ecc 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/keys.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/keys.ts @@ -1,5 +1,7 @@ -import define from '../../../define.js'; -import { RegistryItems } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { RegistryItemsRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; export const meta = { requireCredential: true, @@ -18,14 +20,22 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const query = RegistryItems.createQueryBuilder('item') - .select('item.key') - .where('item.domain IS NULL') - .andWhere('item.userId = :userId', { userId: user.id }) - .andWhere('item.scope = :scope', { scope: ps.scope }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.registryItemsRepository) + private registryItemsRepository: RegistryItemsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.registryItemsRepository.createQueryBuilder('item') + .select('item.key') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: me.id }) + .andWhere('item.scope = :scope', { scope: ps.scope }); - const items = await query.getMany(); + const items = await query.getMany(); - return items.map(x => x.key); -}); + return items.map(x => x.key); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/registry/remove.ts b/packages/backend/src/server/api/endpoints/i/registry/remove.ts index 92473654c..b5870f099 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/remove.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/remove.ts @@ -1,5 +1,7 @@ -import define from '../../../define.js'; -import { RegistryItems } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { RegistryItemsRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -28,18 +30,26 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const query = RegistryItems.createQueryBuilder('item') - .where('item.domain IS NULL') - .andWhere('item.userId = :userId', { userId: user.id }) - .andWhere('item.key = :key', { key: ps.key }) - .andWhere('item.scope = :scope', { scope: ps.scope }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.registryItemsRepository) + private registryItemsRepository: RegistryItemsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.registryItemsRepository.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: me.id }) + .andWhere('item.key = :key', { key: ps.key }) + .andWhere('item.scope = :scope', { scope: ps.scope }); - const item = await query.getOne(); + const item = await query.getOne(); - if (item == null) { - throw new ApiError(meta.errors.noSuchKey); + if (item == null) { + throw new ApiError(meta.errors.noSuchKey); + } + + await this.registryItemsRepository.remove(item); + }); } - - await RegistryItems.remove(item); -}); +} diff --git a/packages/backend/src/server/api/endpoints/i/registry/scopes.ts b/packages/backend/src/server/api/endpoints/i/registry/scopes.ts index de4b313e2..58085ddbc 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/scopes.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/scopes.ts @@ -1,5 +1,7 @@ -import define from '../../../define.js'; -import { RegistryItems } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { RegistryItemsRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; export const meta = { requireCredential: true, @@ -14,20 +16,28 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const query = RegistryItems.createQueryBuilder('item') - .select('item.scope') - .where('item.domain IS NULL') - .andWhere('item.userId = :userId', { userId: user.id }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.registryItemsRepository) + private registryItemsRepository: RegistryItemsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.registryItemsRepository.createQueryBuilder('item') + .select('item.scope') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: me.id }); - const items = await query.getMany(); + const items = await query.getMany(); - const res = [] as string[][]; + const res = [] as string[][]; - for (const item of items) { - if (res.some(scope => scope.join('.') === item.scope.join('.'))) continue; - res.push(item.scope); + for (const item of items) { + if (res.some(scope => scope.join('.') === item.scope.join('.'))) continue; + res.push(item.scope); + } + + return res; + }); } - - return res; -}); +} diff --git a/packages/backend/src/server/api/endpoints/i/registry/set.ts b/packages/backend/src/server/api/endpoints/i/registry/set.ts index d380b428a..585aac2e0 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/set.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/set.ts @@ -1,7 +1,9 @@ -import { publishMainStream } from '@/services/stream.js'; -import define from '../../../define.js'; -import { RegistryItems } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { RegistryItemsRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { requireCredential: true, @@ -22,37 +24,48 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const query = RegistryItems.createQueryBuilder('item') - .where('item.domain IS NULL') - .andWhere('item.userId = :userId', { userId: user.id }) - .andWhere('item.key = :key', { key: ps.key }) - .andWhere('item.scope = :scope', { scope: ps.scope }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.registryItemsRepository) + private registryItemsRepository: RegistryItemsRepository, - const existingItem = await query.getOne(); + private idService: IdService, + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.registryItemsRepository.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: me.id }) + .andWhere('item.key = :key', { key: ps.key }) + .andWhere('item.scope = :scope', { scope: ps.scope }); - if (existingItem) { - await RegistryItems.update(existingItem.id, { - updatedAt: new Date(), - value: ps.value, - }); - } else { - await RegistryItems.insert({ - id: genId(), - createdAt: new Date(), - updatedAt: new Date(), - userId: user.id, - domain: null, - scope: ps.scope, - key: ps.key, - value: ps.value, + const existingItem = await query.getOne(); + + if (existingItem) { + await this.registryItemsRepository.update(existingItem.id, { + updatedAt: new Date(), + value: ps.value, + }); + } else { + await this.registryItemsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + updatedAt: new Date(), + userId: me.id, + domain: null, + scope: ps.scope, + key: ps.key, + value: ps.value, + }); + } + + // TODO: サードパーティアプリが傍受出来てしまうのでどうにかする + this.globalEventService.publishMainStream(me.id, 'registryUpdated', { + scope: ps.scope, + key: ps.key, + value: ps.value, + }); }); } - - // TODO: サードパーティアプリが傍受出来てしまうのでどうにかする - publishMainStream(user.id, 'registryUpdated', { - scope: ps.scope, - key: ps.key, - value: ps.value, - }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/i/revoke-token.ts b/packages/backend/src/server/api/endpoints/i/revoke-token.ts index c69245379..86a82e6a6 100644 --- a/packages/backend/src/server/api/endpoints/i/revoke-token.ts +++ b/packages/backend/src/server/api/endpoints/i/revoke-token.ts @@ -1,6 +1,8 @@ -import define from '../../define.js'; -import { AccessTokens } from '@/models/index.js'; -import { publishUserEvent } from '@/services/stream.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AccessTokensRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { requireCredential: true, @@ -17,16 +19,26 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const token = await AccessTokens.findOneBy({ id: ps.tokenId }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.accessTokensRepository) + private accessTokensRepository: AccessTokensRepository, - if (token) { - await AccessTokens.delete({ - id: ps.tokenId, - userId: user.id, + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + const token = await this.accessTokensRepository.findOneBy({ id: ps.tokenId }); + + if (token) { + await this.accessTokensRepository.delete({ + id: ps.tokenId, + userId: me.id, + }); + + // Terminate streaming + this.globalEventService.publishUserEvent(me.id, 'terminate'); + } }); - - // Terminate streaming - publishUserEvent(user.id, 'terminate'); } -}); +} diff --git a/packages/backend/src/server/api/endpoints/i/signin-history.ts b/packages/backend/src/server/api/endpoints/i/signin-history.ts index ca3741166..410cd7206 100644 --- a/packages/backend/src/server/api/endpoints/i/signin-history.ts +++ b/packages/backend/src/server/api/endpoints/i/signin-history.ts @@ -1,6 +1,9 @@ -import define from '../../define.js'; -import { Signins } from '@/models/index.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { SigninsRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { SigninEntityService } from '@/core/entities/SigninEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { requireCredential: true, @@ -19,11 +22,22 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const query = makePaginationQuery(Signins.createQueryBuilder('signin'), ps.sinceId, ps.untilId) - .andWhere(`signin.userId = :meId`, { meId: user.id }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.signinsRepository) + private signinsRepository: SigninsRepository, - const history = await query.take(ps.limit).getMany(); + private signinEntityService: SigninEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.signinsRepository.createQueryBuilder('signin'), ps.sinceId, ps.untilId) + .andWhere('signin.userId = :meId', { meId: me.id }); - return await Promise.all(history.map(record => Signins.pack(record))); -}); + const history = await query.take(ps.limit).getMany(); + + return await Promise.all(history.map(record => this.signinEntityService.pack(record))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/unpin.ts b/packages/backend/src/server/api/endpoints/i/unpin.ts index 9912689da..9a735e116 100644 --- a/packages/backend/src/server/api/endpoints/i/unpin.ts +++ b/packages/backend/src/server/api/endpoints/i/unpin.ts @@ -1,7 +1,9 @@ -import { removePinned } from '@/services/i/pin.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { UsersRepository } from '@/models/index.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { NotePiningService } from '@/core/NotePiningService.js'; import { ApiError } from '../../error.js'; -import { Users } from '@/models/index.js'; export const meta = { tags: ['account', 'notes'], @@ -34,13 +36,21 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - await removePinned(user, ps.noteId).catch(e => { - if (e.id === 'b302d4cf-c050-400a-bbb3-be208681f40c') throw new ApiError(meta.errors.noSuchNote); - throw e; - }); +@Injectable() +export default class extends Endpoint { + constructor( + private userEntityService: UserEntityService, + private notePiningService: NotePiningService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.notePiningService.removePinned(me, ps.noteId).catch(err => { + if (err.id === 'b302d4cf-c050-400a-bbb3-be208681f40c') throw new ApiError(meta.errors.noSuchNote); + throw err; + }); - return await Users.pack(user.id, user, { - detail: true, - }); -}); + return await this.userEntityService.pack(me.id, me, { + detail: true, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/update-email.ts b/packages/backend/src/server/api/endpoints/i/update-email.ts index 331807852..719cc14f0 100644 --- a/packages/backend/src/server/api/endpoints/i/update-email.ts +++ b/packages/backend/src/server/api/endpoints/i/update-email.ts @@ -1,13 +1,15 @@ -import { publishMainStream } from '@/services/stream.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; import rndstr from 'rndstr'; -import config from '@/config/index.js'; import ms from 'ms'; import bcrypt from 'bcryptjs'; -import { Users, UserProfiles } from '@/models/index.js'; -import { sendEmail } from '@/services/send-email.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UsersRepository, UserProfilesRepository } from '@/models/index.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { EmailService } from '@/core/EmailService.js'; +import { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; import { ApiError } from '../../error.js'; -import { validateEmailForAccount } from '@/services/validate-email-for-account.js'; export const meta = { requireCredential: true, @@ -44,50 +46,68 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.config) + private config: Config, - // Compare password - const same = await bcrypt.compare(ps.password, profile.password!); + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - if (!same) { - throw new ApiError(meta.errors.incorrectPassword); - } + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, - if (ps.email != null) { - const available = await validateEmailForAccount(ps.email); - if (!available) { - throw new ApiError(meta.errors.unavailable); - } - } + private userEntityService: UserEntityService, + private emailService: EmailService, + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); - await UserProfiles.update(user.id, { - email: ps.email, - emailVerified: false, - emailVerifyCode: null, - }); + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); - const iObj = await Users.pack(user.id, user, { - detail: true, - includeSecrets: true, - }); + if (!same) { + throw new ApiError(meta.errors.incorrectPassword); + } - // Publish meUpdated event - publishMainStream(user.id, 'meUpdated', iObj); + if (ps.email != null) { + const available = await this.emailService.validateEmailForAccount(ps.email); + if (!available) { + throw new ApiError(meta.errors.unavailable); + } + } - if (ps.email != null) { - const code = rndstr('a-z0-9', 16); + await this.userProfilesRepository.update(me.id, { + email: ps.email, + emailVerified: false, + emailVerifyCode: null, + }); - await UserProfiles.update(user.id, { - emailVerifyCode: code, + const iObj = await this.userEntityService.pack(me.id, me, { + detail: true, + includeSecrets: true, + }); + + // Publish meUpdated event + this.globalEventService.publishMainStream(me.id, 'meUpdated', iObj); + + if (ps.email != null) { + const code = rndstr('a-z0-9', 16); + + await this.userProfilesRepository.update(me.id, { + emailVerifyCode: code, + }); + + const link = `${this.config.url}/verify-email/${code}`; + + this.emailService.sendEmail(ps.email, 'Email verification', + `To verify email, please click this link:
${link}`, + `To verify email, please click this link: ${link}`); + } + + return iObj; }); - - const link = `${config.url}/verify-email/${code}`; - - sendEmail(ps.email, 'Email verification', - `To verify email, please click this link:
${link}`, - `To verify email, please click this link: ${link}`); } - - return iObj; -}); +} diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 3c2f1cea0..4b904d469 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -1,19 +1,23 @@ import RE2 from 're2'; import * as mfm from 'mfm-js'; -import { publishMainStream, publishUserEvent } from '@/services/stream.js'; -import acceptAllFollowRequests from '@/services/following/requests/accept-all.js'; -import { publishToFollowers } from '@/services/i/update.js'; +import { Inject, Injectable } from '@nestjs/common'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractHashtags } from '@/misc/extract-hashtags.js'; -import { updateUsertags } from '@/services/update-hashtag.js'; -import { Users, DriveFiles, UserProfiles, Pages } from '@/models/index.js'; -import { User } from '@/models/entities/user.js'; -import { UserProfile } from '@/models/entities/user-profile.js'; +import { UsersRepository, DriveFilesRepository, UserProfilesRepository, PagesRepository } from '@/models/index.js'; +import type { User } from '@/models/entities/User.js'; +import { birthdaySchema, descriptionSchema, locationSchema, nameSchema } from '@/models/entities/User.js'; +import type { UserProfile } from '@/models/entities/UserProfile.js'; import { notificationTypes } from '@/types.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { langmap } from '@/misc/langmap.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { AccountUpdateService } from '@/core/AccountUpdateService.js'; +import { HashtagService } from '@/core/HashtagService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import define from '../../define.js'; export const meta = { tags: ['account'], @@ -70,10 +74,10 @@ export const meta = { export const paramDef = { type: 'object', properties: { - name: { ...Users.nameSchema, nullable: true }, - description: { ...Users.descriptionSchema, nullable: true }, - location: { ...Users.locationSchema, nullable: true }, - birthday: { ...Users.birthdaySchema, nullable: true }, + name: { ...nameSchema, nullable: true }, + description: { ...descriptionSchema, nullable: true }, + location: { ...locationSchema, nullable: true }, + birthday: { ...birthdaySchema, nullable: true }, lang: { type: 'string', enum: [null, ...Object.keys(langmap)], nullable: true }, avatarId: { type: 'string', format: 'misskey:id', nullable: true }, bannerId: { type: 'string', format: 'misskey:id', nullable: true }, @@ -122,134 +126,157 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, _user, token) => { - const user = await Users.findOneByOrFail({ id: _user.id }); - const isSecure = token == null; +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - const updates = {} as Partial; - const profileUpdates = {} as Partial; + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, - const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, - if (ps.name !== undefined) updates.name = ps.name; - if (ps.description !== undefined) profileUpdates.description = ps.description; - if (ps.lang !== undefined) profileUpdates.lang = ps.lang; - if (ps.location !== undefined) profileUpdates.location = ps.location; - if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday; - if (ps.ffVisibility !== undefined) profileUpdates.ffVisibility = ps.ffVisibility; - if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId; - if (ps.bannerId !== undefined) updates.bannerId = ps.bannerId; - if (ps.mutedWords !== undefined) { - // validate regular expression syntax - ps.mutedWords.filter(x => !Array.isArray(x)).forEach(x => { - const regexp = x.match(/^\/(.+)\/(.*)$/); - if (!regexp) throw new ApiError(meta.errors.invalidRegexp); + @Inject(DI.pagesRepository) + private pagesRepository: PagesRepository, - try { - new RE2(regexp[1], regexp[2]); - } catch (err) { - throw new ApiError(meta.errors.invalidRegexp); + private userEntityService: UserEntityService, + private globalEventService: GlobalEventService, + private userFollowingService: UserFollowingService, + private accountUpdateService: AccountUpdateService, + private hashtagService: HashtagService, + ) { + super(meta, paramDef, async (ps, _user, token) => { + const user = await this.usersRepository.findOneByOrFail({ id: _user.id }); + const isSecure = token == null; + + const updates = {} as Partial; + const profileUpdates = {} as Partial; + + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + + if (ps.name !== undefined) updates.name = ps.name; + if (ps.description !== undefined) profileUpdates.description = ps.description; + if (ps.lang !== undefined) profileUpdates.lang = ps.lang; + if (ps.location !== undefined) profileUpdates.location = ps.location; + if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday; + if (ps.ffVisibility !== undefined) profileUpdates.ffVisibility = ps.ffVisibility; + if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId; + if (ps.bannerId !== undefined) updates.bannerId = ps.bannerId; + if (ps.mutedWords !== undefined) { + // validate regular expression syntax + ps.mutedWords.filter(x => !Array.isArray(x)).forEach(x => { + const regexp = x.match(/^\/(.+)\/(.*)$/); + if (!regexp) throw new ApiError(meta.errors.invalidRegexp); + + try { + new RE2(regexp[1], regexp[2]); + } catch (err) { + throw new ApiError(meta.errors.invalidRegexp); + } + }); + + profileUpdates.mutedWords = ps.mutedWords; + profileUpdates.enableWordMute = ps.mutedWords.length > 0; } - }); + if (ps.mutedInstances !== undefined) profileUpdates.mutedInstances = ps.mutedInstances; + if (ps.mutingNotificationTypes !== undefined) profileUpdates.mutingNotificationTypes = ps.mutingNotificationTypes as typeof notificationTypes[number][]; + if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked; + if (typeof ps.isExplorable === 'boolean') updates.isExplorable = ps.isExplorable; + if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus; + if (typeof ps.publicReactions === 'boolean') profileUpdates.publicReactions = ps.publicReactions; + if (typeof ps.isBot === 'boolean') updates.isBot = ps.isBot; + if (typeof ps.showTimelineReplies === 'boolean') updates.showTimelineReplies = ps.showTimelineReplies; + if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot; + if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed; + if (typeof ps.noCrawle === 'boolean') profileUpdates.noCrawle = ps.noCrawle; + if (typeof ps.isCat === 'boolean') updates.isCat = ps.isCat; + if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote; + if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail; + if (typeof ps.alwaysMarkNsfw === 'boolean') profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw; + if (typeof ps.autoSensitive === 'boolean') profileUpdates.autoSensitive = ps.autoSensitive; + if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes; - profileUpdates.mutedWords = ps.mutedWords; - profileUpdates.enableWordMute = ps.mutedWords.length > 0; - } - if (ps.mutedInstances !== undefined) profileUpdates.mutedInstances = ps.mutedInstances; - if (ps.mutingNotificationTypes !== undefined) profileUpdates.mutingNotificationTypes = ps.mutingNotificationTypes as typeof notificationTypes[number][]; - if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked; - if (typeof ps.isExplorable === 'boolean') updates.isExplorable = ps.isExplorable; - if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus; - if (typeof ps.publicReactions === 'boolean') profileUpdates.publicReactions = ps.publicReactions; - if (typeof ps.isBot === 'boolean') updates.isBot = ps.isBot; - if (typeof ps.showTimelineReplies === 'boolean') updates.showTimelineReplies = ps.showTimelineReplies; - if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot; - if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed; - if (typeof ps.noCrawle === 'boolean') profileUpdates.noCrawle = ps.noCrawle; - if (typeof ps.isCat === 'boolean') updates.isCat = ps.isCat; - if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote; - if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail; - if (typeof ps.alwaysMarkNsfw === 'boolean') profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw; - if (typeof ps.autoSensitive === 'boolean') profileUpdates.autoSensitive = ps.autoSensitive; - if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes; + if (ps.avatarId) { + const avatar = await this.driveFilesRepository.findOneBy({ id: ps.avatarId }); - if (ps.avatarId) { - const avatar = await DriveFiles.findOneBy({ id: ps.avatarId }); + if (avatar == null || avatar.userId !== user.id) throw new ApiError(meta.errors.noSuchAvatar); + if (!avatar.type.startsWith('image/')) throw new ApiError(meta.errors.avatarNotAnImage); + } - if (avatar == null || avatar.userId !== user.id) throw new ApiError(meta.errors.noSuchAvatar); - if (!avatar.type.startsWith('image/')) throw new ApiError(meta.errors.avatarNotAnImage); - } + if (ps.bannerId) { + const banner = await this.driveFilesRepository.findOneBy({ id: ps.bannerId }); - if (ps.bannerId) { - const banner = await DriveFiles.findOneBy({ id: ps.bannerId }); + if (banner == null || banner.userId !== user.id) throw new ApiError(meta.errors.noSuchBanner); + if (!banner.type.startsWith('image/')) throw new ApiError(meta.errors.bannerNotAnImage); + } - if (banner == null || banner.userId !== user.id) throw new ApiError(meta.errors.noSuchBanner); - if (!banner.type.startsWith('image/')) throw new ApiError(meta.errors.bannerNotAnImage); - } + if (ps.pinnedPageId) { + const page = await this.pagesRepository.findOneBy({ id: ps.pinnedPageId }); - if (ps.pinnedPageId) { - const page = await Pages.findOneBy({ id: ps.pinnedPageId }); + if (page == null || page.userId !== user.id) throw new ApiError(meta.errors.noSuchPage); - if (page == null || page.userId !== user.id) throw new ApiError(meta.errors.noSuchPage); + profileUpdates.pinnedPageId = page.id; + } else if (ps.pinnedPageId === null) { + profileUpdates.pinnedPageId = null; + } - profileUpdates.pinnedPageId = page.id; - } else if (ps.pinnedPageId === null) { - profileUpdates.pinnedPageId = null; - } + if (ps.fields) { + profileUpdates.fields = ps.fields + .filter(x => typeof x.name === 'string' && x.name !== '' && typeof x.value === 'string' && x.value !== '') + .map(x => { + return { name: x.name, value: x.value }; + }); + } - if (ps.fields) { - profileUpdates.fields = ps.fields - .filter(x => typeof x.name === 'string' && x.name !== '' && typeof x.value === 'string' && x.value !== '') - .map(x => { - return { name: x.name, value: x.value }; + //#region emojis/tags + + let emojis = [] as string[]; + let tags = [] as string[]; + + const newName = updates.name === undefined ? user.name : updates.name; + const newDescription = profileUpdates.description === undefined ? profile.description : profileUpdates.description; + + if (newName != null) { + const tokens = mfm.parseSimple(newName); + emojis = emojis.concat(extractCustomEmojisFromMfm(tokens!)); + } + + if (newDescription != null) { + const tokens = mfm.parse(newDescription); + emojis = emojis.concat(extractCustomEmojisFromMfm(tokens!)); + tags = extractHashtags(tokens!).map(tag => normalizeForSearch(tag)).splice(0, 32); + } + + updates.emojis = emojis; + updates.tags = tags; + + // ハッシュタグ更新 + this.hashtagService.updateUsertags(user, tags); + //#endregion + + if (Object.keys(updates).length > 0) await this.usersRepository.update(user.id, updates); + if (Object.keys(profileUpdates).length > 0) await this.userProfilesRepository.update(user.id, profileUpdates); + + const iObj = await this.userEntityService.pack(user.id, user, { + detail: true, + includeSecrets: isSecure, }); + + // Publish meUpdated event + this.globalEventService.publishMainStream(user.id, 'meUpdated', iObj); + this.globalEventService.publishUserEvent(user.id, 'updateUserProfile', await this.userProfilesRepository.findOneBy({ userId: user.id })); + + // 鍵垢を解除したとき、溜まっていたフォローリクエストがあるならすべて承認 + if (user.isLocked && ps.isLocked === false) { + this.userFollowingService.acceptAllFollowRequests(user); + } + + // フォロワーにUpdateを配信 + this.accountUpdateService.publishToFollowers(user.id); + + return iObj; + }); } - - //#region emojis/tags - - let emojis = [] as string[]; - let tags = [] as string[]; - - const newName = updates.name === undefined ? user.name : updates.name; - const newDescription = profileUpdates.description === undefined ? profile.description : profileUpdates.description; - - if (newName != null) { - const tokens = mfm.parseSimple(newName); - emojis = emojis.concat(extractCustomEmojisFromMfm(tokens!)); - } - - if (newDescription != null) { - const tokens = mfm.parse(newDescription); - emojis = emojis.concat(extractCustomEmojisFromMfm(tokens!)); - tags = extractHashtags(tokens!).map(tag => normalizeForSearch(tag)).splice(0, 32); - } - - updates.emojis = emojis; - updates.tags = tags; - - // ハッシュタグ更新 - updateUsertags(user, tags); - //#endregion - - if (Object.keys(updates).length > 0) await Users.update(user.id, updates); - if (Object.keys(profileUpdates).length > 0) await UserProfiles.update(user.id, profileUpdates); - - const iObj = await Users.pack(user.id, user, { - detail: true, - includeSecrets: isSecure, - }); - - // Publish meUpdated event - publishMainStream(user.id, 'meUpdated', iObj); - publishUserEvent(user.id, 'updateUserProfile', await UserProfiles.findOneBy({ userId: user.id })); - - // 鍵垢を解除したとき、溜まっていたフォローリクエストがあるならすべて承認 - if (user.isLocked && ps.isLocked === false) { - acceptAllFollowRequests(user); - } - - // フォロワーにUpdateを配信 - publishToFollowers(user.id); - - return iObj; -}); +} diff --git a/packages/backend/src/server/api/endpoints/i/user-group-invites.ts b/packages/backend/src/server/api/endpoints/i/user-group-invites.ts index 1d7e4a16b..6dd1626bb 100644 --- a/packages/backend/src/server/api/endpoints/i/user-group-invites.ts +++ b/packages/backend/src/server/api/endpoints/i/user-group-invites.ts @@ -1,6 +1,9 @@ -import define from '../../define.js'; -import { UserGroupInvitations } from '@/models/index.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserGroupInvitationsRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { UserGroupInvitationEntityService } from '@/core/entities/UserGroupInvitationEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['account', 'groups'], @@ -42,14 +45,25 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const query = makePaginationQuery(UserGroupInvitations.createQueryBuilder('invitation'), ps.sinceId, ps.untilId) - .andWhere(`invitation.userId = :meId`, { meId: user.id }) - .leftJoinAndSelect('invitation.userGroup', 'user_group'); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userGroupInvitationsRepository) + private userGroupInvitationsRepository: UserGroupInvitationsRepository, - const invitations = await query - .take(ps.limit) - .getMany(); + private userGroupInvitationEntityService: UserGroupInvitationEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.userGroupInvitationsRepository.createQueryBuilder('invitation'), ps.sinceId, ps.untilId) + .andWhere('invitation.userId = :meId', { meId: me.id }) + .leftJoinAndSelect('invitation.userGroup', 'user_group'); - return await UserGroupInvitations.packMany(invitations); -}); + const invitations = await query + .take(ps.limit) + .getMany(); + + return await this.userGroupInvitationEntityService.packMany(invitations); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/create.ts b/packages/backend/src/server/api/endpoints/i/webhooks/create.ts index 2e2fd00b8..016b1b5d6 100644 --- a/packages/backend/src/server/api/endpoints/i/webhooks/create.ts +++ b/packages/backend/src/server/api/endpoints/i/webhooks/create.ts @@ -1,8 +1,10 @@ -import define from '../../../define.js'; -import { genId } from '@/misc/gen-id.js'; -import { Webhooks } from '@/models/index.js'; -import { publishInternalEvent } from '@/services/stream.js'; -import { webhookEventTypes } from '@/models/entities/webhook.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { IdService } from '@/core/IdService.js'; +import { WebhooksRepository } from '@/models/index.js'; +import { webhookEventTypes } from '@/models/entities/Webhook.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['webhooks'], @@ -25,19 +27,32 @@ export const paramDef = { required: ['name', 'url', 'secret', 'on'], } as const; +// TODO: ロジックをサービスに切り出す + // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const webhook = await Webhooks.insert({ - id: genId(), - createdAt: new Date(), - userId: user.id, - name: ps.name, - url: ps.url, - secret: ps.secret, - on: ps.on, - }).then(x => Webhooks.findOneByOrFail(x.identifiers[0])); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.webhooksRepository) + private webhooksRepository: WebhooksRepository, - publishInternalEvent('webhookCreated', webhook); + private idService: IdService, + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + const webhook = await this.webhooksRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: me.id, + name: ps.name, + url: ps.url, + secret: ps.secret, + on: ps.on, + }).then(x => this.webhooksRepository.findOneByOrFail(x.identifiers[0])); - return webhook; -}); + this.globalEventService.publishInternalEvent('webhookCreated', webhook); + + return webhook; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/delete.ts b/packages/backend/src/server/api/endpoints/i/webhooks/delete.ts index 2821eaa5f..53b553b43 100644 --- a/packages/backend/src/server/api/endpoints/i/webhooks/delete.ts +++ b/packages/backend/src/server/api/endpoints/i/webhooks/delete.ts @@ -1,7 +1,9 @@ -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { WebhooksRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import { Webhooks } from '@/models/index.js'; -import { publishInternalEvent } from '@/services/stream.js'; export const meta = { tags: ['webhooks'], @@ -27,18 +29,30 @@ export const paramDef = { required: ['webhookId'], } as const; +// TODO: ロジックをサービスに切り出す + // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const webhook = await Webhooks.findOneBy({ - id: ps.webhookId, - userId: user.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.webhooksRepository) + private webhooksRepository: WebhooksRepository, - if (webhook == null) { - throw new ApiError(meta.errors.noSuchWebhook); + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + const webhook = await this.webhooksRepository.findOneBy({ + id: ps.webhookId, + userId: me.id, + }); + + if (webhook == null) { + throw new ApiError(meta.errors.noSuchWebhook); + } + + await this.webhooksRepository.delete(webhook.id); + + this.globalEventService.publishInternalEvent('webhookDeleted', webhook); + }); } - - await Webhooks.delete(webhook.id); - - publishInternalEvent('webhookDeleted', webhook); -}); +} diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/list.ts b/packages/backend/src/server/api/endpoints/i/webhooks/list.ts index 54e456373..8e4aff45d 100644 --- a/packages/backend/src/server/api/endpoints/i/webhooks/list.ts +++ b/packages/backend/src/server/api/endpoints/i/webhooks/list.ts @@ -1,5 +1,7 @@ -import define from '../../../define.js'; -import { Webhooks } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { WebhooksRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['webhooks', 'account'], @@ -16,10 +18,18 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const webhooks = await Webhooks.findBy({ - userId: me.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.webhooksRepository) + private webhooksRepository: WebhooksRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const webhooks = await this.webhooksRepository.findBy({ + userId: me.id, + }); - return webhooks; -}); + return webhooks; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/show.ts b/packages/backend/src/server/api/endpoints/i/webhooks/show.ts index 02fa1edb5..622c2ade9 100644 --- a/packages/backend/src/server/api/endpoints/i/webhooks/show.ts +++ b/packages/backend/src/server/api/endpoints/i/webhooks/show.ts @@ -1,6 +1,8 @@ -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { WebhooksRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import { Webhooks } from '@/models/index.js'; export const meta = { tags: ['webhooks'], @@ -27,15 +29,23 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const webhook = await Webhooks.findOneBy({ - id: ps.webhookId, - userId: user.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.webhooksRepository) + private webhooksRepository: WebhooksRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const webhook = await this.webhooksRepository.findOneBy({ + id: ps.webhookId, + userId: me.id, + }); - if (webhook == null) { - throw new ApiError(meta.errors.noSuchWebhook); + if (webhook == null) { + throw new ApiError(meta.errors.noSuchWebhook); + } + + return webhook; + }); } - - return webhook; -}); +} diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/update.ts b/packages/backend/src/server/api/endpoints/i/webhooks/update.ts index f87b9753f..3a0ef1a52 100644 --- a/packages/backend/src/server/api/endpoints/i/webhooks/update.ts +++ b/packages/backend/src/server/api/endpoints/i/webhooks/update.ts @@ -1,8 +1,10 @@ -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { WebhooksRepository } from '@/models/index.js'; +import { webhookEventTypes } from '@/models/entities/Webhook.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import { Webhooks } from '@/models/index.js'; -import { publishInternalEvent } from '@/services/stream.js'; -import { webhookEventTypes } from '@/models/entities/webhook.js'; export const meta = { tags: ['webhooks'], @@ -36,24 +38,36 @@ export const paramDef = { required: ['webhookId', 'name', 'url', 'secret', 'on', 'active'], } as const; +// TODO: ロジックをサービスに切り出す + // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const webhook = await Webhooks.findOneBy({ - id: ps.webhookId, - userId: user.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.webhooksRepository) + private webhooksRepository: WebhooksRepository, - if (webhook == null) { - throw new ApiError(meta.errors.noSuchWebhook); + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + const webhook = await this.webhooksRepository.findOneBy({ + id: ps.webhookId, + userId: me.id, + }); + + if (webhook == null) { + throw new ApiError(meta.errors.noSuchWebhook); + } + + await this.webhooksRepository.update(webhook.id, { + name: ps.name, + url: ps.url, + secret: ps.secret, + on: ps.on, + active: ps.active, + }); + + this.globalEventService.publishInternalEvent('webhookUpdated', webhook); + }); } - - await Webhooks.update(webhook.id, { - name: ps.name, - url: ps.url, - secret: ps.secret, - on: ps.on, - active: ps.active, - }); - - publishInternalEvent('webhookUpdated', webhook); -}); +} diff --git a/packages/backend/src/server/api/endpoints/messaging/history.ts b/packages/backend/src/server/api/endpoints/messaging/history.ts index ea0600d0e..da3ba59df 100644 --- a/packages/backend/src/server/api/endpoints/messaging/history.ts +++ b/packages/backend/src/server/api/endpoints/messaging/history.ts @@ -1,7 +1,10 @@ -import define from '../../define.js'; -import { MessagingMessage } from '@/models/entities/messaging-message.js'; -import { MessagingMessages, Mutings, UserGroupJoinings } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; import { Brackets } from 'typeorm'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { MessagingMessage } from '@/models/entities/MessagingMessage.js'; +import { MutingsRepository, UserGroupJoiningsRepository, MessagingMessagesRepository } from '@/models/index.js'; +import { MessagingMessageEntityService } from '@/core/entities/MessagingMessageEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['messaging'], @@ -31,61 +34,77 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const mute = await Mutings.findBy({ - muterId: user.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, - const groups = ps.group ? await UserGroupJoinings.findBy({ - userId: user.id, - }).then(xs => xs.map(x => x.userGroupId)) : []; + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, - if (ps.group && groups.length === 0) { - return []; + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + private messagingMessageEntityService: MessagingMessageEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const mute = await this.mutingsRepository.findBy({ + muterId: me.id, + }); + + const groups = ps.group ? await this.userGroupJoiningsRepository.findBy({ + userId: me.id, + }).then(xs => xs.map(x => x.userGroupId)) : []; + + if (ps.group && groups.length === 0) { + return []; + } + + const history: MessagingMessage[] = []; + + for (let i = 0; i < ps.limit; i++) { + const found = ps.group + ? history.map(m => m.groupId!) + : history.map(m => (m.userId === me.id) ? m.recipientId! : m.userId!); + + const query = this.messagingMessagesRepository.createQueryBuilder('message') + .orderBy('message.createdAt', 'DESC'); + + if (ps.group) { + query.where('message.groupId IN (:...groups)', { groups: groups }); + + if (found.length > 0) { + query.andWhere('message.groupId NOT IN (:...found)', { found: found }); + } + } else { + query.where(new Brackets(qb => { qb + .where('message.userId = :userId', { userId: me.id }) + .orWhere('message.recipientId = :userId', { userId: me.id }); + })); + query.andWhere('message.groupId IS NULL'); + + if (found.length > 0) { + query.andWhere('message.userId NOT IN (:...found)', { found: found }); + query.andWhere('message.recipientId NOT IN (:...found)', { found: found }); + } + + if (mute.length > 0) { + query.andWhere('message.userId NOT IN (:...mute)', { mute: mute.map(m => m.muteeId) }); + query.andWhere('message.recipientId NOT IN (:...mute)', { mute: mute.map(m => m.muteeId) }); + } + } + + const message = await query.getOne(); + + if (message) { + history.push(message); + } else { + break; + } + } + + return await Promise.all(history.map(h => this.messagingMessageEntityService.pack(h.id, me))); + }); } - - const history: MessagingMessage[] = []; - - for (let i = 0; i < ps.limit; i++) { - const found = ps.group - ? history.map(m => m.groupId!) - : history.map(m => (m.userId === user.id) ? m.recipientId! : m.userId!); - - const query = MessagingMessages.createQueryBuilder('message') - .orderBy('message.createdAt', 'DESC'); - - if (ps.group) { - query.where(`message.groupId IN (:...groups)`, { groups: groups }); - - if (found.length > 0) { - query.andWhere(`message.groupId NOT IN (:...found)`, { found: found }); - } - } else { - query.where(new Brackets(qb => { qb - .where(`message.userId = :userId`, { userId: user.id }) - .orWhere(`message.recipientId = :userId`, { userId: user.id }); - })); - query.andWhere(`message.groupId IS NULL`); - - if (found.length > 0) { - query.andWhere(`message.userId NOT IN (:...found)`, { found: found }); - query.andWhere(`message.recipientId NOT IN (:...found)`, { found: found }); - } - - if (mute.length > 0) { - query.andWhere(`message.userId NOT IN (:...mute)`, { mute: mute.map(m => m.muteeId) }); - query.andWhere(`message.recipientId NOT IN (:...mute)`, { mute: mute.map(m => m.muteeId) }); - } - } - - const message = await query.getOne(); - - if (message) { - history.push(message); - } else { - break; - } - } - - return await Promise.all(history.map(h => MessagingMessages.pack(h.id, user))); -}); +} diff --git a/packages/backend/src/server/api/endpoints/messaging/messages.ts b/packages/backend/src/server/api/endpoints/messaging/messages.ts index dbf1f6c86..6579b0398 100644 --- a/packages/backend/src/server/api/endpoints/messaging/messages.ts +++ b/packages/backend/src/server/api/endpoints/messaging/messages.ts @@ -1,10 +1,15 @@ -import define from '../../define.js'; -import { ApiError } from '../../error.js'; -import { getUser } from '../../common/getters.js'; -import { MessagingMessages, UserGroups, UserGroupJoinings, Users } from '@/models/index.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { Inject, Injectable } from '@nestjs/common'; import { Brackets } from 'typeorm'; -import { readUserMessagingMessage, readGroupMessagingMessage, deliverReadActivity } from '../../common/read-messaging-message.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { UsersRepository } from '@/models/index.js'; +import { UserGroupsRepository, MessagingMessagesRepository, UserGroupJoiningsRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { MessagingMessageEntityService } from '@/core/entities/MessagingMessageEntityService.js'; +import { MessagingService } from '@/core/MessagingService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; +import { GetterService } from '../../common/GetterService.js'; export const meta = { tags: ['messaging'], @@ -69,73 +74,93 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - if (ps.userId != null) { - // Fetch recipient (user) - const recipient = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw e; - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, - const query = makePaginationQuery(MessagingMessages.createQueryBuilder('message'), ps.sinceId, ps.untilId) - .andWhere(new Brackets(qb => { qb - .where(new Brackets(qb => { qb - .where('message.userId = :meId') - .andWhere('message.recipientId = :recipientId'); - })) - .orWhere(new Brackets(qb => { qb - .where('message.userId = :recipientId') - .andWhere('message.recipientId = :meId'); - })); - })) - .setParameter('meId', user.id) - .setParameter('recipientId', recipient.id); + @Inject(DI.userGroupsRepository) + private userGroupRepository: UserGroupsRepository, - const messages = await query.take(ps.limit).getMany(); + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, - // Mark all as read - if (ps.markAsRead) { - readUserMessagingMessage(user.id, recipient.id, messages.filter(m => m.recipientId === user.id).map(x => x.id)); + private messagingMessageEntityService: MessagingMessageEntityService, + private messagingService: MessagingService, + private userEntityService: UserEntityService, + private queryService: QueryService, + private getterService: GetterService, + ) { + super(meta, paramDef, async (ps, me) => { + if (ps.userId != null) { + // Fetch recipient (user) + const recipient = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); - // リモートユーザーとのメッセージだったら既読配信 - if (Users.isLocalUser(user) && Users.isRemoteUser(recipient)) { - deliverReadActivity(user, recipient, messages); + const query = this.queryService.makePaginationQuery(this.messagingMessagesRepository.createQueryBuilder('message'), ps.sinceId, ps.untilId) + .andWhere(new Brackets(qb => { qb + .where(new Brackets(qb => { qb + .where('message.userId = :meId') + .andWhere('message.recipientId = :recipientId'); + })) + .orWhere(new Brackets(qb => { qb + .where('message.userId = :recipientId') + .andWhere('message.recipientId = :meId'); + })); + })) + .setParameter('meId', me.id) + .setParameter('recipientId', recipient.id); + + const messages = await query.take(ps.limit).getMany(); + + // Mark all as read + if (ps.markAsRead) { + this.messagingService.readUserMessagingMessage(me.id, recipient.id, messages.filter(m => m.recipientId === me.id).map(x => x.id)); + + // リモートユーザーとのメッセージだったら既読配信 + if (this.userEntityService.isLocalUser(me) && this.userEntityService.isRemoteUser(recipient)) { + this.messagingService.deliverReadActivity(me, recipient, messages); + } + } + + return await Promise.all(messages.map(message => this.messagingMessageEntityService.pack(message, me, { + populateRecipient: false, + }))); + } else if (ps.groupId != null) { + // Fetch recipient (group) + const recipientGroup = await this.userGroupRepository.findOneBy({ id: ps.groupId }); + + if (recipientGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // check joined + const joining = await this.userGroupJoiningsRepository.findOneBy({ + userId: me.id, + userGroupId: recipientGroup.id, + }); + + if (joining == null) { + throw new ApiError(meta.errors.groupAccessDenied); + } + + const query = this.queryService.makePaginationQuery(this.messagingMessagesRepository.createQueryBuilder('message'), ps.sinceId, ps.untilId) + .andWhere('message.groupId = :groupId', { groupId: recipientGroup.id }); + + const messages = await query.take(ps.limit).getMany(); + + // Mark all as read + if (ps.markAsRead) { + this.messagingService.readGroupMessagingMessage(me.id, recipientGroup.id, messages.map(x => x.id)); + } + + return await Promise.all(messages.map(message => this.messagingMessageEntityService.pack(message, me, { + populateGroup: false, + }))); } - } - - return await Promise.all(messages.map(message => MessagingMessages.pack(message, user, { - populateRecipient: false, - }))); - } else if (ps.groupId != null) { - // Fetch recipient (group) - const recipientGroup = await UserGroups.findOneBy({ id: ps.groupId }); - - if (recipientGroup == null) { - throw new ApiError(meta.errors.noSuchGroup); - } - - // check joined - const joining = await UserGroupJoinings.findOneBy({ - userId: user.id, - userGroupId: recipientGroup.id, }); - - if (joining == null) { - throw new ApiError(meta.errors.groupAccessDenied); - } - - const query = makePaginationQuery(MessagingMessages.createQueryBuilder('message'), ps.sinceId, ps.untilId) - .andWhere(`message.groupId = :groupId`, { groupId: recipientGroup.id }); - - const messages = await query.take(ps.limit).getMany(); - - // Mark all as read - if (ps.markAsRead) { - readGroupMessagingMessage(user.id, recipientGroup.id, messages.map(x => x.id)); - } - - return await Promise.all(messages.map(message => MessagingMessages.pack(message, user, { - populateGroup: false, - }))); } -}); +} diff --git a/packages/backend/src/server/api/endpoints/messaging/messages/create.ts b/packages/backend/src/server/api/endpoints/messaging/messages/create.ts index 405af5ec1..e02afcbcf 100644 --- a/packages/backend/src/server/api/endpoints/messaging/messages/create.ts +++ b/packages/backend/src/server/api/endpoints/messaging/messages/create.ts @@ -1,10 +1,12 @@ -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { BlockingsRepository, UserGroupJoiningsRepository, DriveFilesRepository, UserGroupsRepository } from '@/models/index.js'; +import type { User } from '@/models/entities/User.js'; +import type { UserGroup } from '@/models/entities/UserGroup.js'; +import { GetterService } from '@/server/api/common/GetterService.js'; +import { MessagingService } from '@/core/MessagingService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import { getUser } from '../../../common/getters.js'; -import { MessagingMessages, DriveFiles, UserGroups, UserGroupJoinings, Blockings } from '@/models/index.js'; -import { User } from '@/models/entities/user.js'; -import { UserGroup } from '@/models/entities/user-group.js'; -import { createMessage } from '@/services/messages/create.js'; export const meta = { tags: ['messaging'], @@ -87,65 +89,85 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - let recipientUser: User | null; - let recipientGroup: UserGroup | null; +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userGroupsRepository) + private userGroupsRepository: UserGroupsRepository, - if (ps.userId != null) { - // Myself - if (ps.userId === user.id) { - throw new ApiError(meta.errors.recipientIsYourself); - } + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, - // Fetch recipient (user) - recipientUser = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw e; + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private getterService: GetterService, + private messagingService: MessagingService, + ) { + super(meta, paramDef, async (ps, me) => { + let recipientUser: User | null; + let recipientGroup: UserGroup | null; + + if (ps.userId != null) { + // Myself + if (ps.userId === me.id) { + throw new ApiError(meta.errors.recipientIsYourself); + } + + // Fetch recipient (user) + recipientUser = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + // Check blocking + const block = await this.blockingsRepository.findOneBy({ + blockerId: recipientUser.id, + blockeeId: me.id, + }); + if (block) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } else if (ps.groupId != null) { + // Fetch recipient (group) + recipientGroup = await this.userGroupsRepository.findOneBy({ id: ps.groupId! }); + + if (recipientGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // check joined + const joining = await this.userGroupJoiningsRepository.findOneBy({ + userId: me.id, + userGroupId: recipientGroup.id, + }); + + if (joining == null) { + throw new ApiError(meta.errors.groupAccessDenied); + } + } + + let file = null; + if (ps.fileId != null) { + file = await this.driveFilesRepository.findOneBy({ + id: ps.fileId, + userId: me.id, + }); + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + // テキストが無いかつ添付ファイルも無かったらエラー + if (ps.text == null && file == null) { + throw new ApiError(meta.errors.contentRequired); + } + + return await this.messagingService.createMessage(me, recipientUser, recipientGroup, ps.text, file); }); - - // Check blocking - const block = await Blockings.findOneBy({ - blockerId: recipientUser.id, - blockeeId: user.id, - }); - if (block) { - throw new ApiError(meta.errors.youHaveBeenBlocked); - } - } else if (ps.groupId != null) { - // Fetch recipient (group) - recipientGroup = await UserGroups.findOneBy({ id: ps.groupId! }); - - if (recipientGroup == null) { - throw new ApiError(meta.errors.noSuchGroup); - } - - // check joined - const joining = await UserGroupJoinings.findOneBy({ - userId: user.id, - userGroupId: recipientGroup.id, - }); - - if (joining == null) { - throw new ApiError(meta.errors.groupAccessDenied); - } } - - let file = null; - if (ps.fileId != null) { - file = await DriveFiles.findOneBy({ - id: ps.fileId, - userId: user.id, - }); - - if (file == null) { - throw new ApiError(meta.errors.noSuchFile); - } - } - - // テキストが無いかつ添付ファイルも無かったらエラー - if (ps.text == null && file == null) { - throw new ApiError(meta.errors.contentRequired); - } - - return await createMessage(user, recipientUser, recipientGroup, ps.text, file); -}); +} diff --git a/packages/backend/src/server/api/endpoints/messaging/messages/delete.ts b/packages/backend/src/server/api/endpoints/messaging/messages/delete.ts index f66d75873..5baecb911 100644 --- a/packages/backend/src/server/api/endpoints/messaging/messages/delete.ts +++ b/packages/backend/src/server/api/endpoints/messaging/messages/delete.ts @@ -1,8 +1,10 @@ -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { MessagingMessagesRepository } from '@/models/index.js'; +import { MessagingService } from '@/core/MessagingService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import { MessagingMessages } from '@/models/index.js'; -import { deleteMessage } from '@/services/messages/delete.js'; export const meta = { tags: ['messaging'], @@ -35,15 +37,25 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const message = await MessagingMessages.findOneBy({ - id: ps.messageId, - userId: user.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, - if (message == null) { - throw new ApiError(meta.errors.noSuchMessage); + private messagingService: MessagingService, + ) { + super(meta, paramDef, async (ps, me) => { + const message = await this.messagingMessagesRepository.findOneBy({ + id: ps.messageId, + userId: me.id, + }); + + if (message == null) { + throw new ApiError(meta.errors.noSuchMessage); + } + + await this.messagingService.deleteMessage(message); + }); } - - await deleteMessage(message); -}); +} diff --git a/packages/backend/src/server/api/endpoints/messaging/messages/read.ts b/packages/backend/src/server/api/endpoints/messaging/messages/read.ts index db12ae922..6e66cafe1 100644 --- a/packages/backend/src/server/api/endpoints/messaging/messages/read.ts +++ b/packages/backend/src/server/api/endpoints/messaging/messages/read.ts @@ -1,7 +1,9 @@ -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { MessagingMessagesRepository } from '@/models/index.js'; +import { MessagingService } from '@/core/MessagingService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import { MessagingMessages } from '@/models/index.js'; -import { readUserMessagingMessage, readGroupMessagingMessage } from '../../../common/read-messaging-message.js'; export const meta = { tags: ['messaging'], @@ -28,22 +30,32 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const message = await MessagingMessages.findOneBy({ id: ps.messageId }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, - if (message == null) { - throw new ApiError(meta.errors.noSuchMessage); - } + private messagingService: MessagingService, + ) { + super(meta, paramDef, async (ps, me) => { + const message = await this.messagingMessagesRepository.findOneBy({ id: ps.messageId }); - if (message.recipientId) { - await readUserMessagingMessage(user.id, message.userId, [message.id]).catch(e => { - if (e.id === 'e140a4bf-49ce-4fb6-b67c-b78dadf6b52f') throw new ApiError(meta.errors.noSuchMessage); - throw e; - }); - } else if (message.groupId) { - await readGroupMessagingMessage(user.id, message.groupId, [message.id]).catch(e => { - if (e.id === '930a270c-714a-46b2-b776-ad27276dc569') throw new ApiError(meta.errors.noSuchMessage); - throw e; + if (message == null) { + throw new ApiError(meta.errors.noSuchMessage); + } + + if (message.recipientId) { + await this.messagingService.readUserMessagingMessage(me.id, message.userId, [message.id]).catch(err => { + if (err.id === 'e140a4bf-49ce-4fb6-b67c-b78dadf6b52f') throw new ApiError(meta.errors.noSuchMessage); + throw err; + }); + } else if (message.groupId) { + await this.messagingService.readGroupMessagingMessage(me.id, message.groupId, [message.id]).catch(err => { + if (err.id === '930a270c-714a-46b2-b776-ad27276dc569') throw new ApiError(meta.errors.noSuchMessage); + throw err; + }); + } }); } -}); +} diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts index 5b624842c..9a6258d7d 100644 --- a/packages/backend/src/server/api/endpoints/meta.ts +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -1,10 +1,14 @@ import { IsNull, MoreThan } from 'typeorm'; -import config from '@/config/index.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { Ads, Emojis, Users } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { AdsRepository, EmojisRepository, UsersRepository } from '@/models/index.js'; import { DB_MAX_NOTE_TEXT_LENGTH } from '@/misc/hard-limits.js'; import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; -import define from '../define.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; +import { MetaService } from '@/core/MetaService.js'; +import { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['meta'], @@ -26,7 +30,6 @@ export const meta = { version: { type: 'string', optional: false, nullable: false, - example: config.version, }, name: { type: 'string', @@ -304,111 +307,132 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const instance = await fetchMeta(true); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - const emojis = await Emojis.find({ - where: { - host: IsNull(), - }, - order: { - category: 'ASC', - name: 'ASC', - }, - cache: { - id: 'meta_emojis', - milliseconds: 3600000, // 1 hour - }, - }); + @Inject(DI.adsRepository) + private adsRepository: AdsRepository, - const ads = await Ads.find({ - where: { - expiresAt: MoreThan(new Date()), - }, - }); + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, - const response: any = { - maintainerName: instance.maintainerName, - maintainerEmail: instance.maintainerEmail, + private userEntityService: UserEntityService, + private emojiEntityService: EmojiEntityService, + private metaService: MetaService, + ) { + super(meta, paramDef, async (ps, me) => { + const instance = await this.metaService.fetch(true); - version: config.version, + const emojis = await this.emojisRepository.find({ + where: { + host: IsNull(), + }, + order: { + category: 'ASC', + name: 'ASC', + }, + cache: { + id: 'meta_emojis', + milliseconds: 3600000, // 1 hour + }, + }); - name: instance.name, - uri: config.url, - description: instance.description, - langs: instance.langs, - tosUrl: instance.ToSUrl, - repositoryUrl: instance.repositoryUrl, - feedbackUrl: instance.feedbackUrl, - disableRegistration: instance.disableRegistration, - disableLocalTimeline: instance.disableLocalTimeline, - disableGlobalTimeline: instance.disableGlobalTimeline, - driveCapacityPerLocalUserMb: instance.localDriveCapacityMb, - driveCapacityPerRemoteUserMb: instance.remoteDriveCapacityMb, - emailRequiredForSignup: instance.emailRequiredForSignup, - enableHcaptcha: instance.enableHcaptcha, - hcaptchaSiteKey: instance.hcaptchaSiteKey, - enableRecaptcha: instance.enableRecaptcha, - recaptchaSiteKey: instance.recaptchaSiteKey, - swPublickey: instance.swPublicKey, - themeColor: instance.themeColor, - mascotImageUrl: instance.mascotImageUrl, - bannerUrl: instance.bannerUrl, - errorImageUrl: instance.errorImageUrl, - iconUrl: instance.iconUrl, - backgroundImageUrl: instance.backgroundImageUrl, - logoImageUrl: instance.logoImageUrl, - maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, // 後方互換性のため - emojis: await Emojis.packMany(emojis), - defaultLightTheme: instance.defaultLightTheme, - defaultDarkTheme: instance.defaultDarkTheme, - ads: ads.map(ad => ({ - id: ad.id, - url: ad.url, - place: ad.place, - ratio: ad.ratio, - imageUrl: ad.imageUrl, - })), - enableEmail: instance.enableEmail, + const ads = await this.adsRepository.find({ + where: { + expiresAt: MoreThan(new Date()), + }, + }); - enableTwitterIntegration: instance.enableTwitterIntegration, - enableGithubIntegration: instance.enableGithubIntegration, - enableDiscordIntegration: instance.enableDiscordIntegration, + const response: any = { + maintainerName: instance.maintainerName, + maintainerEmail: instance.maintainerEmail, - enableServiceWorker: instance.enableServiceWorker, + version: this.config.version, - translatorAvailable: instance.deeplAuthKey != null, + name: instance.name, + uri: this.config.url, + description: instance.description, + langs: instance.langs, + tosUrl: instance.ToSUrl, + repositoryUrl: instance.repositoryUrl, + feedbackUrl: instance.feedbackUrl, + disableRegistration: instance.disableRegistration, + disableLocalTimeline: instance.disableLocalTimeline, + disableGlobalTimeline: instance.disableGlobalTimeline, + driveCapacityPerLocalUserMb: instance.localDriveCapacityMb, + driveCapacityPerRemoteUserMb: instance.remoteDriveCapacityMb, + emailRequiredForSignup: instance.emailRequiredForSignup, + enableHcaptcha: instance.enableHcaptcha, + hcaptchaSiteKey: instance.hcaptchaSiteKey, + enableRecaptcha: instance.enableRecaptcha, + recaptchaSiteKey: instance.recaptchaSiteKey, + swPublickey: instance.swPublicKey, + themeColor: instance.themeColor, + mascotImageUrl: instance.mascotImageUrl, + bannerUrl: instance.bannerUrl, + errorImageUrl: instance.errorImageUrl, + iconUrl: instance.iconUrl, + backgroundImageUrl: instance.backgroundImageUrl, + logoImageUrl: instance.logoImageUrl, + maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, // 後方互換性のため + emojis: await this.emojiEntityService.packMany(emojis), + defaultLightTheme: instance.defaultLightTheme, + defaultDarkTheme: instance.defaultDarkTheme, + ads: ads.map(ad => ({ + id: ad.id, + url: ad.url, + place: ad.place, + ratio: ad.ratio, + imageUrl: ad.imageUrl, + })), + enableEmail: instance.enableEmail, - ...(ps.detail ? { - pinnedPages: instance.pinnedPages, - pinnedClipId: instance.pinnedClipId, - cacheRemoteFiles: instance.cacheRemoteFiles, - requireSetup: (await Users.countBy({ - host: IsNull(), - })) === 0, - } : {}), - }; + enableTwitterIntegration: instance.enableTwitterIntegration, + enableGithubIntegration: instance.enableGithubIntegration, + enableDiscordIntegration: instance.enableDiscordIntegration, - if (ps.detail) { - const proxyAccount = instance.proxyAccountId ? await Users.pack(instance.proxyAccountId).catch(() => null) : null; + enableServiceWorker: instance.enableServiceWorker, - response.proxyAccountName = proxyAccount ? proxyAccount.username : null; - response.features = { - registration: !instance.disableRegistration, - localTimeLine: !instance.disableLocalTimeline, - globalTimeLine: !instance.disableGlobalTimeline, - emailRequiredForSignup: instance.emailRequiredForSignup, - elasticsearch: config.elasticsearch ? true : false, - hcaptcha: instance.enableHcaptcha, - recaptcha: instance.enableRecaptcha, - objectStorage: instance.useObjectStorage, - twitter: instance.enableTwitterIntegration, - github: instance.enableGithubIntegration, - discord: instance.enableDiscordIntegration, - serviceWorker: instance.enableServiceWorker, - miauth: true, - }; + translatorAvailable: instance.deeplAuthKey != null, + + ...(ps.detail ? { + pinnedPages: instance.pinnedPages, + pinnedClipId: instance.pinnedClipId, + cacheRemoteFiles: instance.cacheRemoteFiles, + requireSetup: (await this.usersRepository.countBy({ + host: IsNull(), + })) === 0, + } : {}), + }; + + if (ps.detail) { + const proxyAccount = instance.proxyAccountId ? await this.userEntityService.pack(instance.proxyAccountId).catch(() => null) : null; + + response.proxyAccountName = proxyAccount ? proxyAccount.username : null; + response.features = { + registration: !instance.disableRegistration, + localTimeLine: !instance.disableLocalTimeline, + globalTimeLine: !instance.disableGlobalTimeline, + emailRequiredForSignup: instance.emailRequiredForSignup, + elasticsearch: this.config.elasticsearch ? true : false, + hcaptcha: instance.enableHcaptcha, + recaptcha: instance.enableRecaptcha, + objectStorage: instance.useObjectStorage, + twitter: instance.enableTwitterIntegration, + github: instance.enableGithubIntegration, + discord: instance.enableDiscordIntegration, + serviceWorker: instance.enableServiceWorker, + miauth: true, + }; + } + + return response; + }); } - - return response; -}); +} diff --git a/packages/backend/src/server/api/endpoints/miauth/gen-token.ts b/packages/backend/src/server/api/endpoints/miauth/gen-token.ts index 73ecdaeb0..d8eb89c0e 100644 --- a/packages/backend/src/server/api/endpoints/miauth/gen-token.ts +++ b/packages/backend/src/server/api/endpoints/miauth/gen-token.ts @@ -1,7 +1,9 @@ -import define from '../../define.js'; -import { AccessTokens } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AccessTokensRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['auth'], @@ -37,28 +39,38 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - // Generate access token - const accessToken = secureRndstr(32, true); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.accessTokensRepository) + private accessTokensRepository: AccessTokensRepository, - const now = new Date(); + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + // Generate access token + const accessToken = secureRndstr(32, true); - // Insert access token doc - await AccessTokens.insert({ - id: genId(), - createdAt: now, - lastUsedAt: now, - session: ps.session, - userId: user.id, - token: accessToken, - hash: accessToken, - name: ps.name, - description: ps.description, - iconUrl: ps.iconUrl, - permission: ps.permission, - }); + const now = new Date(); - return { - token: accessToken, - }; -}); + // Insert access token doc + await this.accessTokensRepository.insert({ + id: this.idService.genId(), + createdAt: now, + lastUsedAt: now, + session: ps.session, + userId: me.id, + token: accessToken, + hash: accessToken, + name: ps.name, + description: ps.description, + iconUrl: ps.iconUrl, + permission: ps.permission, + }); + + return { + token: accessToken, + }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/mute/create.ts b/packages/backend/src/server/api/endpoints/mute/create.ts index 7e857e673..cbdd00118 100644 --- a/packages/backend/src/server/api/endpoints/mute/create.ts +++ b/packages/backend/src/server/api/endpoints/mute/create.ts @@ -1,10 +1,12 @@ -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { IdService } from '@/core/IdService.js'; +import { MutingsRepository } from '@/models/index.js'; +import type { Muting } from '@/models/entities/Muting.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { getUser } from '../../common/getters.js'; -import { genId } from '@/misc/gen-id.js'; -import { Mutings, NoteWatchings } from '@/models/index.js'; -import { Muting } from '@/models/entities/muting.js'; -import { publishUserEvent } from '@/services/stream.js'; +import { GetterService } from '../../common/GetterService.js'; export const meta = { tags: ['account'], @@ -48,47 +50,54 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const muter = user; +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, - // 自分自身 - if (user.id === ps.userId) { - throw new ApiError(meta.errors.muteeIsYourself); + private globalEventService: GlobalEventService, + private getterService: GetterService, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const muter = me; + + // 自分自身 + if (me.id === ps.userId) { + throw new ApiError(meta.errors.muteeIsYourself); + } + + // Get mutee + const mutee = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + // Check if already muting + const exist = await this.mutingsRepository.findOneBy({ + muterId: muter.id, + muteeId: mutee.id, + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyMuting); + } + + if (ps.expiresAt && ps.expiresAt <= Date.now()) { + return; + } + + // Create mute + await this.mutingsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null, + muterId: muter.id, + muteeId: mutee.id, + } as Muting); + + this.globalEventService.publishUserEvent(me.id, 'mute', mutee); + }); } - - // Get mutee - const mutee = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw e; - }); - - // Check if already muting - const exist = await Mutings.findOneBy({ - muterId: muter.id, - muteeId: mutee.id, - }); - - if (exist != null) { - throw new ApiError(meta.errors.alreadyMuting); - } - - if (ps.expiresAt && ps.expiresAt <= Date.now()) { - return; - } - - // Create mute - await Mutings.insert({ - id: genId(), - createdAt: new Date(), - expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null, - muterId: muter.id, - muteeId: mutee.id, - } as Muting); - - publishUserEvent(user.id, 'mute', mutee); - - NoteWatchings.delete({ - userId: muter.id, - noteUserId: mutee.id, - }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/mute/delete.ts b/packages/backend/src/server/api/endpoints/mute/delete.ts index 0b173dbe2..c7098059d 100644 --- a/packages/backend/src/server/api/endpoints/mute/delete.ts +++ b/packages/backend/src/server/api/endpoints/mute/delete.ts @@ -1,8 +1,10 @@ -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { MutingsRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { getUser } from '../../common/getters.js'; -import { Mutings } from '@/models/index.js'; -import { publishUserEvent } from '@/services/stream.js'; +import { GetterService } from '../../common/GetterService.js'; export const meta = { tags: ['account'], @@ -41,34 +43,45 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const muter = user; +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, - // Check if the mutee is yourself - if (user.id === ps.userId) { - throw new ApiError(meta.errors.muteeIsYourself); + private globalEventService: GlobalEventService, + private getterService: GetterService, + ) { + super(meta, paramDef, async (ps, me) => { + const muter = me; + + // Check if the mutee is yourself + if (me.id === ps.userId) { + throw new ApiError(meta.errors.muteeIsYourself); + } + + // Get mutee + const mutee = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + // Check not muting + const exist = await this.mutingsRepository.findOneBy({ + muterId: muter.id, + muteeId: mutee.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notMuting); + } + + // Delete mute + await this.mutingsRepository.delete({ + id: exist.id, + }); + + this.globalEventService.publishUserEvent(me.id, 'unmute', mutee); + }); } - - // Get mutee - const mutee = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw e; - }); - - // Check not muting - const exist = await Mutings.findOneBy({ - muterId: muter.id, - muteeId: mutee.id, - }); - - if (exist == null) { - throw new ApiError(meta.errors.notMuting); - } - - // Delete mute - await Mutings.delete({ - id: exist.id, - }); - - publishUserEvent(user.id, 'unmute', mutee); -}); +} diff --git a/packages/backend/src/server/api/endpoints/mute/list.ts b/packages/backend/src/server/api/endpoints/mute/list.ts index 31283cf4c..11c05eb79 100644 --- a/packages/backend/src/server/api/endpoints/mute/list.ts +++ b/packages/backend/src/server/api/endpoints/mute/list.ts @@ -1,6 +1,9 @@ -import define from '../../define.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; -import { Mutings } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { MutingsRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { MutingEntityService } from '@/core/entities/MutingEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['account'], @@ -31,13 +34,24 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const query = makePaginationQuery(Mutings.createQueryBuilder('muting'), ps.sinceId, ps.untilId) - .andWhere(`muting.muterId = :meId`, { meId: me.id }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, - const mutings = await query - .take(ps.limit) - .getMany(); + private mutingEntityService: MutingEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.mutingsRepository.createQueryBuilder('muting'), ps.sinceId, ps.untilId) + .andWhere('muting.muterId = :meId', { meId: me.id }); - return await Mutings.packMany(mutings, me); -}); + const mutings = await query + .take(ps.limit) + .getMany(); + + return await this.mutingEntityService.packMany(mutings, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/my/apps.ts b/packages/backend/src/server/api/endpoints/my/apps.ts index 85b75c15d..90cd53a13 100644 --- a/packages/backend/src/server/api/endpoints/my/apps.ts +++ b/packages/backend/src/server/api/endpoints/my/apps.ts @@ -1,5 +1,8 @@ -import define from '../../define.js'; -import { Apps } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AppsRepository } from '@/models/index.js'; +import { AppEntityService } from '@/core/entities/AppEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['account', 'app'], @@ -27,18 +30,28 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const query = { - userId: user.id, - }; +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.appsRepository) + private appsRepository: AppsRepository, - const apps = await Apps.find({ - where: query, - take: ps.limit, - skip: ps.offset, - }); + private appEntityService: AppEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = { + userId: me.id, + }; - return await Promise.all(apps.map(app => Apps.pack(app, user, { - detail: true, - }))); -}); + const apps = await this.appsRepository.find({ + where: query, + take: ps.limit, + skip: ps.offset, + }); + + return await Promise.all(apps.map(app => this.appEntityService.pack(app, me, { + detail: true, + }))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes.ts b/packages/backend/src/server/api/endpoints/notes.ts index 015b0338e..288e19531 100644 --- a/packages/backend/src/server/api/endpoints/notes.ts +++ b/packages/backend/src/server/api/endpoints/notes.ts @@ -1,6 +1,9 @@ -import { Notes } from '@/models/index.js'; -import define from '../define.js'; -import { makePaginationQuery } from '../common/make-pagination-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { NotesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['notes'], @@ -32,48 +35,59 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .andWhere('note.visibility = \'public\'') - .andWhere('note.localOnly = FALSE') - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, - if (ps.local) { - query.andWhere('note.userHost IS NULL'); + private noteEntityService: NoteEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere('note.visibility = \'public\'') + .andWhere('note.localOnly = FALSE') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + + if (ps.local) { + query.andWhere('note.userHost IS NULL'); + } + + if (ps.reply !== undefined) { + query.andWhere(ps.reply ? 'note.replyId IS NOT NULL' : 'note.replyId IS NULL'); + } + + if (ps.renote !== undefined) { + query.andWhere(ps.renote ? 'note.renoteId IS NOT NULL' : 'note.renoteId IS NULL'); + } + + if (ps.withFiles !== undefined) { + query.andWhere(ps.withFiles ? 'note.fileIds != \'{}\'' : 'note.fileIds = \'{}\''); + } + + if (ps.poll !== undefined) { + query.andWhere(ps.poll ? 'note.hasPoll = TRUE' : 'note.hasPoll = FALSE'); + } + + // TODO + //if (bot != undefined) { + // query.isBot = bot; + //} + + const notes = await query.take(ps.limit).getMany(); + + return await this.noteEntityService.packMany(notes); + }); } - - if (ps.reply !== undefined) { - query.andWhere(ps.reply ? 'note.replyId IS NOT NULL' : 'note.replyId IS NULL'); - } - - if (ps.renote !== undefined) { - query.andWhere(ps.renote ? 'note.renoteId IS NOT NULL' : 'note.renoteId IS NULL'); - } - - if (ps.withFiles !== undefined) { - query.andWhere(ps.withFiles ? 'note.fileIds != \'{}\'' : 'note.fileIds = \'{}\''); - } - - if (ps.poll !== undefined) { - query.andWhere(ps.poll ? 'note.hasPoll = TRUE' : 'note.hasPoll = FALSE'); - } - - // TODO - //if (bot != undefined) { - // query.isBot = bot; - //} - - const notes = await query.take(ps.limit).getMany(); - - return await Notes.packMany(notes); -}); +} diff --git a/packages/backend/src/server/api/endpoints/notes/children.ts b/packages/backend/src/server/api/endpoints/notes/children.ts index efc109105..86f90e049 100644 --- a/packages/backend/src/server/api/endpoints/notes/children.ts +++ b/packages/backend/src/server/api/endpoints/notes/children.ts @@ -1,10 +1,10 @@ import { Brackets } from 'typeorm'; -import { Notes } from '@/models/index.js'; -import define from '../../define.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; -import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; -import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; -import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { NotesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['notes'], @@ -34,38 +34,49 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .andWhere(new Brackets(qb => { qb - .where('note.replyId = :noteId', { noteId: ps.noteId }) - .orWhere(new Brackets(qb => { qb - .where('note.renoteId = :noteId', { noteId: ps.noteId }) +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private noteEntityService: NoteEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) .andWhere(new Brackets(qb => { qb - .where('note.text IS NOT NULL') - .orWhere('note.fileIds != \'{}\'') - .orWhere('note.hasPoll = TRUE'); - })); - })); - })) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .where('note.replyId = :noteId', { noteId: ps.noteId }) + .orWhere(new Brackets(qb => { qb + .where('note.renoteId = :noteId', { noteId: ps.noteId }) + .andWhere(new Brackets(qb => { qb + .where('note.text IS NOT NULL') + .orWhere('note.fileIds != \'{}\'') + .orWhere('note.hasPoll = TRUE'); + })); + })); + })) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); - generateVisibilityQuery(query, user); - if (user) { - generateMutedUserQuery(query, user); - generateBlockedUserQuery(query, user); + this.queryService.generateVisibilityQuery(query, me); + if (me) { + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + } + + const notes = await query.take(ps.limit).getMany(); + + return await this.noteEntityService.packMany(notes, me); + }); } - - const notes = await query.take(ps.limit).getMany(); - - return await Notes.packMany(notes, user); -}); +} diff --git a/packages/backend/src/server/api/endpoints/notes/clips.ts b/packages/backend/src/server/api/endpoints/notes/clips.ts index e79f8563e..7d893f32a 100644 --- a/packages/backend/src/server/api/endpoints/notes/clips.ts +++ b/packages/backend/src/server/api/endpoints/notes/clips.ts @@ -1,8 +1,11 @@ import { In } from 'typeorm'; -import { ClipNotes, Clips } from '@/models/index.js'; -import define from '../../define.js'; -import { getNote } from '../../common/getters.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { ClipNotesRepository, ClipsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; +import { GetterService } from '../../common/GetterService.js'; export const meta = { tags: ['clips', 'notes'], @@ -37,20 +40,34 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const note = await getNote(ps.noteId).catch(e => { - if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw e; - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.clipsRepository) + private clipsRepository: ClipsRepository, - const clipNotes = await ClipNotes.findBy({ - noteId: note.id, - }); + @Inject(DI.clipNotesRepository) + private clipNotesRepository: ClipNotesRepository, - const clips = await Clips.findBy({ - id: In(clipNotes.map(x => x.clipId)), - isPublic: true, - }); + private clipEntityService: ClipEntityService, + private getterService: GetterService, + ) { + 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; + }); - return await Promise.all(clips.map(x => Clips.pack(x))); -}); + const clipNotes = await this.clipNotesRepository.findBy({ + noteId: note.id, + }); + + const clips = await this.clipsRepository.findBy({ + id: In(clipNotes.map(x => x.clipId)), + isPublic: true, + }); + + return await Promise.all(clips.map(x => this.clipEntityService.pack(x))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/conversation.ts b/packages/backend/src/server/api/endpoints/notes/conversation.ts index b731d1824..2f8324ed6 100644 --- a/packages/backend/src/server/api/endpoints/notes/conversation.ts +++ b/packages/backend/src/server/api/endpoints/notes/conversation.ts @@ -1,8 +1,11 @@ -import { Note } from '@/models/entities/note.js'; -import { Notes } from '@/models/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import type { Note } from '@/models/entities/Note.js'; +import { NotesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { getNote } from '../../common/getters.js'; +import { GetterService } from '../../common/GetterService.js'; export const meta = { tags: ['notes'], @@ -39,36 +42,47 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const note = await getNote(ps.noteId).catch(e => { - if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw e; - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, - const conversation: Note[] = []; - let i = 0; + private noteEntityService: NoteEntityService, + private getterService: GetterService, + ) { + 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; + }); - async function get(id: any) { - i++; - const p = await Notes.findOneBy({ id }); - if (p == null) return; + const conversation: Note[] = []; + let i = 0; - if (i > ps.offset!) { - conversation.push(p); - } + const get = async (id: any) => { + i++; + const p = await this.notesRepository.findOneBy({ id }); + if (p == null) return; - if (conversation.length === ps.limit) { - return; - } + if (i > ps.offset!) { + conversation.push(p); + } - if (p.replyId) { - await get(p.replyId); - } + if (conversation.length === ps.limit) { + return; + } + + if (p.replyId) { + await get(p.replyId); + } + }; + + if (note.replyId) { + await get(note.replyId); + } + + return await this.noteEntityService.packMany(conversation, me); + }); } - - if (note.replyId) { - await get(note.replyId); - } - - return await Notes.packMany(conversation, user); -}); +} diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index a13329416..30b7a889f 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -1,15 +1,18 @@ import ms from 'ms'; import { In } from 'typeorm'; -import create from '@/services/note/create.js'; -import { User } from '@/models/entities/user.js'; -import { Users, DriveFiles, Notes, Channels, Blockings } from '@/models/index.js'; -import { DriveFile } from '@/models/entities/drive-file.js'; -import { Note } from '@/models/entities/note.js'; -import { Channel } from '@/models/entities/channel.js'; +import { Inject, Injectable } from '@nestjs/common'; +import type { User } from '@/models/entities/User.js'; +import { UsersRepository, NotesRepository, BlockingsRepository, DriveFilesRepository, ChannelsRepository } from '@/models/index.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import type { Note } from '@/models/entities/Note.js'; +import type { Channel } from '@/models/entities/Channel.js'; 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 { DI } from '@/di-symbols.js'; import { noteVisibilities } from '../../../../types.js'; import { ApiError } from '../../error.js'; -import define from '../../define.js'; export const meta = { tags: ['notes'], @@ -161,115 +164,138 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - let visibleUsers: User[] = []; - if (ps.visibleUserIds) { - visibleUsers = await Users.findBy({ - id: In(ps.visibleUserIds), +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + private noteEntityService: NoteEntityService, + private noteCreateService: NoteCreateService, + ) { + super(meta, paramDef, async (ps, me) => { + let visibleUsers: User[] = []; + if (ps.visibleUserIds) { + visibleUsers = await this.usersRepository.findBy({ + id: In(ps.visibleUserIds), + }); + } + + let files: DriveFile[] = []; + const fileIds = ps.fileIds != null ? ps.fileIds : ps.mediaIds != null ? 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(); + } + + let renote: Note | null = null; + if (ps.renoteId != null) { + // Fetch renote to note + renote = await this.notesRepository.findOneBy({ id: ps.renoteId }); + + if (renote == null) { + throw new ApiError(meta.errors.noSuchRenoteTarget); + } else if (renote.renoteId && !renote.text && !renote.fileIds && !renote.hasPoll) { + throw new ApiError(meta.errors.cannotReRenote); + } + + // Check blocking + if (renote.userId !== me.id) { + const block = await this.blockingsRepository.findOneBy({ + blockerId: renote.userId, + blockeeId: me.id, + }); + if (block) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } + } + + let reply: Note | null = null; + if (ps.replyId != null) { + // Fetch reply + reply = await this.notesRepository.findOneBy({ id: ps.replyId }); + + if (reply == null) { + throw new ApiError(meta.errors.noSuchReplyTarget); + } else if (reply.renoteId && !reply.text && !reply.fileIds && !reply.hasPoll) { + throw new ApiError(meta.errors.cannotReplyToPureRenote); + } + + // Check blocking + if (reply.userId !== me.id) { + const block = await this.blockingsRepository.findOneBy({ + blockerId: reply.userId, + blockeeId: me.id, + }); + if (block) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } + } + + 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; + } + } + + let channel: Channel | null = null; + if (ps.channelId != null) { + channel = await this.channelsRepository.findOneBy({ id: ps.channelId }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + } + + // 投稿を作成 + const note = await this.noteCreateService.create(me, { + createdAt: new Date(), + files: files, + poll: ps.poll ? { + choices: ps.poll.choices, + multiple: ps.poll.multiple || false, + expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, + } : undefined, + text: ps.text ?? undefined, + reply, + renote, + cw: ps.cw, + localOnly: ps.localOnly, + visibility: ps.visibility, + visibleUsers, + channel, + apMentions: ps.noExtractMentions ? [] : undefined, + apHashtags: ps.noExtractHashtags ? [] : undefined, + apEmojis: ps.noExtractEmojis ? [] : undefined, + }); + + return { + createdNote: await this.noteEntityService.pack(note, me), + }; }); } - - let files: DriveFile[] = []; - const fileIds = ps.fileIds != null ? ps.fileIds : ps.mediaIds != null ? ps.mediaIds : null; - if (fileIds != null) { - files = await DriveFiles.createQueryBuilder('file') - .where('file.userId = :userId AND file.id IN (:...fileIds)', { - userId: user.id, - fileIds, - }) - .orderBy('array_position(ARRAY[:...fileIds], "id"::text)') - .setParameters({ fileIds }) - .getMany(); - } - - let renote: Note | null = null; - if (ps.renoteId != null) { - // Fetch renote to note - renote = await Notes.findOneBy({ id: ps.renoteId }); - - if (renote == null) { - throw new ApiError(meta.errors.noSuchRenoteTarget); - } else if (renote.renoteId && !renote.text && !renote.fileIds && !renote.hasPoll) { - throw new ApiError(meta.errors.cannotReRenote); - } - - // Check blocking - if (renote.userId !== user.id) { - const block = await Blockings.findOneBy({ - blockerId: renote.userId, - blockeeId: user.id, - }); - if (block) { - throw new ApiError(meta.errors.youHaveBeenBlocked); - } - } - } - - let reply: Note | null = null; - if (ps.replyId != null) { - // Fetch reply - reply = await Notes.findOneBy({ id: ps.replyId }); - - if (reply == null) { - throw new ApiError(meta.errors.noSuchReplyTarget); - } else if (reply.renoteId && !reply.text && !reply.fileIds && !reply.hasPoll) { - throw new ApiError(meta.errors.cannotReplyToPureRenote); - } - - // Check blocking - if (reply.userId !== user.id) { - const block = await Blockings.findOneBy({ - blockerId: reply.userId, - blockeeId: user.id, - }); - if (block) { - throw new ApiError(meta.errors.youHaveBeenBlocked); - } - } - } - - 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; - } - } - - let channel: Channel | null = null; - if (ps.channelId != null) { - channel = await Channels.findOneBy({ id: ps.channelId }); - - if (channel == null) { - throw new ApiError(meta.errors.noSuchChannel); - } - } - - // 投稿を作成 - const note = await create(user, { - createdAt: new Date(), - files: files, - poll: ps.poll ? { - choices: ps.poll.choices, - multiple: ps.poll.multiple || false, - expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, - } : undefined, - text: ps.text || undefined, - reply, - renote, - cw: ps.cw, - localOnly: ps.localOnly, - visibility: ps.visibility, - visibleUsers, - channel, - apMentions: ps.noExtractMentions ? [] : undefined, - apHashtags: ps.noExtractHashtags ? [] : undefined, - apEmojis: ps.noExtractEmojis ? [] : undefined, - }); - - return { - createdNote: await Notes.pack(note, user), - }; -}); +} diff --git a/packages/backend/src/server/api/endpoints/notes/delete.ts b/packages/backend/src/server/api/endpoints/notes/delete.ts index c23ceeb5b..4769c8bdf 100644 --- a/packages/backend/src/server/api/endpoints/notes/delete.ts +++ b/packages/backend/src/server/api/endpoints/notes/delete.ts @@ -1,9 +1,11 @@ import ms from 'ms'; -import deleteNote from '@/services/note/delete.js'; -import { Users } from '@/models/index.js'; -import define from '../../define.js'; -import { getNote } from '../../common/getters.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UsersRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { NoteDeleteService } from '@/core/NoteDeleteService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; +import { GetterService } from '../../common/GetterService.js'; export const meta = { tags: ['notes'], @@ -42,16 +44,27 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const note = await getNote(ps.noteId).catch(e => { - if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw e; - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - if ((!user.isAdmin && !user.isModerator) && (note.userId !== user.id)) { - throw new ApiError(meta.errors.accessDenied); + private getterService: GetterService, + private noteDeleteService: NoteDeleteService, + ) { + 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 ((!me.isAdmin && !me.isModerator) && (note.userId !== me.id)) { + throw new ApiError(meta.errors.accessDenied); + } + + // この操作を行うのが投稿者とは限らない(例えばモデレーター)ため + await this.noteDeleteService.delete(await this.usersRepository.findOneByOrFail({ id: note.userId }), note); + }); } - - // この操作を行うのが投稿者とは限らない(例えばモデレーター)ため - await deleteNote(await Users.findOneByOrFail({ id: note.userId }), note); -}); +} diff --git a/packages/backend/src/server/api/endpoints/notes/favorites/create.ts b/packages/backend/src/server/api/endpoints/notes/favorites/create.ts index 097371a42..bfdd1acd2 100644 --- a/packages/backend/src/server/api/endpoints/notes/favorites/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/favorites/create.ts @@ -1,8 +1,10 @@ -import { NoteFavorites } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { NoteFavoritesRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GetterService } from '@/server/api/common/GetterService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import { getNote } from '../../../common/getters.js'; export const meta = { tags: ['notes', 'favorites'], @@ -35,28 +37,39 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - // Get favoritee - const note = await getNote(ps.noteId).catch(e => { - if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw e; - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.noteFavoritesRepository) + private noteFavoritesRepository: NoteFavoritesRepository, - // if already favorited - const exist = await NoteFavorites.findOneBy({ - noteId: note.id, - userId: user.id, - }); + private idService: IdService, + private getterService: GetterService, + ) { + super(meta, paramDef, async (ps, me) => { + // Get favoritee + 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 (exist != null) { - throw new ApiError(meta.errors.alreadyFavorited); + // if already favorited + const exist = await this.noteFavoritesRepository.findOneBy({ + noteId: note.id, + userId: me.id, + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyFavorited); + } + + // Create favorite + await this.noteFavoritesRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + noteId: note.id, + userId: me.id, + }); + }); } - - // Create favorite - await NoteFavorites.insert({ - id: genId(), - createdAt: new Date(), - noteId: note.id, - userId: user.id, - }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/notes/favorites/delete.ts b/packages/backend/src/server/api/endpoints/notes/favorites/delete.ts index 82ef4fa19..6b3a02b10 100644 --- a/packages/backend/src/server/api/endpoints/notes/favorites/delete.ts +++ b/packages/backend/src/server/api/endpoints/notes/favorites/delete.ts @@ -1,7 +1,9 @@ -import { NoteFavorites } from '@/models/index.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GetterService } from '@/server/api/common/GetterService.js'; +import { DI } from '@/di-symbols.js'; +import { NoteFavoritesRepository } from '@/models/index.js'; import { ApiError } from '../../../error.js'; -import { getNote } from '../../../common/getters.js'; export const meta = { tags: ['notes', 'favorites'], @@ -34,23 +36,33 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - // Get favoritee - const note = await getNote(ps.noteId).catch(e => { - if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw e; - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.noteFavoritesRepository) + private noteFavoritesRepository: NoteFavoritesRepository, - // if already favorited - const exist = await NoteFavorites.findOneBy({ - noteId: note.id, - userId: user.id, - }); + private getterService: GetterService, + ) { + super(meta, paramDef, async (ps, me) => { + // Get favoritee + 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 (exist == null) { - throw new ApiError(meta.errors.notFavorited); + // if already favorited + const exist = await this.noteFavoritesRepository.findOneBy({ + noteId: note.id, + userId: me.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notFavorited); + } + + // Delete favorite + await this.noteFavoritesRepository.delete(exist.id); + }); } - - // Delete favorite - await NoteFavorites.delete(exist.id); -}); +} diff --git a/packages/backend/src/server/api/endpoints/notes/featured.ts b/packages/backend/src/server/api/endpoints/notes/featured.ts index dd9cc581a..9985f9d25 100644 --- a/packages/backend/src/server/api/endpoints/notes/featured.ts +++ b/packages/backend/src/server/api/endpoints/notes/featured.ts @@ -1,7 +1,9 @@ -import { Notes } from '@/models/index.js'; -import define from '../../define.js'; -import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; -import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { NotesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['notes'], @@ -29,39 +31,50 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const max = 30; - const day = 1000 * 60 * 60 * 24 * 3; // 3日前まで +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, - const query = Notes.createQueryBuilder('note') - .addSelect('note.score') - .where('note.userHost IS NULL') - .andWhere('note.score > 0') - .andWhere('note.createdAt > :date', { date: new Date(Date.now() - day) }) - .andWhere('note.visibility = \'public\'') - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + private noteEntityService: NoteEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const max = 30; + const day = 1000 * 60 * 60 * 24 * 3; // 3日前まで - if (user) generateMutedUserQuery(query, user); - if (user) generateBlockedUserQuery(query, user); + const query = this.notesRepository.createQueryBuilder('note') + .addSelect('note.score') + .where('note.userHost IS NULL') + .andWhere('note.score > 0') + .andWhere('note.createdAt > :date', { date: new Date(Date.now() - day) }) + .andWhere('note.visibility = \'public\'') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); - let notes = await query - .orderBy('note.score', 'DESC') - .take(max) - .getMany(); + if (me) this.queryService.generateMutedUserQuery(query, me); + if (me) this.queryService.generateBlockedUserQuery(query, me); - notes.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + let notes = await query + .orderBy('note.score', 'DESC') + .take(max) + .getMany(); - notes = notes.slice(ps.offset, ps.offset + ps.limit); + notes.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - return await Notes.packMany(notes, user); -}); + notes = notes.slice(ps.offset, ps.offset + ps.limit); + + return await this.noteEntityService.packMany(notes, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts index 925318f54..73b5afa40 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -1,13 +1,12 @@ -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { Notes } from '@/models/index.js'; -import { activeUsersChart } from '@/services/chart/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { NotesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { MetaService } from '@/core/MetaService.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; -import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; -import { generateRepliesQuery } from '../../common/generate-replies-query.js'; -import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js'; -import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; export const meta = { tags: ['notes'], @@ -49,50 +48,63 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const m = await fetchMeta(); - if (m.disableGlobalTimeline) { - if (user == null || (!user.isAdmin && !user.isModerator)) { - throw new ApiError(meta.errors.gtlDisabled); - } +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private noteEntityService: NoteEntityService, + private queryService: QueryService, + private metaService: MetaService, + private activeUsersChart: ActiveUsersChart, + ) { + super(meta, paramDef, async (ps, me) => { + const m = await this.metaService.fetch(); + if (m.disableGlobalTimeline) { + if (me == null || (!me.isAdmin && !me.isModerator)) { + throw new ApiError(meta.errors.gtlDisabled); + } + } + + //#region Construct query + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('note.visibility = \'public\'') + .andWhere('note.channelId IS NULL') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + + this.queryService.generateRepliesQuery(query, me); + if (me) { + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateMutedNoteQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + //#endregion + + const timeline = await query.take(ps.limit).getMany(); + + process.nextTick(() => { + if (me) { + this.activeUsersChart.read(me); + } + }); + + return await this.noteEntityService.packMany(timeline, me); + }); } - - //#region Construct query - const query = makePaginationQuery(Notes.createQueryBuilder('note'), - ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('note.visibility = \'public\'') - .andWhere('note.channelId IS NULL') - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); - - generateRepliesQuery(query, user); - if (user) { - generateMutedUserQuery(query, user); - generateMutedNoteQuery(query, user); - generateBlockedUserQuery(query, user); - } - - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } - //#endregion - - const timeline = await query.take(ps.limit).getMany(); - - process.nextTick(() => { - if (user) { - activeUsersChart.read(user); - } - }); - - return await Notes.packMany(timeline, user); -}); +} diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 2dc98c4c9..c6458223e 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -1,16 +1,13 @@ import { Brackets } from 'typeorm'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { Followings, Notes } from '@/models/index.js'; -import { activeUsersChart } from '@/services/chart/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { NotesRepository, FollowingsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; +import { MetaService } from '@/core/MetaService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; -import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; -import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; -import { generateRepliesQuery } from '../../common/generate-replies-query.js'; -import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js'; -import { generateChannelQuery } from '../../common/generate-channel-query.js'; -import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; export const meta = { tags: ['notes'], @@ -57,83 +54,99 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const m = await fetchMeta(); - if (m.disableLocalTimeline && (!user.isAdmin && !user.isModerator)) { - throw new ApiError(meta.errors.stlDisabled); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + private noteEntityService: NoteEntityService, + private queryService: QueryService, + private metaService: MetaService, + private activeUsersChart: ActiveUsersChart, + ) { + super(meta, paramDef, async (ps, me) => { + const m = await this.metaService.fetch(); + if (m.disableLocalTimeline && (!me.isAdmin && !me.isModerator)) { + throw new ApiError(meta.errors.stlDisabled); + } + + //#region Construct query + const followingQuery = this.followingsRepository.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: me.id }); + + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere(new Brackets(qb => { + qb.where(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: me.id }) + .orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); + })) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') + .setParameters(followingQuery.getParameters()); + + this.queryService.generateChannelQuery(query, me); + this.queryService.generateRepliesQuery(query, me); + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateMutedNoteQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + + if (ps.includeMyRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.userId != :meId', { meId: me.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeRenotedMyNotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeLocalRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserHost IS NOT NULL'); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + //#endregion + + const timeline = await query.take(ps.limit).getMany(); + + process.nextTick(() => { + this.activeUsersChart.read(me); + }); + + return await this.noteEntityService.packMany(timeline, me); + }); } - - //#region Construct query - const followingQuery = Followings.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: user.id }); - - const query = makePaginationQuery(Notes.createQueryBuilder('note'), - ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere(new Brackets(qb => { - qb.where(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: user.id }) - .orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); - })) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') - .setParameters(followingQuery.getParameters()); - - generateChannelQuery(query, user); - generateRepliesQuery(query, user); - generateVisibilityQuery(query, user); - generateMutedUserQuery(query, user); - generateMutedNoteQuery(query, user); - generateBlockedUserQuery(query, user); - - if (ps.includeMyRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.userId != :meId', { meId: user.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.includeRenotedMyNotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserId != :meId', { meId: user.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.includeLocalRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserHost IS NOT NULL'); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } - //#endregion - - const timeline = await query.take(ps.limit).getMany(); - - process.nextTick(() => { - activeUsersChart.read(user); - }); - - return await Notes.packMany(timeline, user); -}); +} diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index aac2a3749..7b8859639 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -1,16 +1,14 @@ import { Brackets } from 'typeorm'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { Notes, Users } from '@/models/index.js'; -import { activeUsersChart } from '@/services/chart/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { NotesRepository } from '@/models/index.js'; +import type { UsersRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { MetaService } from '@/core/MetaService.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; -import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; -import { generateRepliesQuery } from '../../common/generate-replies-query.js'; -import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js'; -import { generateChannelQuery } from '../../common/generate-channel-query.js'; -import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; export const meta = { tags: ['notes'], @@ -56,64 +54,77 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const m = await fetchMeta(); - if (m.disableLocalTimeline) { - if (user == null || (!user.isAdmin && !user.isModerator)) { - throw new ApiError(meta.errors.ltlDisabled); - } - } +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, - //#region Construct query - const query = makePaginationQuery(Notes.createQueryBuilder('note'), - ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)') - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); - - generateChannelQuery(query, user); - generateRepliesQuery(query, user); - generateVisibilityQuery(query, user); - if (user) generateMutedUserQuery(query, user); - if (user) generateMutedNoteQuery(query, user); - if (user) generateBlockedUserQuery(query, user); - - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } - - if (ps.fileType != null) { - query.andWhere('note.fileIds != \'{}\''); - query.andWhere(new Brackets(qb => { - for (const type of ps.fileType!) { - const i = ps.fileType!.indexOf(type); - qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type }); + private noteEntityService: NoteEntityService, + private queryService: QueryService, + private metaService: MetaService, + private activeUsersChart: ActiveUsersChart, + ) { + super(meta, paramDef, async (ps, me) => { + const m = await this.metaService.fetch(); + if (m.disableLocalTimeline) { + if (me == null || (!me.isAdmin && !me.isModerator)) { + throw new ApiError(meta.errors.ltlDisabled); + } } - })); - if (ps.excludeNsfw) { - query.andWhere('note.cw IS NULL'); - query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)'); - } + //#region Construct query + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + + this.queryService.generateChannelQuery(query, me); + this.queryService.generateRepliesQuery(query, me); + this.queryService.generateVisibilityQuery(query, me); + if (me) this.queryService.generateMutedUserQuery(query, me); + if (me) this.queryService.generateMutedNoteQuery(query, me); + if (me) this.queryService.generateBlockedUserQuery(query, me); + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + + if (ps.fileType != null) { + query.andWhere('note.fileIds != \'{}\''); + query.andWhere(new Brackets(qb => { + for (const type of ps.fileType!) { + const i = ps.fileType!.indexOf(type); + qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type }); + } + })); + + if (ps.excludeNsfw) { + query.andWhere('note.cw IS NULL'); + query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)'); + } + } + //#endregion + + const timeline = await query.take(ps.limit).getMany(); + + process.nextTick(() => { + if (me) { + this.activeUsersChart.read(me); + } + }); + + return await this.noteEntityService.packMany(timeline, me); + }); } - //#endregion - - const timeline = await query.take(ps.limit).getMany(); - - process.nextTick(() => { - if (user) { - activeUsersChart.read(user); - } - }); - - return await Notes.packMany(timeline, user); -}); +} diff --git a/packages/backend/src/server/api/endpoints/notes/mentions.ts b/packages/backend/src/server/api/endpoints/notes/mentions.ts index 9b4154452..9b2dabc88 100644 --- a/packages/backend/src/server/api/endpoints/notes/mentions.ts +++ b/packages/backend/src/server/api/endpoints/notes/mentions.ts @@ -1,12 +1,12 @@ import { Brackets } from 'typeorm'; -import read from '@/services/note/read.js'; -import { Notes, Followings } from '@/models/index.js'; -import define from '../../define.js'; -import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; -import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; -import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; -import { generateMutedNoteThreadQuery } from '../../common/generate-muted-note-thread-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { NotesRepository, FollowingsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { MetaService } from '@/core/MetaService.js'; +import { NoteReadService } from '@/core/NoteReadService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['notes'], @@ -37,45 +37,60 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const followingQuery = Followings.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: user.id }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, - const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .andWhere(new Brackets(qb => { qb - .where(`'{"${user.id}"}' <@ note.mentions`) - .orWhere(`'{"${user.id}"}' <@ note.visibleUserIds`); - })) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, - generateVisibilityQuery(query, user); - generateMutedUserQuery(query, user); - generateMutedNoteThreadQuery(query, user); - generateBlockedUserQuery(query, user); + private noteEntityService: NoteEntityService, + private queryService: QueryService, + private noteReadService: NoteReadService, + ) { + super(meta, paramDef, async (ps, me) => { + const followingQuery = this.followingsRepository.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: me.id }); - if (ps.visibility) { - query.andWhere('note.visibility = :visibility', { visibility: ps.visibility }); + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere(new Brackets(qb => { qb + .where(`'{"${me.id}"}' <@ note.mentions`) + .orWhere(`'{"${me.id}"}' <@ note.visibleUserIds`); + })) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateMutedNoteThreadQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + + if (ps.visibility) { + query.andWhere('note.visibility = :visibility', { visibility: ps.visibility }); + } + + if (ps.following) { + query.andWhere(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: me.id }); + query.setParameters(followingQuery.getParameters()); + } + + const mentions = await query.take(ps.limit).getMany(); + + this.noteReadService.read(me.id, mentions); + + return await this.noteEntityService.packMany(mentions, me); + }); } - - if (ps.following) { - query.andWhere(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: user.id }); - query.setParameters(followingQuery.getParameters()); - } - - const mentions = await query.take(ps.limit).getMany(); - - read(user.id, mentions); - - return await Notes.packMany(mentions, user); -}); +} diff --git a/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts index 5a04d68f3..11bfdbba0 100644 --- a/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts +++ b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts @@ -1,6 +1,9 @@ import { Brackets, In } from 'typeorm'; -import { Polls, Mutings, Notes, PollVotes } from '@/models/index.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { NotesRepository, MutingsRepository, PollsRepository, PollVotesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['notes'], @@ -28,56 +31,75 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const query = Polls.createQueryBuilder('poll') - .where('poll.userHost IS NULL') - .andWhere('poll.userId != :meId', { meId: user.id }) - .andWhere('poll.noteVisibility = \'public\'') - .andWhere(new Brackets(qb => { qb - .where('poll.expiresAt IS NULL') - .orWhere('poll.expiresAt > :now', { now: new Date() }); - })); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, - //#region exclude arleady voted polls - const votedQuery = PollVotes.createQueryBuilder('vote') - .select('vote.noteId') - .where('vote.userId = :meId', { meId: user.id }); + @Inject(DI.pollsRepository) + private pollsRepository: PollsRepository, - query - .andWhere(`poll.noteId NOT IN (${ votedQuery.getQuery() })`); + @Inject(DI.pollVotesRepository) + private pollVotesRepository: PollVotesRepository, - query.setParameters(votedQuery.getParameters()); - //#endregion + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, - //#region mute - const mutingQuery = Mutings.createQueryBuilder('muting') - .select('muting.muteeId') - .where('muting.muterId = :muterId', { muterId: user.id }); + private noteEntityService: NoteEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.pollsRepository.createQueryBuilder('poll') + .where('poll.userHost IS NULL') + .andWhere('poll.userId != :meId', { meId: me.id }) + .andWhere('poll.noteVisibility = \'public\'') + .andWhere(new Brackets(qb => { qb + .where('poll.expiresAt IS NULL') + .orWhere('poll.expiresAt > :now', { now: new Date() }); + })); - query - .andWhere(`poll.userId NOT IN (${ mutingQuery.getQuery() })`); + //#region exclude arleady voted polls + const votedQuery = this.pollVotesRepository.createQueryBuilder('vote') + .select('vote.noteId') + .where('vote.userId = :meId', { meId: me.id }); - query.setParameters(mutingQuery.getParameters()); - //#endregion + query + .andWhere(`poll.noteId NOT IN (${ votedQuery.getQuery() })`); - const polls = await query - .orderBy('poll.noteId', 'DESC') - .take(ps.limit) - .skip(ps.offset) - .getMany(); + query.setParameters(votedQuery.getParameters()); + //#endregion - if (polls.length === 0) return []; + //#region mute + const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') + .select('muting.muteeId') + .where('muting.muterId = :muterId', { muterId: me.id }); - const notes = await Notes.find({ - where: { - id: In(polls.map(poll => poll.noteId)), - }, - order: { - createdAt: 'DESC', - }, - }); + query + .andWhere(`poll.userId NOT IN (${ mutingQuery.getQuery() })`); - return await Notes.packMany(notes, user, { - detail: true, - }); -}); + query.setParameters(mutingQuery.getParameters()); + //#endregion + + const polls = await query + .orderBy('poll.noteId', 'DESC') + .take(ps.limit) + .skip(ps.offset) + .getMany(); + + if (polls.length === 0) return []; + + const notes = await this.notesRepository.find({ + where: { + id: In(polls.map(poll => poll.noteId)), + }, + order: { + createdAt: 'DESC', + }, + }); + + return await this.noteEntityService.packMany(notes, me, { + detail: true, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts index 45a832cbd..76f07528d 100644 --- a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts +++ b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts @@ -1,16 +1,17 @@ import { Not } from 'typeorm'; -import { publishNoteStream } from '@/services/stream.js'; -import { createNotification } from '@/services/create-notification.js'; -import { deliver } from '@/queue/index.js'; -import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import renderVote from '@/remote/activitypub/renderer/vote.js'; -import { deliverQuestionUpdate } from '@/services/note/polls/update.js'; -import { PollVotes, NoteWatchings, Users, Polls, Blockings } from '@/models/index.js'; -import { IRemoteUser } from '@/models/entities/user.js'; -import { genId } from '@/misc/gen-id.js'; -import { getNote } from '../../../common/getters.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UsersRepository, BlockingsRepository, PollsRepository, PollVotesRepository } from '@/models/index.js'; +import type { IRemoteUser } from '@/models/entities/User.js'; +import { IdService } from '@/core/IdService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GetterService } from '@/server/api/common/GetterService.js'; +import { QueueService } from '@/core/QueueService.js'; +import { PollService } from '@/core/PollService.js'; +import { ApRendererService } from '@/core/remote/activitypub/ApRendererService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import define from '../../../define.js'; export const meta = { tags: ['notes'], @@ -67,103 +68,116 @@ export const paramDef = { required: ['noteId', 'choice'], } as const; +// TODO: ロジックをサービスに切り出す + // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const createdAt = new Date(); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - // Get votee - const note = await getNote(ps.noteId).catch(e => { - if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw e; - }); + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, - if (!note.hasPoll) { - throw new ApiError(meta.errors.noPoll); - } + @Inject(DI.pollsRepository) + private pollsRepository: PollsRepository, - // Check blocking - if (note.userId !== user.id) { - const block = await Blockings.findOneBy({ - blockerId: note.userId, - blockeeId: user.id, - }); - if (block) { - throw new ApiError(meta.errors.youHaveBeenBlocked); - } - } + @Inject(DI.pollVotesRepository) + private pollVotesRepository: PollVotesRepository, - const poll = await Polls.findOneByOrFail({ noteId: note.id }); + private idService: IdService, + private getterService: GetterService, + private queueService: QueueService, + private pollService: PollService, + private apRendererService: ApRendererService, + private globalEventService: GlobalEventService, + private createNotificationService: CreateNotificationService, + ) { + super(meta, paramDef, async (ps, me) => { + const createdAt = new Date(); - if (poll.expiresAt && poll.expiresAt < createdAt) { - throw new ApiError(meta.errors.alreadyExpired); - } + // Get votee + 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 (poll.choices[ps.choice] == null) { - throw new ApiError(meta.errors.invalidChoice); - } - - // if already voted - const exist = await PollVotes.findBy({ - noteId: note.id, - userId: user.id, - }); - - if (exist.length) { - if (poll.multiple) { - if (exist.some(x => x.choice === ps.choice)) { - throw new ApiError(meta.errors.alreadyVoted); + if (!note.hasPoll) { + throw new ApiError(meta.errors.noPoll); } - } else { - throw new ApiError(meta.errors.alreadyVoted); - } - } - // Create vote - const vote = await PollVotes.insert({ - id: genId(), - createdAt, - noteId: note.id, - userId: user.id, - choice: ps.choice, - }).then(x => PollVotes.findOneByOrFail(x.identifiers[0])); + // Check blocking + if (note.userId !== me.id) { + const block = await this.blockingsRepository.findOneBy({ + blockerId: note.userId, + blockeeId: me.id, + }); + if (block) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } - // Increment votes count - const index = ps.choice + 1; // In SQL, array index is 1 based - await Polls.query(`UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`); + const poll = await this.pollsRepository.findOneByOrFail({ noteId: note.id }); - publishNoteStream(note.id, 'pollVoted', { - choice: ps.choice, - userId: user.id, - }); + if (poll.expiresAt && poll.expiresAt < createdAt) { + throw new ApiError(meta.errors.alreadyExpired); + } - // Notify - createNotification(note.userId, 'pollVote', { - notifierId: user.id, - noteId: note.id, - choice: ps.choice, - }); + if (poll.choices[ps.choice] == null) { + throw new ApiError(meta.errors.invalidChoice); + } - // Fetch watchers - NoteWatchings.findBy({ - noteId: note.id, - userId: Not(user.id), - }).then(watchers => { - for (const watcher of watchers) { - createNotification(watcher.userId, 'pollVote', { - notifierId: user.id, + // if already voted + const exist = await this.pollVotesRepository.findBy({ + noteId: note.id, + userId: me.id, + }); + + if (exist.length) { + if (poll.multiple) { + if (exist.some(x => x.choice === ps.choice)) { + throw new ApiError(meta.errors.alreadyVoted); + } + } else { + throw new ApiError(meta.errors.alreadyVoted); + } + } + + // Create vote + const vote = await this.pollVotesRepository.insert({ + id: this.idService.genId(), + createdAt, + noteId: note.id, + userId: me.id, + choice: ps.choice, + }).then(x => this.pollVotesRepository.findOneByOrFail(x.identifiers[0])); + + // Increment votes count + const index = ps.choice + 1; // In SQL, array index is 1 based + await this.pollsRepository.query(`UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`); + + this.globalEventService.publishNoteStream(note.id, 'pollVoted', { + choice: ps.choice, + userId: me.id, + }); + + // Notify + this.createNotificationService.createNotification(note.userId, 'pollVote', { + notifierId: me.id, noteId: note.id, choice: ps.choice, }); - } - }); - // リモート投票の場合リプライ送信 - if (note.userHost != null) { - const pollOwner = await Users.findOneByOrFail({ id: note.userId }) as IRemoteUser; + // リモート投票の場合リプライ送信 + if (note.userHost != null) { + const pollOwner = await this.usersRepository.findOneByOrFail({ id: note.userId }) as IRemoteUser; - deliver(user, renderActivity(await renderVote(user, vote, note, poll, pollOwner)), pollOwner.inbox); + this.queueService.deliver(me, this.apRendererService.renderActivity(await this.apRendererService.renderVote(me, vote, note, poll, pollOwner)), pollOwner.inbox); + } + + // リモートフォロワーにUpdate配信 + this.pollService.deliverQuestionUpdate(note.id); + }); } - - // リモートフォロワーにUpdate配信 - deliverQuestionUpdate(note.id); -}); +} diff --git a/packages/backend/src/server/api/endpoints/notes/reactions.ts b/packages/backend/src/server/api/endpoints/notes/reactions.ts index 15a62d394..d57950f01 100644 --- a/packages/backend/src/server/api/endpoints/notes/reactions.ts +++ b/packages/backend/src/server/api/endpoints/notes/reactions.ts @@ -1,8 +1,12 @@ -import { DeepPartial, FindOptionsWhere } from 'typeorm'; -import { NoteReactions } from '@/models/index.js'; -import { NoteReaction } from '@/models/entities/note-reaction.js'; -import define from '../../define.js'; +import { DeepPartial } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; +import { NoteReactionsRepository } from '@/models/index.js'; +import type { NoteReaction } from '@/models/entities/NoteReaction.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { NoteReactionEntityService } from '@/core/entities/NoteReactionEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; +import type { FindOptionsWhere } from 'typeorm'; export const meta = { tags: ['notes', 'reactions'], @@ -45,28 +49,38 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const query = { - noteId: ps.noteId, - } as FindOptionsWhere; +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.noteReactionsRepository) + private noteReactionsRepository: NoteReactionsRepository, - if (ps.type) { - // ローカルリアクションはホスト名が . とされているが - // DB 上ではそうではないので、必要に応じて変換 - const suffix = '@.:'; - const type = ps.type.endsWith(suffix) ? ps.type.slice(0, ps.type.length - suffix.length) + ':' : ps.type; - query.reaction = type; + private noteReactionEntityService: NoteReactionEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = { + noteId: ps.noteId, + } as FindOptionsWhere; + + if (ps.type) { + // ローカルリアクションはホスト名が . とされているが + // DB 上ではそうではないので、必要に応じて変換 + const suffix = '@.:'; + const type = ps.type.endsWith(suffix) ? ps.type.slice(0, ps.type.length - suffix.length) + ':' : ps.type; + query.reaction = type; + } + + const reactions = await this.noteReactionsRepository.find({ + where: query, + take: ps.limit, + skip: ps.offset, + order: { + id: -1, + }, + relations: ['user', 'user.avatar', 'user.banner', 'note'], + }); + + return await Promise.all(reactions.map(reaction => this.noteReactionEntityService.pack(reaction, me))); + }); } - - const reactions = await NoteReactions.find({ - where: query, - take: ps.limit, - skip: ps.offset, - order: { - id: -1, - }, - relations: ['user', 'user.avatar', 'user.banner', 'note'], - }); - - return await Promise.all(reactions.map(reaction => NoteReactions.pack(reaction, user))); -}); +} diff --git a/packages/backend/src/server/api/endpoints/notes/reactions/create.ts b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts index 07e52a926..2af734307 100644 --- a/packages/backend/src/server/api/endpoints/notes/reactions/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts @@ -1,6 +1,7 @@ -import createReaction from '@/services/note/reaction/create.js'; -import define from '../../../define.js'; -import { getNote } from '../../../common/getters.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GetterService } from '@/server/api/common/GetterService.js'; +import { ReactionService } from '@/core/ReactionService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -41,15 +42,23 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const note = await getNote(ps.noteId).catch(e => { - if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw e; - }); - await createReaction(user, note, ps.reaction).catch(e => { - if (e.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') throw new ApiError(meta.errors.alreadyReacted); - if (e.id === 'e70412a4-7197-4726-8e74-f3e0deb92aa7') throw new ApiError(meta.errors.youHaveBeenBlocked); - throw e; - }); - return; -}); +@Injectable() +export default class extends Endpoint { + constructor( + private getterService: GetterService, + private reactionService: ReactionService, + ) { + 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; + }); + await this.reactionService.create(me, note, ps.reaction).catch(err => { + if (err.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') throw new ApiError(meta.errors.alreadyReacted); + if (err.id === 'e70412a4-7197-4726-8e74-f3e0deb92aa7') throw new ApiError(meta.errors.youHaveBeenBlocked); + throw err; + }); + return; + }); + } +} 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 c13cafa21..31ed96292 100644 --- a/packages/backend/src/server/api/endpoints/notes/reactions/delete.ts +++ b/packages/backend/src/server/api/endpoints/notes/reactions/delete.ts @@ -1,7 +1,8 @@ import ms from 'ms'; -import deleteReaction from '@/services/note/reaction/delete.js'; -import define from '../../../define.js'; -import { getNote } from '../../../common/getters.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GetterService } from '@/server/api/common/GetterService.js'; +import { ReactionService } from '@/core/ReactionService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -41,13 +42,21 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const note = await getNote(ps.noteId).catch(e => { - if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw e; - }); - await deleteReaction(user, note).catch(e => { - if (e.id === '60527ec9-b4cb-4a88-a6bd-32d3ad26817d') throw new ApiError(meta.errors.notReacted); - throw e; - }); -}); +@Injectable() +export default class extends Endpoint { + constructor( + private getterService: GetterService, + private reactionService: ReactionService, + ) { + 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; + }); + await this.reactionService.delete(me, note).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/renotes.ts b/packages/backend/src/server/api/endpoints/notes/renotes.ts index 28be36076..57b7aeae0 100644 --- a/packages/backend/src/server/api/endpoints/notes/renotes.ts +++ b/packages/backend/src/server/api/endpoints/notes/renotes.ts @@ -1,11 +1,11 @@ -import { Notes } from '@/models/index.js'; -import define from '../../define.js'; -import { getNote } from '../../common/getters.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { NotesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; -import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; -import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; +import { GetterService } from '../../common/GetterService.js'; export const meta = { tags: ['notes'], @@ -43,31 +43,43 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const note = await getNote(ps.noteId).catch(e => { - if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw e; - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, - const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .andWhere('note.renoteId = :renoteId', { renoteId: note.id }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + private noteEntityService: NoteEntityService, + private queryService: QueryService, + private getterService: GetterService, + ) { + 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; + }); - generateVisibilityQuery(query, user); - if (user) generateMutedUserQuery(query, user); - if (user) generateBlockedUserQuery(query, user); + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere('note.renoteId = :renoteId', { renoteId: note.id }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); - const renotes = await query.take(ps.limit).getMany(); + this.queryService.generateVisibilityQuery(query, me); + if (me) this.queryService.generateMutedUserQuery(query, me); + if (me) this.queryService.generateBlockedUserQuery(query, me); - return await Notes.packMany(renotes, user); -}); + const renotes = await query.take(ps.limit).getMany(); + + return await this.noteEntityService.packMany(renotes, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/replies.ts b/packages/backend/src/server/api/endpoints/notes/replies.ts index ab0018f58..7020d0c68 100644 --- a/packages/backend/src/server/api/endpoints/notes/replies.ts +++ b/packages/backend/src/server/api/endpoints/notes/replies.ts @@ -1,9 +1,9 @@ -import { Notes } from '@/models/index.js'; -import define from '../../define.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; -import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; -import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; -import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { NotesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['notes'], @@ -33,26 +33,37 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .andWhere('note.replyId = :replyId', { replyId: ps.noteId }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, - generateVisibilityQuery(query, user); - if (user) generateMutedUserQuery(query, user); - if (user) generateBlockedUserQuery(query, user); + private noteEntityService: NoteEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere('note.replyId = :replyId', { replyId: ps.noteId }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); - const timeline = await query.take(ps.limit).getMany(); + this.queryService.generateVisibilityQuery(query, me); + if (me) this.queryService.generateMutedUserQuery(query, me); + if (me) this.queryService.generateBlockedUserQuery(query, me); - return await Notes.packMany(timeline, user); -}); + const timeline = await query.take(ps.limit).getMany(); + + return await this.noteEntityService.packMany(timeline, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts index 777de7221..0727c9af6 100644 --- a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts +++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts @@ -1,12 +1,12 @@ import { Brackets } from 'typeorm'; -import { Notes } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { NotesRepository } from '@/models/index.js'; import { safeForSql } from '@/misc/safe-for-sql.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; -import define from '../../define.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; -import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; -import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; -import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['notes', 'hashtags'], @@ -66,75 +66,86 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, - generateVisibilityQuery(query, me); - if (me) generateMutedUserQuery(query, me); - if (me) generateBlockedUserQuery(query, me); + private noteEntityService: NoteEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); - try { - if (ps.tag) { - if (!safeForSql(ps.tag)) throw 'Injection'; - query.andWhere(`'{"${normalizeForSearch(ps.tag)}"}' <@ note.tags`); - } else { - query.andWhere(new Brackets(qb => { - for (const tags of ps.query!) { - qb.orWhere(new Brackets(qb => { - for (const tag of tags) { - if (!safeForSql(tag)) throw 'Injection'; - qb.andWhere(`'{"${normalizeForSearch(tag)}"}' <@ note.tags`); + this.queryService.generateVisibilityQuery(query, me); + if (me) this.queryService.generateMutedUserQuery(query, me); + if (me) this.queryService.generateBlockedUserQuery(query, me); + + try { + if (ps.tag) { + if (!safeForSql(ps.tag)) throw 'Injection'; + query.andWhere(`'{"${normalizeForSearch(ps.tag)}"}' <@ note.tags`); + } else { + query.andWhere(new Brackets(qb => { + for (const tags of ps.query!) { + qb.orWhere(new Brackets(qb => { + for (const tag of tags) { + if (!safeForSql(tag)) throw 'Injection'; + qb.andWhere(`'{"${normalizeForSearch(tag)}"}' <@ note.tags`); + } + })); } })); } - })); - } - } catch (e) { - if (e === 'Injection') return []; - throw e; + } catch (e) { + if (e === 'Injection') return []; + throw e; + } + + if (ps.reply != null) { + if (ps.reply) { + query.andWhere('note.replyId IS NOT NULL'); + } else { + query.andWhere('note.replyId IS NULL'); + } + } + + if (ps.renote != null) { + if (ps.renote) { + query.andWhere('note.renoteId IS NOT NULL'); + } else { + query.andWhere('note.renoteId IS NULL'); + } + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + + if (ps.poll != null) { + if (ps.poll) { + query.andWhere('note.hasPoll = TRUE'); + } else { + query.andWhere('note.hasPoll = FALSE'); + } + } + + // Search notes + const notes = await query.take(ps.limit).getMany(); + + return await this.noteEntityService.packMany(notes, me); + }); } - - if (ps.reply != null) { - if (ps.reply) { - query.andWhere('note.replyId IS NOT NULL'); - } else { - query.andWhere('note.replyId IS NULL'); - } - } - - if (ps.renote != null) { - if (ps.renote) { - query.andWhere('note.renoteId IS NOT NULL'); - } else { - query.andWhere('note.renoteId IS NULL'); - } - } - - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } - - if (ps.poll != null) { - if (ps.poll) { - query.andWhere('note.hasPoll = TRUE'); - } else { - query.andWhere('note.hasPoll = FALSE'); - } - } - - // Search notes - const notes = await query.take(ps.limit).getMany(); - - return await Notes.packMany(notes, me); -}); +} diff --git a/packages/backend/src/server/api/endpoints/notes/search.ts b/packages/backend/src/server/api/endpoints/notes/search.ts index 4e2cdae80..484cfc112 100644 --- a/packages/backend/src/server/api/endpoints/notes/search.ts +++ b/packages/backend/src/server/api/endpoints/notes/search.ts @@ -1,12 +1,11 @@ import { In } from 'typeorm'; -import { Notes } from '@/models/index.js'; -import config from '@/config/index.js'; -import es from '../../../../db/elasticsearch.js'; -import define from '../../define.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; -import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; -import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; -import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { NotesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['notes'], @@ -46,97 +45,51 @@ export const paramDef = { required: ['query'], } as const; +// TODO: ロジックをサービスに切り出す + // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - if (es == null) { - const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, - if (ps.userId) { - query.andWhere('note.userId = :userId', { userId: ps.userId }); - } else if (ps.channelId) { - query.andWhere('note.channelId = :channelId', { channelId: ps.channelId }); - } + private noteEntityService: NoteEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId); - query - .andWhere('note.text ILIKE :q', { q: `%${ps.query}%` }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + if (ps.userId) { + query.andWhere('note.userId = :userId', { userId: ps.userId }); + } else if (ps.channelId) { + query.andWhere('note.channelId = :channelId', { channelId: ps.channelId }); + } - generateVisibilityQuery(query, me); - if (me) generateMutedUserQuery(query, me); - if (me) generateBlockedUserQuery(query, me); + query + .andWhere('note.text ILIKE :q', { q: `%${ps.query}%` }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); - const notes = await query.take(ps.limit).getMany(); + this.queryService.generateVisibilityQuery(query, me); + if (me) this.queryService.generateMutedUserQuery(query, me); + if (me) this.queryService.generateBlockedUserQuery(query, me); - return await Notes.packMany(notes, me); - } else { - const userQuery = ps.userId != null ? [{ - term: { - userId: ps.userId, - }, - }] : []; + const notes = await query.take(ps.limit).getMany(); - const hostQuery = ps.userId == null ? - ps.host === null ? [{ - bool: { - must_not: { - exists: { - field: 'userHost', - }, - }, - }, - }] : ps.host !== undefined ? [{ - term: { - userHost: ps.host, - }, - }] : [] - : []; - - const result = await es.search({ - index: config.elasticsearch.index || 'misskey_note', - body: { - size: ps.limit, - from: ps.offset, - query: { - bool: { - must: [{ - simple_query_string: { - fields: ['text'], - query: ps.query.toLowerCase(), - default_operator: 'and', - }, - }, ...hostQuery, ...userQuery], - }, - }, - sort: [{ - _doc: 'desc', - }], - }, + return await this.noteEntityService.packMany(notes, me); }); - - const hits = result.body.hits.hits.map((hit: any) => hit._id); - - if (hits.length === 0) return []; - - // Fetch found notes - const notes = await Notes.find({ - where: { - id: In(hits), - }, - order: { - id: -1, - }, - }); - - return await Notes.packMany(notes, me); } -}); +} diff --git a/packages/backend/src/server/api/endpoints/notes/show.ts b/packages/backend/src/server/api/endpoints/notes/show.ts index 5cd74bd2c..c3f5b9dfb 100644 --- a/packages/backend/src/server/api/endpoints/notes/show.ts +++ b/packages/backend/src/server/api/endpoints/notes/show.ts @@ -1,7 +1,10 @@ -import { Notes } from '@/models/index.js'; -import define from '../../define.js'; -import { getNote } from '../../common/getters.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { NotesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; +import { GetterService } from '../../common/GetterService.js'; export const meta = { tags: ['notes'], @@ -32,13 +35,24 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const note = await getNote(ps.noteId).catch(e => { - if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw e; - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, - return await Notes.pack(note, user, { - detail: true, - }); -}); + private noteEntityService: NoteEntityService, + private getterService: GetterService, + ) { + 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; + }); + + return await this.noteEntityService.pack(note, me, { + detail: true, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/state.ts b/packages/backend/src/server/api/endpoints/notes/state.ts index 01afa5add..7756d39f7 100644 --- a/packages/backend/src/server/api/endpoints/notes/state.ts +++ b/packages/backend/src/server/api/endpoints/notes/state.ts @@ -1,5 +1,7 @@ -import { NoteFavorites, Notes, NoteThreadMutings, NoteWatchings } from '@/models/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { NotesRepository, NoteThreadMutingsRepository, NoteFavoritesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['notes'], @@ -35,36 +37,42 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const note = await Notes.findOneByOrFail({ id: ps.noteId }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, - const [favorite, watching, threadMuting] = await Promise.all([ - NoteFavorites.count({ - where: { - userId: user.id, - noteId: note.id, - }, - take: 1, - }), - NoteWatchings.count({ - where: { - userId: user.id, - noteId: note.id, - }, - take: 1, - }), - NoteThreadMutings.count({ - where: { - userId: user.id, - threadId: note.threadId || note.id, - }, - take: 1, - }), - ]); + @Inject(DI.noteThreadMutingsRepository) + private noteThreadMutingsRepository: NoteThreadMutingsRepository, - return { - isFavorited: favorite !== 0, - isWatching: watching !== 0, - isMutedThread: threadMuting !== 0, - }; -}); + @Inject(DI.noteFavoritesRepository) + private noteFavoritesRepository: NoteFavoritesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const note = await this.notesRepository.findOneByOrFail({ id: ps.noteId }); + + const [favorite, threadMuting] = await Promise.all([ + this.noteFavoritesRepository.count({ + where: { + userId: me.id, + noteId: note.id, + }, + take: 1, + }), + this.noteThreadMutingsRepository.count({ + where: { + userId: me.id, + threadId: note.threadId || note.id, + }, + take: 1, + }), + ]); + + return { + isFavorited: favorite !== 0, + isMutedThread: threadMuting !== 0, + }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts index cf360526d..060581d74 100644 --- a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts @@ -1,8 +1,10 @@ -import { Notes, NoteThreadMutings } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; -import readNote from '@/services/note/read.js'; -import define from '../../../define.js'; -import { getNote } from '../../../common/getters.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { NotesRepository, NoteThreadMutingsRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GetterService } from '@/server/api/common/GetterService.js'; +import { NoteReadService } from '@/core/NoteReadService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -30,26 +32,41 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const note = await getNote(ps.noteId).catch(e => { - if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw e; - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, - const mutedNotes = await Notes.find({ - where: [{ - id: note.threadId || note.id, - }, { - threadId: note.threadId || note.id, - }], - }); + @Inject(DI.noteThreadMutingsRepository) + private noteThreadMutingsRepository: NoteThreadMutingsRepository, - await readNote(user.id, mutedNotes); + private getterService: GetterService, + private noteReadService: NoteReadService, + private idService: IdService, + ) { + 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; + }); - await NoteThreadMutings.insert({ - id: genId(), - createdAt: new Date(), - threadId: note.threadId || note.id, - userId: user.id, - }); -}); + const mutedNotes = await this.notesRepository.find({ + where: [{ + id: note.threadId ?? note.id, + }, { + threadId: note.threadId ?? note.id, + }], + }); + + await this.noteReadService.read(me.id, mutedNotes); + + await this.noteThreadMutingsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + threadId: note.threadId ?? note.id, + userId: me.id, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/thread-muting/delete.ts b/packages/backend/src/server/api/endpoints/notes/thread-muting/delete.ts index ac310d0fe..aed15852d 100644 --- a/packages/backend/src/server/api/endpoints/notes/thread-muting/delete.ts +++ b/packages/backend/src/server/api/endpoints/notes/thread-muting/delete.ts @@ -1,6 +1,8 @@ -import { NoteThreadMutings } from '@/models/index.js'; -import define from '../../../define.js'; -import { getNote } from '../../../common/getters.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { NoteThreadMutingsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GetterService } from '@/server/api/common/GetterService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -28,14 +30,24 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const note = await getNote(ps.noteId).catch(e => { - if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw e; - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.noteThreadMutingsRepository) + private noteThreadMutingsRepository: NoteThreadMutingsRepository, - await NoteThreadMutings.delete({ - threadId: note.threadId || note.id, - userId: user.id, - }); -}); + private getterService: GetterService, + ) { + 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; + }); + + await this.noteThreadMutingsRepository.delete({ + threadId: note.threadId ?? note.id, + userId: me.id, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 22f492517..53a1ae134 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -1,14 +1,12 @@ import { Brackets } from 'typeorm'; -import { Notes, Followings } from '@/models/index.js'; -import { activeUsersChart } from '@/services/chart/index.js'; -import define from '../../define.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; -import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; -import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; -import { generateRepliesQuery } from '../../common/generate-replies-query.js'; -import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js'; -import { generateChannelQuery } from '../../common/generate-channel-query.js'; -import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { NotesRepository, FollowingsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { MetaService } from '@/core/MetaService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['notes'], @@ -47,85 +45,100 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const hasFollowing = (await Followings.count({ - where: { - followerId: user.id, - }, - take: 1, - })) !== 0; +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, - //#region Construct query - const followingQuery = Followings.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: user.id }); + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, - const query = makePaginationQuery(Notes.createQueryBuilder('note'), - ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere(new Brackets(qb => { qb - .where('note.userId = :meId', { meId: user.id }); - if (hasFollowing) qb.orWhere(`note.userId IN (${ followingQuery.getQuery() })`); - })) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') - .setParameters(followingQuery.getParameters()); + private noteEntityService: NoteEntityService, + private queryService: QueryService, + private activeUsersChart: ActiveUsersChart, + ) { + super(meta, paramDef, async (ps, me) => { + const hasFollowing = (await this.followingsRepository.count({ + where: { + followerId: me.id, + }, + take: 1, + })) !== 0; - generateChannelQuery(query, user); - generateRepliesQuery(query, user); - generateVisibilityQuery(query, user); - generateMutedUserQuery(query, user); - generateMutedNoteQuery(query, user); - generateBlockedUserQuery(query, user); + //#region Construct query + const followingQuery = this.followingsRepository.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: me.id }); - if (ps.includeMyRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.userId != :meId', { meId: user.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere(new Brackets(qb => { qb + .where('note.userId = :meId', { meId: me.id }); + if (hasFollowing) qb.orWhere(`note.userId IN (${ followingQuery.getQuery() })`); + })) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') + .setParameters(followingQuery.getParameters()); + + this.queryService.generateChannelQuery(query, me); + this.queryService.generateRepliesQuery(query, me); + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateMutedNoteQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + + if (ps.includeMyRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.userId != :meId', { meId: me.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeRenotedMyNotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeLocalRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserHost IS NOT NULL'); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + //#endregion + + const timeline = await query.take(ps.limit).getMany(); + + process.nextTick(() => { + this.activeUsersChart.read(me); + }); + + return await this.noteEntityService.packMany(timeline, me); + }); } - - if (ps.includeRenotedMyNotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserId != :meId', { meId: user.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.includeLocalRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserHost IS NOT NULL'); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } - //#endregion - - const timeline = await query.take(ps.limit).getMany(); - - process.nextTick(() => { - activeUsersChart.read(user); - }); - - return await Notes.packMany(timeline, user); -}); +} diff --git a/packages/backend/src/server/api/endpoints/notes/translate.ts b/packages/backend/src/server/api/endpoints/notes/translate.ts index 5e40e7106..c24f1e401 100644 --- a/packages/backend/src/server/api/endpoints/notes/translate.ts +++ b/packages/backend/src/server/api/endpoints/notes/translate.ts @@ -1,12 +1,15 @@ import { URLSearchParams } from 'node:url'; import fetch from 'node-fetch'; -import config from '@/config/index.js'; -import { getAgentByUrl } from '@/misc/fetch.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { Notes } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { NotesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { MetaService } from '@/core/MetaService.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; import { ApiError } from '../../error.js'; -import { getNote } from '../../common/getters.js'; -import define from '../../define.js'; +import { GetterService } from '../../common/GetterService.js'; export const meta = { tags: ['notes'], @@ -37,58 +40,74 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const note = await getNote(ps.noteId).catch(e => { - if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw e; - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, - if (!(await Notes.isVisibleForMe(note, user ? user.id : null))) { - return 204; // TODO: 良い感じのエラー返す - } + private noteEntityService: NoteEntityService, + private getterService: GetterService, + private metaService: MetaService, + private httpRequestService: HttpRequestService, + ) { + 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.text == null) { - return 204; - } + if (!(await this.noteEntityService.isVisibleForMe(note, me ? me.id : null))) { + return 204; // TODO: 良い感じのエラー返す + } - const instance = await fetchMeta(); + if (note.text == null) { + return 204; + } - if (instance.deeplAuthKey == null) { - return 204; // TODO: 良い感じのエラー返す - } + const instance = await this.metaService.fetch(); - let targetLang = ps.targetLang; - if (targetLang.includes('-')) targetLang = targetLang.split('-')[0]; + if (instance.deeplAuthKey == null) { + return 204; // TODO: 良い感じのエラー返す + } - const params = new URLSearchParams(); - params.append('auth_key', instance.deeplAuthKey); - params.append('text', note.text); - params.append('target_lang', targetLang); + let targetLang = ps.targetLang; + if (targetLang.includes('-')) targetLang = targetLang.split('-')[0]; - const endpoint = instance.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate'; + const params = new URLSearchParams(); + params.append('auth_key', instance.deeplAuthKey); + params.append('text', note.text); + params.append('target_lang', targetLang); - const res = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'User-Agent': config.userAgent, - Accept: 'application/json, */*', - }, - body: params, - // TODO - //timeout: 10000, - agent: getAgentByUrl, - }); + const endpoint = instance.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate'; - const json = (await res.json()) as { + const res = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': config.userAgent, + Accept: 'application/json, */*', + }, + body: params, + // TODO + //timeout: 10000, + agent: (url) => this.httpRequestService.getAgentByUrl(url), + }); + + const json = (await res.json()) as { translations: { detected_source_language: string; text: string; }[]; }; - return { - sourceLang: json.translations[0].detected_source_language, - text: json.translations[0].text, - }; -}); + return { + sourceLang: json.translations[0].detected_source_language, + text: json.translations[0].text, + }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/unrenote.ts b/packages/backend/src/server/api/endpoints/notes/unrenote.ts index 3fba0efe0..c0048888b 100644 --- a/packages/backend/src/server/api/endpoints/notes/unrenote.ts +++ b/packages/backend/src/server/api/endpoints/notes/unrenote.ts @@ -1,9 +1,11 @@ import ms from 'ms'; -import deleteNote from '@/services/note/delete.js'; -import { Notes, Users } from '@/models/index.js'; -import define from '../../define.js'; -import { getNote } from '../../common/getters.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UsersRepository, NotesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { NoteDeleteService } from '@/core/NoteDeleteService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; +import { GetterService } from '../../common/GetterService.js'; export const meta = { tags: ['notes'], @@ -36,18 +38,32 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const note = await getNote(ps.noteId).catch(e => { - if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw e; - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - const renotes = await Notes.findBy({ - userId: user.id, - renoteId: note.id, - }); + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, - for (const note of renotes) { - deleteNote(await Users.findOneByOrFail({ id: user.id }), note); + private getterService: GetterService, + private noteDeleteService: NoteDeleteService, + ) { + 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; + }); + + const renotes = await this.notesRepository.findBy({ + userId: me.id, + renoteId: note.id, + }); + + for (const note of renotes) { + this.noteDeleteService.delete(await this.usersRepository.findOneByOrFail({ id: me.id }), note); + } + }); } -}); +} 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 e603a8f62..87a464578 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 @@ -1,10 +1,12 @@ import { Brackets } from 'typeorm'; -import { UserLists, UserListJoinings, Notes } from '@/models/index.js'; -import { activeUsersChart } from '@/services/chart/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { NotesRepository, UserListsRepository, UserListJoiningsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; -import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; export const meta = { tags: ['notes', 'lists'], @@ -52,72 +54,90 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const list = await UserLists.findOneBy({ - id: ps.listId, - userId: user.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, - if (list == null) { - throw new ApiError(meta.errors.noSuchList); + @Inject(DI.userListsRepository) + private userListsRepository: UserListsRepository, + + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, + + private noteEntityService: NoteEntityService, + private queryService: QueryService, + private activeUsersChart: ActiveUsersChart, + ) { + super(meta, paramDef, async (ps, me) => { + const list = await this.userListsRepository.findOneBy({ + id: ps.listId, + userId: me.id, + }); + + if (list == null) { + throw new ApiError(meta.errors.noSuchList); + } + + //#region Construct query + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .innerJoin(this.userListJoiningsRepository.metadata.targetName, 'userListJoining', 'userListJoining.userId = note.userId') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') + .andWhere('userListJoining.userListId = :userListId', { userListId: list.id }); + + this.queryService.generateVisibilityQuery(query, me); + + if (ps.includeMyRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.userId != :meId', { meId: me.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeRenotedMyNotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeLocalRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserHost IS NOT NULL'); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + //#endregion + + const timeline = await query.take(ps.limit).getMany(); + + this.activeUsersChart.read(me); + + return await this.noteEntityService.packMany(timeline, me); + }); } - - //#region Construct query - const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .innerJoin(UserListJoinings.metadata.targetName, 'userListJoining', 'userListJoining.userId = note.userId') - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') - .andWhere('userListJoining.userListId = :userListId', { userListId: list.id }); - - generateVisibilityQuery(query, user); - - if (ps.includeMyRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.userId != :meId', { meId: user.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.includeRenotedMyNotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserId != :meId', { meId: user.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.includeLocalRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserHost IS NOT NULL'); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } - //#endregion - - const timeline = await query.take(ps.limit).getMany(); - - activeUsersChart.read(user); - - return await Notes.packMany(timeline, user); -}); +} diff --git a/packages/backend/src/server/api/endpoints/notes/watching/create.ts b/packages/backend/src/server/api/endpoints/notes/watching/create.ts deleted file mode 100644 index 7d482b073..000000000 --- a/packages/backend/src/server/api/endpoints/notes/watching/create.ts +++ /dev/null @@ -1,38 +0,0 @@ -import watch from '@/services/note/watch.js'; -import define from '../../../define.js'; -import { getNote } from '../../../common/getters.js'; -import { ApiError } from '../../../error.js'; - -export const meta = { - tags: ['notes'], - - requireCredential: true, - - kind: 'write:account', - - errors: { - noSuchNote: { - message: 'No such note.', - code: 'NO_SUCH_NOTE', - id: 'ea0e37a6-90a3-4f58-ba6b-c328ca206fc7', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - noteId: { type: 'string', format: 'misskey:id' }, - }, - required: ['noteId'], -} as const; - -// eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const note = await getNote(ps.noteId).catch(e => { - if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw e; - }); - - await watch(user.id, note); -}); diff --git a/packages/backend/src/server/api/endpoints/notes/watching/delete.ts b/packages/backend/src/server/api/endpoints/notes/watching/delete.ts deleted file mode 100644 index 2c1a2e5fb..000000000 --- a/packages/backend/src/server/api/endpoints/notes/watching/delete.ts +++ /dev/null @@ -1,38 +0,0 @@ -import unwatch from '@/services/note/unwatch.js'; -import define from '../../../define.js'; -import { getNote } from '../../../common/getters.js'; -import { ApiError } from '../../../error.js'; - -export const meta = { - tags: ['notes'], - - requireCredential: true, - - kind: 'write:account', - - errors: { - noSuchNote: { - message: 'No such note.', - code: 'NO_SUCH_NOTE', - id: '09b3695c-f72c-4731-a428-7cff825fc82e', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - noteId: { type: 'string', format: 'misskey:id' }, - }, - required: ['noteId'], -} as const; - -// eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const note = await getNote(ps.noteId).catch(e => { - if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw e; - }); - - await unwatch(user.id, note); -}); diff --git a/packages/backend/src/server/api/endpoints/notifications/create.ts b/packages/backend/src/server/api/endpoints/notifications/create.ts index 80d513d8d..3427a3eb5 100644 --- a/packages/backend/src/server/api/endpoints/notifications/create.ts +++ b/packages/backend/src/server/api/endpoints/notifications/create.ts @@ -1,5 +1,6 @@ -import { createNotification } from '@/services/create-notification.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { CreateNotificationService } from '@/core/CreateNotificationService.js'; export const meta = { tags: ['notifications'], @@ -23,11 +24,18 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user, token) => { - createNotification(user.id, 'app', { - appAccessTokenId: token ? token.id : null, - customBody: ps.body, - customHeader: ps.header, - customIcon: ps.icon, - }); -}); +@Injectable() +export default class extends Endpoint { + constructor( + private createNotificationService: CreateNotificationService, + ) { + super(meta, paramDef, async (ps, user, token) => { + this.createNotificationService.createNotification(user.id, 'app', { + appAccessTokenId: token ? token.id : null, + customBody: ps.body, + customHeader: ps.header, + customIcon: ps.icon, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts b/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts index d169afbb3..3d1eb2b39 100644 --- a/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts +++ b/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts @@ -1,7 +1,9 @@ -import { publishMainStream } from '@/services/stream.js'; -import { pushNotification } from '@/services/push-notification.js'; -import { Notifications } from '@/models/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { NotificationsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { PushNotificationService } from '@/core/PushNotificationService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['notifications', 'account'], @@ -18,16 +20,27 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - // Update documents - await Notifications.update({ - notifieeId: user.id, - isRead: false, - }, { - isRead: true, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.notificationsRepository) + private notificationsRepository: NotificationsRepository, - // 全ての通知を読みましたよというイベントを発行 - publishMainStream(user.id, 'readAllNotifications'); - pushNotification(user.id, 'readAllNotifications', undefined); -}); + private globalEventService: GlobalEventService, + private pushNotificationService: PushNotificationService, + ) { + super(meta, paramDef, async (ps, me) => { + // Update documents + await this.notificationsRepository.update({ + notifieeId: me.id, + isRead: false, + }, { + isRead: true, + }); + + // 全ての通知を読みましたよというイベントを発行 + this.globalEventService.publishMainStream(me.id, 'readAllNotifications'); + this.pushNotificationService.pushNotification(me.id, 'readAllNotifications', undefined); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notifications/read.ts b/packages/backend/src/server/api/endpoints/notifications/read.ts index 7bce525a5..cdf8d09f9 100644 --- a/packages/backend/src/server/api/endpoints/notifications/read.ts +++ b/packages/backend/src/server/api/endpoints/notifications/read.ts @@ -1,5 +1,6 @@ -import define from '../../define.js'; -import { readNotification } from '../../common/read-notification.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { NotificationService } from '@/core/NotificationService.js'; export const meta = { tags: ['notifications', 'account'], @@ -43,7 +44,14 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - if ('notificationId' in ps) return readNotification(user.id, [ps.notificationId]); - return readNotification(user.id, ps.notificationIds); -}); +@Injectable() +export default class extends Endpoint { + constructor( + private notificationService: NotificationService, + ) { + super(meta, paramDef, async (ps, me) => { + if ('notificationId' in ps) return this.notificationService.readNotification(me.id, [ps.notificationId]); + return this.notificationService.readNotification(me.id, ps.notificationIds); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/page-push.ts b/packages/backend/src/server/api/endpoints/page-push.ts index 6dd3ede85..1b0299c3c 100644 --- a/packages/backend/src/server/api/endpoints/page-push.ts +++ b/packages/backend/src/server/api/endpoints/page-push.ts @@ -1,6 +1,10 @@ -import { publishMainStream } from '@/services/stream.js'; -import { Users, Pages } from '@/models/index.js'; -import define from '../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { PagesRepository } from '@/models/index.js'; +import type { UsersRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../error.js'; export const meta = { @@ -27,19 +31,30 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const page = await Pages.findOneBy({ id: ps.pageId }); - if (page == null) { - throw new ApiError(meta.errors.noSuchPage); - } +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.pagesRepository) + private pagesRepository: PagesRepository, - publishMainStream(page.userId, 'pageEvent', { - pageId: ps.pageId, - event: ps.event, - var: ps.var, - userId: user.id, - user: await Users.pack(user.id, { id: page.userId }, { - detail: true, - }), - }); -}); + private userEntityService: UserEntityService, + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + const page = await this.pagesRepository.findOneBy({ id: ps.pageId }); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + + this.globalEventService.publishMainStream(page.userId, 'pageEvent', { + pageId: ps.pageId, + event: ps.event, + var: ps.var, + userId: me.id, + user: await this.userEntityService.pack(me.id, { id: page.userId }, { + detail: true, + }), + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/pages/create.ts b/packages/backend/src/server/api/endpoints/pages/create.ts index b008cde84..ac80849aa 100644 --- a/packages/backend/src/server/api/endpoints/pages/create.ts +++ b/packages/backend/src/server/api/endpoints/pages/create.ts @@ -1,8 +1,11 @@ import ms from 'ms'; -import { Pages, DriveFiles } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; -import { Page } from '@/models/entities/page.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { DriveFilesRepository, PagesRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { Page } from '@/models/entities/Page.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { PageEntityService } from '@/core/entities/PageEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -59,45 +62,59 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - let eyeCatchingImage = null; - if (ps.eyeCatchingImageId != null) { - eyeCatchingImage = await DriveFiles.findOneBy({ - id: ps.eyeCatchingImageId, - userId: user.id, +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.pagesRepository) + private pagesRepository: PagesRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private pageEntityService: PageEntityService, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + let eyeCatchingImage = null; + if (ps.eyeCatchingImageId != null) { + eyeCatchingImage = await this.driveFilesRepository.findOneBy({ + id: ps.eyeCatchingImageId, + userId: me.id, + }); + + if (eyeCatchingImage == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + await this.pagesRepository.findBy({ + userId: me.id, + name: ps.name, + }).then(result => { + if (result.length > 0) { + throw new ApiError(meta.errors.nameAlreadyExists); + } + }); + + const page = await this.pagesRepository.insert(new Page({ + id: this.idService.genId(), + createdAt: new Date(), + updatedAt: new Date(), + title: ps.title, + name: ps.name, + summary: ps.summary, + content: ps.content, + variables: ps.variables, + script: ps.script, + eyeCatchingImageId: eyeCatchingImage ? eyeCatchingImage.id : null, + userId: me.id, + visibility: 'public', + alignCenter: ps.alignCenter, + hideTitleWhenPinned: ps.hideTitleWhenPinned, + font: ps.font, + })).then(x => this.pagesRepository.findOneByOrFail(x.identifiers[0])); + + return await this.pageEntityService.pack(page); }); - - if (eyeCatchingImage == null) { - throw new ApiError(meta.errors.noSuchFile); - } } - - await Pages.findBy({ - userId: user.id, - name: ps.name, - }).then(result => { - if (result.length > 0) { - throw new ApiError(meta.errors.nameAlreadyExists); - } - }); - - const page = await Pages.insert(new Page({ - id: genId(), - createdAt: new Date(), - updatedAt: new Date(), - title: ps.title, - name: ps.name, - summary: ps.summary, - content: ps.content, - variables: ps.variables, - script: ps.script, - eyeCatchingImageId: eyeCatchingImage ? eyeCatchingImage.id : null, - userId: user.id, - visibility: 'public', - alignCenter: ps.alignCenter, - hideTitleWhenPinned: ps.hideTitleWhenPinned, - font: ps.font, - })).then(x => Pages.findOneByOrFail(x.identifiers[0])); - - return await Pages.pack(page); -}); +} diff --git a/packages/backend/src/server/api/endpoints/pages/delete.ts b/packages/backend/src/server/api/endpoints/pages/delete.ts index a7708e658..4e9775576 100644 --- a/packages/backend/src/server/api/endpoints/pages/delete.ts +++ b/packages/backend/src/server/api/endpoints/pages/delete.ts @@ -1,5 +1,7 @@ -import { Pages } from '@/models/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { PagesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -33,14 +35,22 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const page = await Pages.findOneBy({ id: ps.pageId }); - if (page == null) { - throw new ApiError(meta.errors.noSuchPage); - } - if (page.userId !== user.id) { - throw new ApiError(meta.errors.accessDenied); - } +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.pagesRepository) + private pagesRepository: PagesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const page = await this.pagesRepository.findOneBy({ id: ps.pageId }); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + if (page.userId !== me.id) { + throw new ApiError(meta.errors.accessDenied); + } - await Pages.delete(page.id); -}); + await this.pagesRepository.delete(page.id); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/pages/featured.ts b/packages/backend/src/server/api/endpoints/pages/featured.ts index 5a149a626..3e3dbb083 100644 --- a/packages/backend/src/server/api/endpoints/pages/featured.ts +++ b/packages/backend/src/server/api/endpoints/pages/featured.ts @@ -1,5 +1,8 @@ -import { Pages } from '@/models/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { PagesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { PageEntityService } from '@/core/entities/PageEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['pages'], @@ -24,13 +27,23 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const query = Pages.createQueryBuilder('page') - .where('page.visibility = \'public\'') - .andWhere('page.likedCount > 0') - .orderBy('page.likedCount', 'DESC'); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.pagesRepository) + private pagesRepository: PagesRepository, - const pages = await query.take(10).getMany(); + private pageEntityService: PageEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.pagesRepository.createQueryBuilder('page') + .where('page.visibility = \'public\'') + .andWhere('page.likedCount > 0') + .orderBy('page.likedCount', 'DESC'); - return await Pages.packMany(pages, me); -}); + const pages = await query.take(10).getMany(); + + return await this.pageEntityService.packMany(pages, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/pages/like.ts b/packages/backend/src/server/api/endpoints/pages/like.ts index 269b539f7..f3c55fed8 100644 --- a/packages/backend/src/server/api/endpoints/pages/like.ts +++ b/packages/backend/src/server/api/endpoints/pages/like.ts @@ -1,6 +1,8 @@ -import { Pages, PageLikes } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { PagesRepository, PageLikesRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -40,33 +42,46 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const page = await Pages.findOneBy({ id: ps.pageId }); - if (page == null) { - throw new ApiError(meta.errors.noSuchPage); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.pagesRepository) + private pagesRepository: PagesRepository, + + @Inject(DI.pageLikesRepository) + private pageLikesRepository: PageLikesRepository, + + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const page = await this.pagesRepository.findOneBy({ id: ps.pageId }); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + + if (page.userId === me.id) { + throw new ApiError(meta.errors.yourPage); + } + + // if already liked + const exist = await this.pageLikesRepository.findOneBy({ + pageId: page.id, + userId: me.id, + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyLiked); + } + + // Create like + await this.pageLikesRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + pageId: page.id, + userId: me.id, + }); + + this.pagesRepository.increment({ id: page.id }, 'likedCount', 1); + }); } - - if (page.userId === user.id) { - throw new ApiError(meta.errors.yourPage); - } - - // if already liked - const exist = await PageLikes.findOneBy({ - pageId: page.id, - userId: user.id, - }); - - if (exist != null) { - throw new ApiError(meta.errors.alreadyLiked); - } - - // Create like - await PageLikes.insert({ - id: genId(), - createdAt: new Date(), - pageId: page.id, - userId: user.id, - }); - - Pages.increment({ id: page.id }, 'likedCount', 1); -}); +} diff --git a/packages/backend/src/server/api/endpoints/pages/show.ts b/packages/backend/src/server/api/endpoints/pages/show.ts index 5d37e86b9..6d73889d3 100644 --- a/packages/backend/src/server/api/endpoints/pages/show.ts +++ b/packages/backend/src/server/api/endpoints/pages/show.ts @@ -1,7 +1,10 @@ import { IsNull } from 'typeorm'; -import { Pages, Users } from '@/models/index.js'; -import { Page } from '@/models/entities/page.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UsersRepository, PagesRepository } from '@/models/index.js'; +import type { Page } from '@/models/entities/Page.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { PageEntityService } from '@/core/entities/PageEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -44,27 +47,40 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - let page: Page | null = null; +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - if (ps.pageId) { - page = await Pages.findOneBy({ id: ps.pageId }); - } else if (ps.name && ps.username) { - const author = await Users.findOneBy({ - host: IsNull(), - usernameLower: ps.username.toLowerCase(), + @Inject(DI.pagesRepository) + private pagesRepository: PagesRepository, + + private pageEntityService: PageEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + let page: Page | null = null; + + if (ps.pageId) { + page = await this.pagesRepository.findOneBy({ id: ps.pageId }); + } else if (ps.name && ps.username) { + const author = await this.usersRepository.findOneBy({ + host: IsNull(), + usernameLower: ps.username.toLowerCase(), + }); + if (author) { + page = await this.pagesRepository.findOneBy({ + name: ps.name, + userId: author.id, + }); + } + } + + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + + return await this.pageEntityService.pack(page, me); }); - if (author) { - page = await Pages.findOneBy({ - name: ps.name, - userId: author.id, - }); - } } - - if (page == null) { - throw new ApiError(meta.errors.noSuchPage); - } - - return await Pages.pack(page, user); -}); +} diff --git a/packages/backend/src/server/api/endpoints/pages/unlike.ts b/packages/backend/src/server/api/endpoints/pages/unlike.ts index 6b3a2bec1..88386739b 100644 --- a/packages/backend/src/server/api/endpoints/pages/unlike.ts +++ b/packages/backend/src/server/api/endpoints/pages/unlike.ts @@ -1,5 +1,7 @@ -import { Pages, PageLikes } from '@/models/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { PagesRepository, PageLikesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -33,23 +35,34 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const page = await Pages.findOneBy({ id: ps.pageId }); - if (page == null) { - throw new ApiError(meta.errors.noSuchPage); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.pagesRepository) + private pagesRepository: PagesRepository, + + @Inject(DI.pageLikesRepository) + private pageLikesRepository: PageLikesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const page = await this.pagesRepository.findOneBy({ id: ps.pageId }); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + + const exist = await this.pageLikesRepository.findOneBy({ + pageId: page.id, + userId: me.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notLiked); + } + + // Delete like + await this.pageLikesRepository.delete(exist.id); + + this.pagesRepository.decrement({ id: page.id }, 'likedCount', 1); + }); } - - const exist = await PageLikes.findOneBy({ - pageId: page.id, - userId: user.id, - }); - - if (exist == null) { - throw new ApiError(meta.errors.notLiked); - } - - // Delete like - await PageLikes.delete(exist.id); - - Pages.decrement({ id: page.id }, 'likedCount', 1); -}); +} diff --git a/packages/backend/src/server/api/endpoints/pages/update.ts b/packages/backend/src/server/api/endpoints/pages/update.ts index d241f585a..8980ac490 100644 --- a/packages/backend/src/server/api/endpoints/pages/update.ts +++ b/packages/backend/src/server/api/endpoints/pages/update.ts @@ -1,7 +1,9 @@ import ms from 'ms'; import { Not } from 'typeorm'; -import { Pages, DriveFiles } from '@/models/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { PagesRepository, DriveFilesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -65,52 +67,63 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const page = await Pages.findOneBy({ id: ps.pageId }); - if (page == null) { - throw new ApiError(meta.errors.noSuchPage); - } - if (page.userId !== user.id) { - throw new ApiError(meta.errors.accessDenied); - } +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.pagesRepository) + private pagesRepository: PagesRepository, - let eyeCatchingImage = null; - if (ps.eyeCatchingImageId != null) { - eyeCatchingImage = await DriveFiles.findOneBy({ - id: ps.eyeCatchingImageId, - userId: user.id, + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const page = await this.pagesRepository.findOneBy({ id: ps.pageId }); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + if (page.userId !== me.id) { + throw new ApiError(meta.errors.accessDenied); + } + + let eyeCatchingImage = null; + if (ps.eyeCatchingImageId != null) { + eyeCatchingImage = await this.driveFilesRepository.findOneBy({ + id: ps.eyeCatchingImageId, + userId: me.id, + }); + + if (eyeCatchingImage == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + await this.pagesRepository.findBy({ + id: Not(ps.pageId), + userId: me.id, + name: ps.name, + }).then(result => { + if (result.length > 0) { + throw new ApiError(meta.errors.nameAlreadyExists); + } + }); + + await this.pagesRepository.update(page.id, { + updatedAt: new Date(), + title: ps.title, + name: ps.name === undefined ? page.name : ps.name, + summary: ps.name === undefined ? page.summary : ps.summary, + content: ps.content, + variables: ps.variables, + script: ps.script, + alignCenter: ps.alignCenter === undefined ? page.alignCenter : ps.alignCenter, + hideTitleWhenPinned: ps.hideTitleWhenPinned === undefined ? page.hideTitleWhenPinned : ps.hideTitleWhenPinned, + font: ps.font === undefined ? page.font : ps.font, + eyeCatchingImageId: ps.eyeCatchingImageId === null + ? null + : ps.eyeCatchingImageId === undefined + ? page.eyeCatchingImageId + : eyeCatchingImage!.id, + }); }); - - if (eyeCatchingImage == null) { - throw new ApiError(meta.errors.noSuchFile); - } } - - await Pages.findBy({ - id: Not(ps.pageId), - userId: user.id, - name: ps.name, - }).then(result => { - if (result.length > 0) { - throw new ApiError(meta.errors.nameAlreadyExists); - } - }); - - await Pages.update(page.id, { - updatedAt: new Date(), - title: ps.title, - name: ps.name === undefined ? page.name : ps.name, - summary: ps.name === undefined ? page.summary : ps.summary, - content: ps.content, - variables: ps.variables, - script: ps.script, - alignCenter: ps.alignCenter === undefined ? page.alignCenter : ps.alignCenter, - hideTitleWhenPinned: ps.hideTitleWhenPinned === undefined ? page.hideTitleWhenPinned : ps.hideTitleWhenPinned, - font: ps.font === undefined ? page.font : ps.font, - eyeCatchingImageId: ps.eyeCatchingImageId === null - ? null - : ps.eyeCatchingImageId === undefined - ? page.eyeCatchingImageId - : eyeCatchingImage!.id, - }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/ping.ts b/packages/backend/src/server/api/endpoints/ping.ts index 2891a0860..4bb62b298 100644 --- a/packages/backend/src/server/api/endpoints/ping.ts +++ b/packages/backend/src/server/api/endpoints/ping.ts @@ -1,4 +1,5 @@ -import define from '../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; export const meta = { requireCredential: false, @@ -24,8 +25,14 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async () => { - return { - pong: Date.now(), - }; -}); +@Injectable() +export default class extends Endpoint { + constructor( + ) { + super(meta, paramDef, async () => { + return { + pong: Date.now(), + }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/pinned-users.ts b/packages/backend/src/server/api/endpoints/pinned-users.ts index 41595b47d..573331e0d 100644 --- a/packages/backend/src/server/api/endpoints/pinned-users.ts +++ b/packages/backend/src/server/api/endpoints/pinned-users.ts @@ -1,9 +1,12 @@ import { IsNull } from 'typeorm'; -import { Users } from '@/models/index.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UsersRepository } from '@/models/index.js'; import * as Acct from '@/misc/acct.js'; -import { User } from '@/models/entities/user.js'; -import define from '../define.js'; +import type { User } from '@/models/entities/User.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { MetaService } from '@/core/MetaService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['users'], @@ -28,13 +31,24 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const meta = await fetchMeta(); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - const users = await Promise.all(meta.pinnedUsers.map(acct => Acct.parse(acct)).map(acct => Users.findOneBy({ - usernameLower: acct.username.toLowerCase(), - host: acct.host ?? IsNull(), - }))); + private metaService: MetaService, + private userEntityService: UserEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const meta = await this.metaService.fetch(); - return await Users.packMany(users.filter(x => x !== undefined) as User[], me, { detail: true }); -}); + const users = await Promise.all(meta.pinnedthis.usersRepository.map(acct => Acct.parse(acct)).map(acct => this.usersRepository.findOneBy({ + usernameLower: acct.username.toLowerCase(), + host: acct.host ?? IsNull(), + }))); + + return await this.userEntityService.packMany(users.filter(x => x !== undefined) as User[], me, { detail: true }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/promo/read.ts b/packages/backend/src/server/api/endpoints/promo/read.ts index c6a940c65..7c8188ce3 100644 --- a/packages/backend/src/server/api/endpoints/promo/read.ts +++ b/packages/backend/src/server/api/endpoints/promo/read.ts @@ -1,8 +1,10 @@ -import { PromoReads } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { PromoReadsRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { getNote } from '../../common/getters.js'; +import { GetterService } from '../../common/GetterService.js'; export const meta = { tags: ['notes'], @@ -27,25 +29,36 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const note = await getNote(ps.noteId).catch(e => { - if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw e; - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.promoReadsRepository) + private promoReadsRepository: PromoReadsRepository, - const exist = await PromoReads.findOneBy({ - noteId: note.id, - userId: user.id, - }); + private idService: IdService, + private getterService: GetterService, + ) { + 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 (exist != null) { - return; + const exist = await this.promoReadsRepository.findOneBy({ + noteId: note.id, + userId: me.id, + }); + + if (exist != null) { + return; + } + + await this.promoReadsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + noteId: note.id, + userId: me.id, + }); + }); } - - await PromoReads.insert({ - id: genId(), - createdAt: new Date(), - noteId: note.id, - userId: user.id, - }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/request-reset-password.ts b/packages/backend/src/server/api/endpoints/request-reset-password.ts index 511a6bbb5..476623953 100644 --- a/packages/backend/src/server/api/endpoints/request-reset-password.ts +++ b/packages/backend/src/server/api/endpoints/request-reset-password.ts @@ -1,13 +1,14 @@ import rndstr from 'rndstr'; import ms from 'ms'; import { IsNull } from 'typeorm'; -import { publishMainStream } from '@/services/stream.js'; -import config from '@/config/index.js'; -import { Users, UserProfiles, PasswordResetRequests } from '@/models/index.js'; -import { sendEmail } from '@/services/send-email.js'; -import { genId } from '@/misc/gen-id.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { PasswordResetRequestsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { IdService } from '@/core/IdService.js'; +import { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; +import { EmailService } from '@/core/EmailService.js'; import { ApiError } from '../error.js'; -import define from '../define.js'; export const meta = { tags: ['reset password'], @@ -36,41 +37,61 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - const user = await Users.findOneBy({ - usernameLower: ps.username.toLowerCase(), - host: IsNull(), - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - // 合致するユーザーが登録されていなかったら無視 - if (user == null) { - return; + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.passwordResetRequestsRepository) + private passwordResetRequestsRepository: PasswordResetRequestsRepository, + + private idService: IdService, + private emailService: EmailService, + ) { + super(meta, paramDef, async (ps, me) => { + const user = await this.usersRepository.findOneBy({ + usernameLower: ps.username.toLowerCase(), + host: IsNull(), + }); + + // 合致するユーザーが登録されていなかったら無視 + if (user == null) { + return; + } + + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + + // 合致するメアドが登録されていなかったら無視 + if (profile.email !== ps.email) { + return; + } + + // メアドが認証されていなかったら無視 + if (!profile.emailVerified) { + return; + } + + const token = rndstr('a-z0-9', 64); + + await this.passwordResetRequestsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: profile.userId, + token, + }); + + const link = `${this.config.url}/reset-password/${token}`; + + this.emailService.sendEmail(ps.email, 'Password reset requested', + `To reset password, please click this link:
${link}`, + `To reset password, please click this link: ${link}`); + }); } - - const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - - // 合致するメアドが登録されていなかったら無視 - if (profile.email !== ps.email) { - return; - } - - // メアドが認証されていなかったら無視 - if (!profile.emailVerified) { - return; - } - - const token = rndstr('a-z0-9', 64); - - await PasswordResetRequests.insert({ - id: genId(), - createdAt: new Date(), - userId: profile.userId, - token, - }); - - const link = `${config.url}/reset-password/${token}`; - - sendEmail(ps.email, 'Password reset requested', - `To reset password, please click this link:
${link}`, - `To reset password, please click this link: ${link}`); -}); +} diff --git a/packages/backend/src/server/api/endpoints/reset-db.ts b/packages/backend/src/server/api/endpoints/reset-db.ts index 140f96d57..526efbc2f 100644 --- a/packages/backend/src/server/api/endpoints/reset-db.ts +++ b/packages/backend/src/server/api/endpoints/reset-db.ts @@ -1,5 +1,9 @@ -import { resetDb } from '@/db/postgre.js'; -import define from '../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import Redis from 'ioredis'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { resetDb } from '@/misc/reset-db.js'; import { ApiError } from '../error.js'; export const meta = { @@ -21,10 +25,22 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - if (process.env.NODE_ENV !== 'test') throw 'NODE_ENV is not a test'; +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.db) + private db: DataSource, - await resetDb(); + @Inject(DI.redis) + private redisClient: Redis.Redis, + ) { + super(meta, paramDef, async (ps, me) => { + if (process.env.NODE_ENV !== 'test') throw 'NODE_ENV is not a test'; - await new Promise(resolve => setTimeout(resolve, 1000)); -}); + await redisClient.flushdb(); + await resetDb(this.db); + + await new Promise(resolve => setTimeout(resolve, 1000)); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/reset-password.ts b/packages/backend/src/server/api/endpoints/reset-password.ts index 797169c2c..48edde519 100644 --- a/packages/backend/src/server/api/endpoints/reset-password.ts +++ b/packages/backend/src/server/api/endpoints/reset-password.ts @@ -1,7 +1,9 @@ import bcrypt from 'bcryptjs'; -import { publishMainStream } from '@/services/stream.js'; -import { Users, UserProfiles, PasswordResetRequests } from '@/models/index.js'; -import define from '../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UserProfilesRepository, PasswordResetRequestsRepository } from '@/models/index.js'; +import type { UsersRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../error.js'; export const meta = { @@ -26,23 +28,34 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const req = await PasswordResetRequests.findOneByOrFail({ - token: ps.token, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.passwordResetRequestsRepository) + private passwordResetRequestsRepository: PasswordResetRequestsRepository, - // 発行してから30分以上経過していたら無効 - if (Date.now() - req.createdAt.getTime() > 1000 * 60 * 30) { - throw new Error(); // TODO + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const req = await this.passwordResetRequestsRepository.findOneByOrFail({ + token: ps.token, + }); + + // 発行してから30分以上経過していたら無効 + if (Date.now() - req.createdAt.getTime() > 1000 * 60 * 30) { + throw new Error(); // TODO + } + + // Generate hash of password + const salt = await bcrypt.genSalt(8); + const hash = await bcrypt.hash(ps.password, salt); + + await this.userProfilesRepository.update(req.userId, { + password: hash, + }); + + this.passwordResetRequestsRepository.delete(req.id); + }); } - - // Generate hash of password - const salt = await bcrypt.genSalt(8); - const hash = await bcrypt.hash(ps.password, salt); - - await UserProfiles.update(req.userId, { - password: hash, - }); - - PasswordResetRequests.delete(req.id); -}); +} diff --git a/packages/backend/src/server/api/endpoints/server-info.ts b/packages/backend/src/server/api/endpoints/server-info.ts index 99f3730e9..8989a3073 100644 --- a/packages/backend/src/server/api/endpoints/server-info.ts +++ b/packages/backend/src/server/api/endpoints/server-info.ts @@ -1,6 +1,7 @@ import * as os from 'node:os'; import si from 'systeminformation'; -import define from '../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; export const meta = { requireCredential: false, @@ -15,22 +16,28 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async () => { - const memStats = await si.mem(); - const fsStats = await si.fsSize(); +@Injectable() +export default class extends Endpoint { + constructor( + ) { + super(meta, paramDef, async () => { + const memStats = await si.mem(); + const fsStats = await si.fsSize(); - return { - machine: os.hostname(), - cpu: { - model: os.cpus()[0].model, - cores: os.cpus().length, - }, - mem: { - total: memStats.total, - }, - fs: { - total: fsStats[0].size, - used: fsStats[0].used, - }, - }; -}); + return { + machine: os.hostname(), + cpu: { + model: os.cpus()[0].model, + cores: os.cpus().length, + }, + mem: { + total: memStats.total, + }, + fs: { + total: fsStats[0].size, + used: fsStats[0].used, + }, + }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/stats.ts b/packages/backend/src/server/api/endpoints/stats.ts index cc94f8bf2..17af75578 100644 --- a/packages/backend/src/server/api/endpoints/stats.ts +++ b/packages/backend/src/server/api/endpoints/stats.ts @@ -1,7 +1,8 @@ -import { Instances, NoteReactions, Notes, Users } from '@/models/index.js'; -import define from '../define.js'; -import { } from '@/services/chart/index.js'; +import { Inject, Injectable } from '@nestjs/common'; import { IsNull } from 'typeorm'; +import { InstancesRepository, NotesRepository, UsersRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; export const meta = { requireCredential: false, @@ -51,34 +52,51 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async () => { - const [ - notesCount, - originalNotesCount, - usersCount, - originalUsersCount, - reactionsCount, - //originalReactionsCount, - instances, - ] = await Promise.all([ - Notes.count({ cache: 3600000 }), // 1 hour - Notes.count({ where: { userHost: IsNull() }, cache: 3600000 }), - Users.count({ cache: 3600000 }), - Users.count({ where: { host: IsNull() }, cache: 3600000 }), - NoteReactions.count({ cache: 3600000 }), // 1 hour - //NoteReactions.count({ where: { userHost: IsNull() }, cache: 3600000 }), - Instances.count({ cache: 3600000 }), - ]); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - return { - notesCount, - originalNotesCount, - usersCount, - originalUsersCount, - reactionsCount, - //originalReactionsCount, - instances, - driveUsageLocal: 0, - driveUsageRemote: 0, - }; -}); + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + @Inject(DI.noteReactionsRepository) + private noteReactionsRepository: NoteReactionsRepository, + ) { + super(meta, paramDef, async () => { + const [ + notesCount, + originalNotesCount, + usersCount, + originalUsersCount, + reactionsCount, + //originalReactionsCount, + instances, + ] = await Promise.all([ + this.notesRepository.count({ cache: 3600000 }), // 1 hour + this.notesRepository.count({ where: { userHost: IsNull() }, cache: 3600000 }), + this.usersRepository.count({ cache: 3600000 }), + this.usersRepository.count({ where: { host: IsNull() }, cache: 3600000 }), + this.noteReactionsRepository.count({ cache: 3600000 }), // 1 hour + //this.noteReactionsRepository.count({ where: { userHost: IsNull() }, cache: 3600000 }), + this.instancesRepository.count({ cache: 3600000 }), + ]); + + return { + notesCount, + originalNotesCount, + usersCount, + originalUsersCount, + reactionsCount, + //originalReactionsCount, + instances, + driveUsageLocal: 0, + driveUsageRemote: 0, + }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/sw/register.ts b/packages/backend/src/server/api/endpoints/sw/register.ts index 437f8874f..73a084c2a 100644 --- a/packages/backend/src/server/api/endpoints/sw/register.ts +++ b/packages/backend/src/server/api/endpoints/sw/register.ts @@ -1,7 +1,9 @@ -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { genId } from '@/misc/gen-id.js'; -import { SwSubscriptions } from '@/models/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { IdService } from '@/core/IdService.js'; +import { SwSubscriptionsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { MetaService } from '@/core/MetaService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['account'], @@ -38,35 +40,46 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - // if already subscribed - const exist = await SwSubscriptions.findOneBy({ - userId: user.id, - endpoint: ps.endpoint, - auth: ps.auth, - publickey: ps.publickey, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.swSubscriptionsRepository) + private swSubscriptionsRepository: SwSubscriptionsRepository, - const instance = await fetchMeta(true); + private idService: IdService, + private metaService: MetaService, + ) { + super(meta, paramDef, async (ps, me) => { + // if already subscribed + const exist = await this.swSubscriptionsRepository.findOneBy({ + userId: me.id, + endpoint: ps.endpoint, + auth: ps.auth, + publickey: ps.publickey, + }); - if (exist != null) { - return { - state: 'already-subscribed' as const, - key: instance.swPublicKey, - }; + const instance = await this.metaService.fetch(true); + + if (exist != null) { + return { + state: 'already-subscribed' as const, + key: instance.swPublicKey, + }; + } + + await this.swSubscriptionsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: me.id, + endpoint: ps.endpoint, + auth: ps.auth, + publickey: ps.publickey, + }); + + return { + state: 'subscribed' as const, + key: instance.swPublicKey, + }; + }); } - - await SwSubscriptions.insert({ - id: genId(), - createdAt: new Date(), - userId: user.id, - endpoint: ps.endpoint, - auth: ps.auth, - publickey: ps.publickey, - }); - - return { - state: 'subscribed' as const, - key: instance.swPublicKey, - }; -}); +} diff --git a/packages/backend/src/server/api/endpoints/sw/unregister.ts b/packages/backend/src/server/api/endpoints/sw/unregister.ts index c19e06b87..feb673015 100644 --- a/packages/backend/src/server/api/endpoints/sw/unregister.ts +++ b/packages/backend/src/server/api/endpoints/sw/unregister.ts @@ -1,5 +1,7 @@ -import { SwSubscriptions } from '@/models/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { SwSubscriptionsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['account'], @@ -18,9 +20,17 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - await SwSubscriptions.delete({ - userId: user.id, - endpoint: ps.endpoint, - }); -}); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.swSubscriptionsRepository) + private swSubscriptionsRepository: SwSubscriptionsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + await this.swSubscriptionsRepository.delete({ + userId: me.id, + endpoint: ps.endpoint, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/test.ts b/packages/backend/src/server/api/endpoints/test.ts index 9949237a7..39ea1f217 100644 --- a/packages/backend/src/server/api/endpoints/test.ts +++ b/packages/backend/src/server/api/endpoints/test.ts @@ -1,4 +1,5 @@ -import define from '../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; export const meta = { tags: ['non-productive'], @@ -21,6 +22,12 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - return ps; -}); +@Injectable() +export default class extends Endpoint { + constructor( + ) { + super(meta, paramDef, async (ps, me) => { + return ps; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/username/available.ts b/packages/backend/src/server/api/endpoints/username/available.ts index 3e41aeaed..56474d698 100644 --- a/packages/backend/src/server/api/endpoints/username/available.ts +++ b/packages/backend/src/server/api/endpoints/username/available.ts @@ -1,6 +1,9 @@ import { IsNull } from 'typeorm'; -import { Users, UsedUsernames } from '@/models/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UsedUsernamesRepository, UsersRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { localUsernameSchema } from '@/models/entities/User.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['users'], @@ -22,22 +25,33 @@ export const meta = { export const paramDef = { type: 'object', properties: { - username: Users.localUsernameSchema, + username: localUsernameSchema, }, required: ['username'], } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - // Get exist - const exist = await Users.countBy({ - host: IsNull(), - usernameLower: ps.username.toLowerCase(), - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - const exist2 = await UsedUsernames.countBy({ username: ps.username.toLowerCase() }); + @Inject(DI.usedUsernamesRepository) + private usedUsernamesRepository: UsedUsernamesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + // Get exist + const exist = await this.usersRepository.countBy({ + host: IsNull(), + usernameLower: ps.username.toLowerCase(), + }); - return { - available: exist === 0 && exist2 === 0, - }; -}); + const exist2 = await this.usedUsernamesRepository.countBy({ username: ps.username.toLowerCase() }); + + return { + available: exist === 0 && exist2 === 0, + }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users.ts b/packages/backend/src/server/api/endpoints/users.ts index 3a8211374..3d05ec2e1 100644 --- a/packages/backend/src/server/api/endpoints/users.ts +++ b/packages/backend/src/server/api/endpoints/users.ts @@ -1,7 +1,9 @@ -import { Users } from '@/models/index.js'; -import define from '../define.js'; -import { generateMutedUserQueryForUsers } from '../common/generate-muted-user-query.js'; -import { generateBlockQueryForUsers } from '../common/generate-block-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UsersRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['users'], @@ -38,43 +40,54 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const query = Users.createQueryBuilder('user'); - query.where('user.isExplorable = TRUE'); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - switch (ps.state) { - case 'admin': query.andWhere('user.isAdmin = TRUE'); break; - case 'moderator': query.andWhere('user.isModerator = TRUE'); break; - case 'adminOrModerator': query.andWhere('user.isAdmin = TRUE OR user.isModerator = TRUE'); break; - case 'alive': query.andWhere('user.updatedAt > :date', { date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5) }); break; + private userEntityService: UserEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.usersRepository.createQueryBuilder('user'); + query.where('user.isExplorable = TRUE'); + + switch (ps.state) { + case 'admin': query.andWhere('user.isAdmin = TRUE'); break; + case 'moderator': query.andWhere('user.isModerator = TRUE'); break; + case 'adminOrModerator': query.andWhere('user.isAdmin = TRUE OR user.isModerator = TRUE'); break; + case 'alive': query.andWhere('user.updatedAt > :date', { date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5) }); break; + } + + switch (ps.origin) { + case 'local': query.andWhere('user.host IS NULL'); break; + case 'remote': query.andWhere('user.host IS NOT NULL'); break; + } + + if (ps.hostname) { + query.andWhere('user.host = :hostname', { hostname: ps.hostname.toLowerCase() }); + } + + switch (ps.sort) { + case '+follower': query.orderBy('user.followersCount', 'DESC'); break; + case '-follower': query.orderBy('user.followersCount', 'ASC'); break; + case '+createdAt': query.orderBy('user.createdAt', 'DESC'); break; + case '-createdAt': query.orderBy('user.createdAt', 'ASC'); break; + case '+updatedAt': query.andWhere('user.updatedAt IS NOT NULL').orderBy('user.updatedAt', 'DESC'); break; + case '-updatedAt': query.andWhere('user.updatedAt IS NOT NULL').orderBy('user.updatedAt', 'ASC'); break; + default: query.orderBy('user.id', 'ASC'); break; + } + + if (me) this.queryService.generateMutedUserQueryForUsers(query, me); + if (me) this.queryService.generateBlockQueryForUsers(query, me); + + query.take(ps.limit); + query.skip(ps.offset); + + const users = await query.getMany(); + + return await this.userEntityService.packMany(users, me, { detail: true }); + }); } - - switch (ps.origin) { - case 'local': query.andWhere('user.host IS NULL'); break; - case 'remote': query.andWhere('user.host IS NOT NULL'); break; - } - - if (ps.hostname) { - query.andWhere('user.host = :hostname', { hostname: ps.hostname.toLowerCase() }); - } - - switch (ps.sort) { - case '+follower': query.orderBy('user.followersCount', 'DESC'); break; - case '-follower': query.orderBy('user.followersCount', 'ASC'); break; - case '+createdAt': query.orderBy('user.createdAt', 'DESC'); break; - case '-createdAt': query.orderBy('user.createdAt', 'ASC'); break; - case '+updatedAt': query.andWhere('user.updatedAt IS NOT NULL').orderBy('user.updatedAt', 'DESC'); break; - case '-updatedAt': query.andWhere('user.updatedAt IS NOT NULL').orderBy('user.updatedAt', 'ASC'); break; - default: query.orderBy('user.id', 'ASC'); break; - } - - if (me) generateMutedUserQueryForUsers(query, me); - if (me) generateBlockQueryForUsers(query, me); - - query.take(ps.limit); - query.skip(ps.offset); - - const users = await query.getMany(); - - return await Users.packMany(users, me, { detail: true }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/users/clips.ts b/packages/backend/src/server/api/endpoints/users/clips.ts index 09fdf27c2..2d5545cba 100644 --- a/packages/backend/src/server/api/endpoints/users/clips.ts +++ b/packages/backend/src/server/api/endpoints/users/clips.ts @@ -1,6 +1,9 @@ -import { Clips } from '@/models/index.js'; -import define from '../../define.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { ClipsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['users', 'clips'], @@ -30,14 +33,25 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const query = makePaginationQuery(Clips.createQueryBuilder('clip'), ps.sinceId, ps.untilId) - .andWhere('clip.userId = :userId', { userId: ps.userId }) - .andWhere('clip.isPublic = true'); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.clipsRepository) + private clipsRepository: ClipsRepository, - const clips = await query - .take(ps.limit) - .getMany(); + private clipEntityService: ClipEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.clipsRepository.createQueryBuilder('clip'), ps.sinceId, ps.untilId) + .andWhere('clip.userId = :userId', { userId: ps.userId }) + .andWhere('clip.isPublic = true'); - return await Clips.packMany(clips); -}); + const clips = await query + .take(ps.limit) + .getMany(); + + return await this.clipEntityService.packMany(clips); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/followers.ts b/packages/backend/src/server/api/endpoints/users/followers.ts index 7f9f98076..08bcdd9f8 100644 --- a/packages/backend/src/server/api/endpoints/users/followers.ts +++ b/packages/backend/src/server/api/endpoints/users/followers.ts @@ -1,9 +1,12 @@ import { IsNull } from 'typeorm'; -import { Users, Followings, UserProfiles } from '@/models/index.js'; -import { toPunyNullable } from '@/misc/convert-host.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UsersRepository, FollowingsRepository, UserProfilesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { FollowingEntityService } from '@/core/entities/FollowingEntityService.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; export const meta = { tags: ['users'], @@ -66,42 +69,60 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const user = await Users.findOneBy(ps.userId != null - ? { id: ps.userId } - : { usernameLower: ps.username!.toLowerCase(), host: toPunyNullable(ps.host) ?? IsNull() }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - if (user == null) { - throw new ApiError(meta.errors.noSuchUser); - } + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, - const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, - if (profile.ffVisibility === 'private') { - if (me == null || (me.id !== user.id)) { - throw new ApiError(meta.errors.forbidden); - } - } else if (profile.ffVisibility === 'followers') { - if (me == null) { - throw new ApiError(meta.errors.forbidden); - } else if (me.id !== user.id) { - const following = await Followings.findOneBy({ - followeeId: user.id, - followerId: me.id, - }); - if (following == null) { - throw new ApiError(meta.errors.forbidden); + private utilityService: UtilityService, + private followingEntityService: FollowingEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const user = await this.usersRepository.findOneBy(ps.userId != null + ? { id: ps.userId } + : { usernameLower: ps.username!.toLowerCase(), host: this.utilityService.toPunyNullable(ps.host) ?? IsNull() }); + + if (user == null) { + throw new ApiError(meta.errors.noSuchUser); } - } + + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + + if (profile.ffVisibility === 'private') { + if (me == null || (me.id !== user.id)) { + throw new ApiError(meta.errors.forbidden); + } + } else if (profile.ffVisibility === 'followers') { + if (me == null) { + throw new ApiError(meta.errors.forbidden); + } else if (me.id !== user.id) { + const following = await this.followingsRepository.findOneBy({ + followeeId: user.id, + followerId: me.id, + }); + if (following == null) { + throw new ApiError(meta.errors.forbidden); + } + } + } + + const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId) + .andWhere('following.followeeId = :userId', { userId: user.id }) + .innerJoinAndSelect('following.follower', 'follower'); + + const followings = await query + .take(ps.limit) + .getMany(); + + return await this.followingEntityService.packMany(followings, me, { populateFollower: true }); + }); } - - const query = makePaginationQuery(Followings.createQueryBuilder('following'), ps.sinceId, ps.untilId) - .andWhere('following.followeeId = :userId', { userId: user.id }) - .innerJoinAndSelect('following.follower', 'follower'); - - const followings = await query - .take(ps.limit) - .getMany(); - - return await Followings.packMany(followings, me, { populateFollower: true }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/users/following.ts b/packages/backend/src/server/api/endpoints/users/following.ts index 0aaa810f7..225ab5210 100644 --- a/packages/backend/src/server/api/endpoints/users/following.ts +++ b/packages/backend/src/server/api/endpoints/users/following.ts @@ -1,9 +1,12 @@ import { IsNull } from 'typeorm'; -import { Users, Followings, UserProfiles } from '@/models/index.js'; -import { toPunyNullable } from '@/misc/convert-host.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UsersRepository, FollowingsRepository, UserProfilesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { FollowingEntityService } from '@/core/entities/FollowingEntityService.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; export const meta = { tags: ['users'], @@ -66,42 +69,60 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const user = await Users.findOneBy(ps.userId != null - ? { id: ps.userId } - : { usernameLower: ps.username!.toLowerCase(), host: toPunyNullable(ps.host) ?? IsNull() }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - if (user == null) { - throw new ApiError(meta.errors.noSuchUser); - } + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, - const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, - if (profile.ffVisibility === 'private') { - if (me == null || (me.id !== user.id)) { - throw new ApiError(meta.errors.forbidden); - } - } else if (profile.ffVisibility === 'followers') { - if (me == null) { - throw new ApiError(meta.errors.forbidden); - } else if (me.id !== user.id) { - const following = await Followings.findOneBy({ - followeeId: user.id, - followerId: me.id, - }); - if (following == null) { - throw new ApiError(meta.errors.forbidden); + private utilityService: UtilityService, + private followingEntityService: FollowingEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const user = await this.usersRepository.findOneBy(ps.userId != null + ? { id: ps.userId } + : { usernameLower: ps.username!.toLowerCase(), host: this.utilityService.toPunyNullable(ps.host) ?? IsNull() }); + + if (user == null) { + throw new ApiError(meta.errors.noSuchUser); } - } + + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + + if (profile.ffVisibility === 'private') { + if (me == null || (me.id !== user.id)) { + throw new ApiError(meta.errors.forbidden); + } + } else if (profile.ffVisibility === 'followers') { + if (me == null) { + throw new ApiError(meta.errors.forbidden); + } else if (me.id !== user.id) { + const following = await this.followingsRepository.findOneBy({ + followeeId: user.id, + followerId: me.id, + }); + if (following == null) { + throw new ApiError(meta.errors.forbidden); + } + } + } + + const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId) + .andWhere('following.followerId = :userId', { userId: user.id }) + .innerJoinAndSelect('following.followee', 'followee'); + + const followings = await query + .take(ps.limit) + .getMany(); + + return await this.followingEntityService.packMany(followings, me, { populateFollowee: true }); + }); } - - const query = makePaginationQuery(Followings.createQueryBuilder('following'), ps.sinceId, ps.untilId) - .andWhere('following.followerId = :userId', { userId: user.id }) - .innerJoinAndSelect('following.followee', 'followee'); - - const followings = await query - .take(ps.limit) - .getMany(); - - return await Followings.packMany(followings, me, { populateFollowee: true }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/users/gallery/posts.ts b/packages/backend/src/server/api/endpoints/users/gallery/posts.ts index 35bf2df59..2d28d6ca0 100644 --- a/packages/backend/src/server/api/endpoints/users/gallery/posts.ts +++ b/packages/backend/src/server/api/endpoints/users/gallery/posts.ts @@ -1,6 +1,9 @@ -import define from '../../../define.js'; -import { GalleryPosts } from '@/models/index.js'; -import { makePaginationQuery } from '../../../common/make-pagination-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GalleryPostsRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['users', 'gallery'], @@ -30,13 +33,24 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const query = makePaginationQuery(GalleryPosts.createQueryBuilder('post'), ps.sinceId, ps.untilId) - .andWhere(`post.userId = :userId`, { userId: ps.userId }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.galleryPostsRepository) + private galleryPostsRepository: GalleryPostsRepository, - const posts = await query - .take(ps.limit) - .getMany(); + private galleryPostEntityService: GalleryPostEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.galleryPostsRepository.createQueryBuilder('post'), ps.sinceId, ps.untilId) + .andWhere('post.userId = :userId', { userId: ps.userId }); - return await GalleryPosts.packMany(posts, user); -}); + const posts = await query + .take(ps.limit) + .getMany(); + + return await this.galleryPostEntityService.packMany(posts, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts b/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts index 56965d306..3eeca7562 100644 --- a/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts +++ b/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts @@ -1,9 +1,12 @@ import { Not, In, IsNull } from 'typeorm'; -import { maximum } from '@/prelude/array.js'; -import { Notes, Users } from '@/models/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { maximum } from '@/misc/prelude/array.js'; +import { NotesRepository, UsersRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { getUser } from '../../common/getters.js'; +import { GetterService } from '../../common/GetterService.js'; export const meta = { tags: ['users'], @@ -51,64 +54,78 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - // Lookup user - const user = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw e; - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - // Fetch recent notes - const recentNotes = await Notes.find({ - where: { - userId: user.id, - replyId: Not(IsNull()), - }, - order: { - id: -1, - }, - take: 1000, - select: ['replyId'], - }); + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, - // 投稿が少なかったら中断 - if (recentNotes.length === 0) { - return []; + private userEntityService: UserEntityService, + private getterService: GetterService, + ) { + super(meta, paramDef, async (ps, me) => { + // Lookup user + const user = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + // Fetch recent notes + const recentNotes = await this.notesRepository.find({ + where: { + userId: user.id, + replyId: Not(IsNull()), + }, + order: { + id: -1, + }, + take: 1000, + select: ['replyId'], + }); + + // 投稿が少なかったら中断 + if (recentNotes.length === 0) { + return []; + } + + // TODO ミュートを考慮 + const replyTargetNotes = await this.notesRepository.find({ + where: { + id: In(recentNotes.map(p => p.replyId)), + }, + select: ['userId'], + }); + + const repliedUsers: any = {}; + + // Extract replies from recent notes + for (const userId of replyTargetNotes.map(x => x.userId.toString())) { + if (repliedUsers[userId]) { + repliedUsers[userId]++; + } else { + repliedUsers[userId] = 1; + } + } + + // Calc peak + const peak = maximum(Object.values(repliedUsers)); + + // Sort replies by frequency + const repliedUsersSorted = Object.keys(repliedUsers).sort((a, b) => repliedUsers[b] - repliedUsers[a]); + + // Extract top replied users + const topRepliedUsers = repliedUsersSorted.slice(0, ps.limit); + + // Make replies object (includes weights) + const repliesObj = await Promise.all(topRepliedUsers.map(async (user) => ({ + user: await this.userEntityService.pack(user, me, { detail: true }), + weight: repliedUsers[user] / peak, + }))); + + return repliesObj; + }); } - - // TODO ミュートを考慮 - const replyTargetNotes = await Notes.find({ - where: { - id: In(recentNotes.map(p => p.replyId)), - }, - select: ['userId'], - }); - - const repliedUsers: any = {}; - - // Extract replies from recent notes - for (const userId of replyTargetNotes.map(x => x.userId.toString())) { - if (repliedUsers[userId]) { - repliedUsers[userId]++; - } else { - repliedUsers[userId] = 1; - } - } - - // Calc peak - const peak = maximum(Object.values(repliedUsers)); - - // Sort replies by frequency - const repliedUsersSorted = Object.keys(repliedUsers).sort((a, b) => repliedUsers[b] - repliedUsers[a]); - - // Extract top replied users - const topRepliedUsers = repliedUsersSorted.slice(0, ps.limit); - - // Make replies object (includes weights) - const repliesObj = await Promise.all(topRepliedUsers.map(async (user) => ({ - user: await Users.pack(user, me, { detail: true }), - weight: repliedUsers[user] / peak, - }))); - - return repliesObj; -}); +} diff --git a/packages/backend/src/server/api/endpoints/users/groups/create.ts b/packages/backend/src/server/api/endpoints/users/groups/create.ts index 4a6362a3c..5d7ad84ae 100644 --- a/packages/backend/src/server/api/endpoints/users/groups/create.ts +++ b/packages/backend/src/server/api/endpoints/users/groups/create.ts @@ -1,8 +1,11 @@ -import { UserGroups, UserGroupJoinings } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; -import { UserGroup } from '@/models/entities/user-group.js'; -import { UserGroupJoining } from '@/models/entities/user-group-joining.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UserGroupsRepository, UserGroupJoiningsRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import type { UserGroup } from '@/models/entities/UserGroup.js'; +import type { UserGroupJoining } from '@/models/entities/UserGroupJoining.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserGroupEntityService } from '@/core/entities/UserGroupEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['groups'], @@ -29,21 +32,35 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const userGroup = await UserGroups.insert({ - id: genId(), - createdAt: new Date(), - userId: user.id, - name: ps.name, - } as UserGroup).then(x => UserGroups.findOneByOrFail(x.identifiers[0])); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userGroupsRepository) + private userGroupsRepository: UserGroupsRepository, - // Push the owner - await UserGroupJoinings.insert({ - id: genId(), - createdAt: new Date(), - userId: user.id, - userGroupId: userGroup.id, - } as UserGroupJoining); + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, - return await UserGroups.pack(userGroup); -}); + private userGroupEntityService: UserGroupEntityService, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const userGroup = await this.userGroupsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: me.id, + name: ps.name, + } as UserGroup).then(x => this.userGroupsRepository.findOneByOrFail(x.identifiers[0])); + + // Push the owner + await this.userGroupJoiningsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: me.id, + userGroupId: userGroup.id, + } as UserGroupJoining); + + return await this.userGroupEntityService.pack(userGroup); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/groups/delete.ts b/packages/backend/src/server/api/endpoints/users/groups/delete.ts index 2ff1f9aec..50156b049 100644 --- a/packages/backend/src/server/api/endpoints/users/groups/delete.ts +++ b/packages/backend/src/server/api/endpoints/users/groups/delete.ts @@ -1,5 +1,7 @@ -import { UserGroups } from '@/models/index.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UserGroupsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -29,15 +31,23 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const userGroup = await UserGroups.findOneBy({ - id: ps.groupId, - userId: user.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userGroupsRepository) + private userGroupsRepository: UserGroupsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const userGroup = await this.userGroupsRepository.findOneBy({ + id: ps.groupId, + userId: me.id, + }); - if (userGroup == null) { - throw new ApiError(meta.errors.noSuchGroup); + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + await this.userGroupsRepository.delete(userGroup.id); + }); } - - await UserGroups.delete(userGroup.id); -}); +} diff --git a/packages/backend/src/server/api/endpoints/users/groups/invitations/accept.ts b/packages/backend/src/server/api/endpoints/users/groups/invitations/accept.ts index 220fff5f3..0490fd41a 100644 --- a/packages/backend/src/server/api/endpoints/users/groups/invitations/accept.ts +++ b/packages/backend/src/server/api/endpoints/users/groups/invitations/accept.ts @@ -1,8 +1,10 @@ -import { UserGroupJoinings, UserGroupInvitations } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; -import { UserGroupJoining } from '@/models/entities/user-group-joining.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UserGroupInvitationsRepository, UserGroupJoiningsRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import type { UserGroupJoining } from '@/models/entities/UserGroupJoining.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../../error.js'; -import define from '../../../../define.js'; export const meta = { tags: ['groups', 'users'], @@ -31,27 +33,40 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - // Fetch the invitation - const invitation = await UserGroupInvitations.findOneBy({ - id: ps.invitationId, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userGroupInvitationsRepository) + private userGroupInvitationsRepository: UserGroupInvitationsRepository, - if (invitation == null) { - throw new ApiError(meta.errors.noSuchInvitation); + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch the invitation + const invitation = await this.userGroupInvitationsRepository.findOneBy({ + id: ps.invitationId, + }); + + if (invitation == null) { + throw new ApiError(meta.errors.noSuchInvitation); + } + + if (invitation.userId !== me.id) { + throw new ApiError(meta.errors.noSuchInvitation); + } + + // Push the user + await this.userGroupJoiningsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: me.id, + userGroupId: invitation.userGroupId, + } as UserGroupJoining); + + this.userGroupInvitationsRepository.delete(invitation.id); + }); } - - if (invitation.userId !== user.id) { - throw new ApiError(meta.errors.noSuchInvitation); - } - - // Push the user - await UserGroupJoinings.insert({ - id: genId(), - createdAt: new Date(), - userId: user.id, - userGroupId: invitation.userGroupId, - } as UserGroupJoining); - - UserGroupInvitations.delete(invitation.id); -}); +} diff --git a/packages/backend/src/server/api/endpoints/users/groups/invitations/reject.ts b/packages/backend/src/server/api/endpoints/users/groups/invitations/reject.ts index 8d1d3db73..26efc1ecf 100644 --- a/packages/backend/src/server/api/endpoints/users/groups/invitations/reject.ts +++ b/packages/backend/src/server/api/endpoints/users/groups/invitations/reject.ts @@ -1,5 +1,7 @@ -import { UserGroupInvitations } from '@/models/index.js'; -import define from '../../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UserGroupInvitationsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../../error.js'; export const meta = { @@ -29,19 +31,27 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - // Fetch the invitation - const invitation = await UserGroupInvitations.findOneBy({ - id: ps.invitationId, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userGroupInvitationsRepository) + private userGroupInvitationsRepository: UserGroupInvitationsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch the invitation + const invitation = await this.userGroupInvitationsRepository.findOneBy({ + id: ps.invitationId, + }); - if (invitation == null) { - throw new ApiError(meta.errors.noSuchInvitation); + if (invitation == null) { + throw new ApiError(meta.errors.noSuchInvitation); + } + + if (invitation.userId !== me.id) { + throw new ApiError(meta.errors.noSuchInvitation); + } + + await this.userGroupInvitationsRepository.delete(invitation.id); + }); } - - if (invitation.userId !== user.id) { - throw new ApiError(meta.errors.noSuchInvitation); - } - - await UserGroupInvitations.delete(invitation.id); -}); +} diff --git a/packages/backend/src/server/api/endpoints/users/groups/invite.ts b/packages/backend/src/server/api/endpoints/users/groups/invite.ts index 1a8d320f3..4ae32a6bd 100644 --- a/packages/backend/src/server/api/endpoints/users/groups/invite.ts +++ b/packages/backend/src/server/api/endpoints/users/groups/invite.ts @@ -1,10 +1,12 @@ -import { UserGroups, UserGroupJoinings, UserGroupInvitations } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; -import { UserGroupInvitation } from '@/models/entities/user-group-invitation.js'; -import { createNotification } from '@/services/create-notification.js'; -import { getUser } from '../../../common/getters.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UserGroupsRepository, UserGroupJoiningsRepository, UserGroupInvitationsRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import type { UserGroupInvitation } from '@/models/entities/UserGroupInvitation.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GetterService } from '@/server/api/common/GetterService.js'; +import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import define from '../../../define.js'; export const meta = { tags: ['groups', 'users'], @@ -52,51 +54,69 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - // Fetch the group - const userGroup = await UserGroups.findOneBy({ - id: ps.groupId, - userId: me.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userGroupsRepository) + private userGroupsRepository: UserGroupsRepository, - if (userGroup == null) { - throw new ApiError(meta.errors.noSuchGroup); + @Inject(DI.userGroupInvitationsRepository) + private userGroupInvitationsRepository: UserGroupInvitationsRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + private idService: IdService, + private getterService: GetterService, + private createNotificationService: CreateNotificationService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch the group + const userGroup = await this.userGroupsRepository.findOneBy({ + id: ps.groupId, + userId: me.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // Fetch the user + const user = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + const joining = await this.userGroupJoiningsRepository.findOneBy({ + userGroupId: userGroup.id, + userId: user.id, + }); + + if (joining) { + throw new ApiError(meta.errors.alreadyAdded); + } + + const existInvitation = await this.userGroupInvitationsRepository.findOneBy({ + userGroupId: userGroup.id, + userId: user.id, + }); + + if (existInvitation) { + throw new ApiError(meta.errors.alreadyInvited); + } + + const invitation = await this.userGroupInvitationsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: user.id, + userGroupId: userGroup.id, + } as UserGroupInvitation).then(x => this.userGroupInvitationsRepository.findOneByOrFail(x.identifiers[0])); + + // 通知を作成 + this.createNotificationService.createNotification(user.id, 'groupInvited', { + notifierId: me.id, + userGroupInvitationId: invitation.id, + }); + }); } - - // Fetch the user - const user = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw e; - }); - - const joining = await UserGroupJoinings.findOneBy({ - userGroupId: userGroup.id, - userId: user.id, - }); - - if (joining) { - throw new ApiError(meta.errors.alreadyAdded); - } - - const existInvitation = await UserGroupInvitations.findOneBy({ - userGroupId: userGroup.id, - userId: user.id, - }); - - if (existInvitation) { - throw new ApiError(meta.errors.alreadyInvited); - } - - const invitation = await UserGroupInvitations.insert({ - id: genId(), - createdAt: new Date(), - userId: user.id, - userGroupId: userGroup.id, - } as UserGroupInvitation).then(x => UserGroupInvitations.findOneByOrFail(x.identifiers[0])); - - // 通知を作成 - createNotification(user.id, 'groupInvited', { - notifierId: me.id, - userGroupInvitationId: invitation.id, - }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/users/groups/joined.ts b/packages/backend/src/server/api/endpoints/users/groups/joined.ts index 16c6e544e..e7e69f257 100644 --- a/packages/backend/src/server/api/endpoints/users/groups/joined.ts +++ b/packages/backend/src/server/api/endpoints/users/groups/joined.ts @@ -1,6 +1,9 @@ import { Not, In } from 'typeorm'; -import { UserGroups, UserGroupJoinings } from '@/models/index.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UserGroupsRepository, UserGroupJoiningsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserGroupEntityService } from '@/core/entities/UserGroupEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['groups', 'account'], @@ -29,17 +32,30 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const ownedGroups = await UserGroups.findBy({ - userId: me.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userGroupsRepository) + private userGroupsRepository: UserGroupsRepository, - const joinings = await UserGroupJoinings.findBy({ - userId: me.id, - ...(ownedGroups.length > 0 ? { - userGroupId: Not(In(ownedGroups.map(x => x.id))), - } : {}), - }); + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, - return await Promise.all(joinings.map(x => UserGroups.pack(x.userGroupId))); -}); + private userGroupEntityService: UserGroupEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const ownedGroups = await this.userGroupsRepository.findBy({ + userId: me.id, + }); + + const joinings = await this.userGroupJoiningsRepository.findBy({ + userId: me.id, + ...(ownedGroups.length > 0 ? { + userGroupId: Not(In(ownedGroups.map(x => x.id))), + } : {}), + }); + + return await Promise.all(joinings.map(x => this.userGroupEntityService.pack(x.userGroupId))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/groups/leave.ts b/packages/backend/src/server/api/endpoints/users/groups/leave.ts index 83dc757db..0a63dbb7f 100644 --- a/packages/backend/src/server/api/endpoints/users/groups/leave.ts +++ b/packages/backend/src/server/api/endpoints/users/groups/leave.ts @@ -1,5 +1,7 @@ -import { UserGroups, UserGroupJoinings } from '@/models/index.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UserGroupsRepository, UserGroupJoiningsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -35,19 +37,30 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - // Fetch the group - const userGroup = await UserGroups.findOneBy({ - id: ps.groupId, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userGroupsRepository) + private userGroupsRepository: UserGroupsRepository, - if (userGroup == null) { - throw new ApiError(meta.errors.noSuchGroup); + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch the group + const userGroup = await this.userGroupsRepository.findOneBy({ + id: ps.groupId, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + if (me.id === userGroup.userId) { + throw new ApiError(meta.errors.youAreOwner); + } + + await this.userGroupJoiningsRepository.delete({ userGroupId: userGroup.id, userId: me.id }); + }); } - - if (me.id === userGroup.userId) { - throw new ApiError(meta.errors.youAreOwner); - } - - await UserGroupJoinings.delete({ userGroupId: userGroup.id, userId: me.id }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/users/groups/owned.ts b/packages/backend/src/server/api/endpoints/users/groups/owned.ts index d77cf1a52..c9ae39561 100644 --- a/packages/backend/src/server/api/endpoints/users/groups/owned.ts +++ b/packages/backend/src/server/api/endpoints/users/groups/owned.ts @@ -1,5 +1,8 @@ -import { UserGroups } from '@/models/index.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UserGroupsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserGroupEntityService } from '@/core/entities/UserGroupEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['groups', 'account'], @@ -28,10 +31,20 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const userGroups = await UserGroups.findBy({ - userId: me.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userGroupsRepository) + private userGroupsRepository: UserGroupsRepository, - return await Promise.all(userGroups.map(x => UserGroups.pack(x))); -}); + private userGroupEntityService: UserGroupEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const userGroups = await this.userGroupsRepository.findBy({ + userId: me.id, + }); + + return await Promise.all(userGroups.map(x => this.userGroupEntityService.pack(x))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/groups/pull.ts b/packages/backend/src/server/api/endpoints/users/groups/pull.ts index ba67a1e5c..e6f60eef0 100644 --- a/packages/backend/src/server/api/endpoints/users/groups/pull.ts +++ b/packages/backend/src/server/api/endpoints/users/groups/pull.ts @@ -1,7 +1,9 @@ -import { UserGroups, UserGroupJoinings } from '@/models/index.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UserGroupsRepository, UserGroupJoiningsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GetterService } from '@/server/api/common/GetterService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import { getUser } from '../../../common/getters.js'; export const meta = { tags: ['groups', 'users'], @@ -43,27 +45,40 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - // Fetch the group - const userGroup = await UserGroups.findOneBy({ - id: ps.groupId, - userId: me.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userGroupsRepository) + private userGroupsRepository: UserGroupsRepository, - if (userGroup == null) { - throw new ApiError(meta.errors.noSuchGroup); + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + private getterService: GetterService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch the group + const userGroup = await this.userGroupsRepository.findOneBy({ + id: ps.groupId, + userId: me.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // Fetch the user + const user = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + if (user.id === userGroup.userId) { + throw new ApiError(meta.errors.isOwner); + } + + // Pull the user + await this.userGroupJoiningsRepository.delete({ userGroupId: userGroup.id, userId: user.id }); + }); } - - // Fetch the user - const user = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw e; - }); - - if (user.id === userGroup.userId) { - throw new ApiError(meta.errors.isOwner); - } - - // Pull the user - await UserGroupJoinings.delete({ userGroupId: userGroup.id, userId: user.id }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/users/groups/show.ts b/packages/backend/src/server/api/endpoints/users/groups/show.ts index 21e3d9da2..1cebfcd20 100644 --- a/packages/backend/src/server/api/endpoints/users/groups/show.ts +++ b/packages/backend/src/server/api/endpoints/users/groups/show.ts @@ -1,5 +1,8 @@ -import { UserGroups, UserGroupJoinings } from '@/models/index.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UserGroupsRepository, UserGroupJoiningsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserGroupEntityService } from '@/core/entities/UserGroupEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -35,24 +38,37 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - // Fetch the group - const userGroup = await UserGroups.findOneBy({ - id: ps.groupId, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userGroupsRepository) + private userGroupsRepository: UserGroupsRepository, - if (userGroup == null) { - throw new ApiError(meta.errors.noSuchGroup); + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + private userGroupEntityService: UserGroupEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch the group + const userGroup = await this.userGroupsRepository.findOneBy({ + id: ps.groupId, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + const joining = await this.userGroupJoiningsRepository.findOneBy({ + userId: me.id, + userGroupId: userGroup.id, + }); + + if (joining == null && userGroup.userId !== me.id) { + throw new ApiError(meta.errors.noSuchGroup); + } + + return await this.userGroupEntityService.pack(userGroup); + }); } - - const joining = await UserGroupJoinings.findOneBy({ - userId: me.id, - userGroupId: userGroup.id, - }); - - if (joining == null && userGroup.userId !== me.id) { - throw new ApiError(meta.errors.noSuchGroup); - } - - return await UserGroups.pack(userGroup); -}); +} diff --git a/packages/backend/src/server/api/endpoints/users/groups/transfer.ts b/packages/backend/src/server/api/endpoints/users/groups/transfer.ts index 6456e70dd..a8b2533b7 100644 --- a/packages/backend/src/server/api/endpoints/users/groups/transfer.ts +++ b/packages/backend/src/server/api/endpoints/users/groups/transfer.ts @@ -1,7 +1,10 @@ -import { UserGroups, UserGroupJoinings } from '@/models/index.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UserGroupsRepository, UserGroupJoiningsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserGroupEntityService } from '@/core/entities/UserGroupEntityService.js'; +import { GetterService } from '@/server/api/common/GetterService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import { getUser } from '../../../common/getters.js'; export const meta = { tags: ['groups', 'users'], @@ -49,35 +52,49 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - // Fetch the group - const userGroup = await UserGroups.findOneBy({ - id: ps.groupId, - userId: me.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userGroupsRepository) + private userGroupsRepository: UserGroupsRepository, - if (userGroup == null) { - throw new ApiError(meta.errors.noSuchGroup); + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + private userGroupEntityService: UserGroupEntityService, + private getterService: GetterService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch the group + const userGroup = await this.userGroupsRepository.findOneBy({ + id: ps.groupId, + userId: me.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // Fetch the user + const user = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + const joining = await this.userGroupJoiningsRepository.findOneBy({ + userGroupId: userGroup.id, + userId: user.id, + }); + + if (joining == null) { + throw new ApiError(meta.errors.noSuchGroupMember); + } + + await this.userGroupsRepository.update(userGroup.id, { + userId: ps.userId, + }); + + return await this.userGroupEntityService.pack(userGroup.id); + }); } - - // Fetch the user - const user = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw e; - }); - - const joining = await UserGroupJoinings.findOneBy({ - userGroupId: userGroup.id, - userId: user.id, - }); - - if (joining == null) { - throw new ApiError(meta.errors.noSuchGroupMember); - } - - await UserGroups.update(userGroup.id, { - userId: ps.userId, - }); - - return await UserGroups.pack(userGroup.id); -}); +} diff --git a/packages/backend/src/server/api/endpoints/users/groups/update.ts b/packages/backend/src/server/api/endpoints/users/groups/update.ts index 0a96165fc..b679625c8 100644 --- a/packages/backend/src/server/api/endpoints/users/groups/update.ts +++ b/packages/backend/src/server/api/endpoints/users/groups/update.ts @@ -1,5 +1,8 @@ -import { UserGroups } from '@/models/index.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UserGroupsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserGroupEntityService } from '@/core/entities/UserGroupEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -36,20 +39,30 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - // Fetch the group - const userGroup = await UserGroups.findOneBy({ - id: ps.groupId, - userId: me.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userGroupsRepository) + private userGroupsRepository: UserGroupsRepository, - if (userGroup == null) { - throw new ApiError(meta.errors.noSuchGroup); + private userGroupEntityService: UserGroupEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch the group + const userGroup = await this.userGroupsRepository.findOneBy({ + id: ps.groupId, + userId: me.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + await this.userGroupsRepository.update(userGroup.id, { + name: ps.name, + }); + + return await this.userGroupEntityService.pack(userGroup.id); + }); } - - await UserGroups.update(userGroup.id, { - name: ps.name, - }); - - return await UserGroups.pack(userGroup.id); -}); +} diff --git a/packages/backend/src/server/api/endpoints/users/lists/create.ts b/packages/backend/src/server/api/endpoints/users/lists/create.ts index 783e63f5d..aa64ca122 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/create.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/create.ts @@ -1,7 +1,10 @@ -import { UserLists } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; -import { UserList } from '@/models/entities/user-list.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UserListsRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import type { UserList } from '@/models/entities/UserList.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserListEntityService } from '@/core/entities/UserListEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['lists'], @@ -28,13 +31,24 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const userList = await UserLists.insert({ - id: genId(), - createdAt: new Date(), - userId: user.id, - name: ps.name, - } as UserList).then(x => UserLists.findOneByOrFail(x.identifiers[0])); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userListsRepository) + private userListsRepository: UserListsRepository, - return await UserLists.pack(userList); -}); + private userListEntityService: UserListEntityService, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const userList = await this.userListsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: me.id, + name: ps.name, + } as UserList).then(x => this.userListsRepository.findOneByOrFail(x.identifiers[0])); + + return await this.userListEntityService.pack(userList); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/lists/delete.ts b/packages/backend/src/server/api/endpoints/users/lists/delete.ts index 5a7613c98..0f4125a39 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/delete.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/delete.ts @@ -1,5 +1,7 @@ -import { UserLists } from '@/models/index.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UserListsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -29,15 +31,23 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const userList = await UserLists.findOneBy({ - id: ps.listId, - userId: user.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userListsRepository) + private userListsRepository: UserListsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const userList = await this.userListsRepository.findOneBy({ + id: ps.listId, + userId: me.id, + }); - if (userList == null) { - throw new ApiError(meta.errors.noSuchList); + if (userList == null) { + throw new ApiError(meta.errors.noSuchList); + } + + await this.userListsRepository.delete(userList.id); + }); } - - await UserLists.delete(userList.id); -}); +} 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 889052fa3..919de2237 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/list.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/list.ts @@ -1,5 +1,8 @@ -import { UserLists } from '@/models/index.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UserListsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserListEntityService } from '@/core/entities/UserListEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['lists', 'account'], @@ -28,10 +31,20 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const userLists = await UserLists.findBy({ - userId: me.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userListsRepository) + private userListsRepository: UserListsRepository, - return await Promise.all(userLists.map(x => UserLists.pack(x))); -}); + private userListEntityService: UserListEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const userLists = await this.userListsRepository.findBy({ + userId: me.id, + }); + + return await Promise.all(userLists.map(x => this.userListEntityService.pack(x))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/lists/pull.ts b/packages/backend/src/server/api/endpoints/users/lists/pull.ts index d3d1d6555..89d97be93 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/pull.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/pull.ts @@ -1,8 +1,11 @@ -import { publishUserListStream } from '@/services/stream.js'; -import { UserLists, UserListJoinings, Users } from '@/models/index.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UserListsRepository, UserListJoiningsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { GetterService } from '@/server/api/common/GetterService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import { getUser } from '../../../common/getters.js'; export const meta = { tags: ['lists', 'users'], @@ -38,25 +41,40 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - // Fetch the list - const userList = await UserLists.findOneBy({ - id: ps.listId, - userId: me.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userListsRepository) + private userListsRepository: UserListsRepository, - if (userList == null) { - throw new ApiError(meta.errors.noSuchList); + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, + + private userEntityService: UserEntityService, + private getterService: GetterService, + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch the list + const userList = await this.userListsRepository.findOneBy({ + id: ps.listId, + userId: me.id, + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchList); + } + + // Fetch the user + const user = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + // Pull the user + await this.userListJoiningsRepository.delete({ userListId: userList.id, userId: user.id }); + + this.globalEventService.publishUserListStream(userList.id, 'userRemoved', await this.userEntityService.pack(user)); + }); } - - // Fetch the user - const user = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw e; - }); - - // Pull the user - await UserListJoinings.delete({ userListId: userList.id, userId: user.id }); - - publishUserListStream(userList.id, 'userRemoved', await Users.pack(user)); -}); +} diff --git a/packages/backend/src/server/api/endpoints/users/lists/push.ts b/packages/backend/src/server/api/endpoints/users/lists/push.ts index 12b7b8634..77ad772b1 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/push.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/push.ts @@ -1,8 +1,10 @@ -import { pushUserToUserList } from '@/services/user-list/push.js'; -import { UserLists, UserListJoinings, Blockings } from '@/models/index.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UserListsRepository, UserListJoiningsRepository, BlockingsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GetterService } from '@/server/api/common/GetterService.js'; +import { UserListService } from '@/core/UserListService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import { getUser } from '../../../common/getters.js'; export const meta = { tags: ['lists', 'users'], @@ -50,43 +52,60 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - // Fetch the list - const userList = await UserLists.findOneBy({ - id: ps.listId, - userId: me.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userListsRepository) + private userListsRepository: UserListsRepository, - if (userList == null) { - throw new ApiError(meta.errors.noSuchList); - } + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, - // Fetch the user - const user = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw e; - }); + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, - // Check blocking - if (user.id !== me.id) { - const block = await Blockings.findOneBy({ - blockerId: user.id, - blockeeId: me.id, + private getterService: GetterService, + private userListService: UserListService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch the list + const userList = await this.userListsRepository.findOneBy({ + id: ps.listId, + userId: me.id, + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchList); + } + + // Fetch the user + const user = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + // Check blocking + if (user.id !== me.id) { + const block = await this.blockingsRepository.findOneBy({ + blockerId: user.id, + blockeeId: me.id, + }); + if (block) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } + + const exist = await this.userListJoiningsRepository.findOneBy({ + userListId: userList.id, + userId: user.id, + }); + + if (exist) { + throw new ApiError(meta.errors.alreadyAdded); + } + + // Push the user + await this.userListService.push(user, userList); }); - if (block) { - throw new ApiError(meta.errors.youHaveBeenBlocked); - } } - - const exist = await UserListJoinings.findOneBy({ - userListId: userList.id, - userId: user.id, - }); - - if (exist) { - throw new ApiError(meta.errors.alreadyAdded); - } - - // Push the user - await pushUserToUserList(user, userList); -}); +} diff --git a/packages/backend/src/server/api/endpoints/users/lists/show.ts b/packages/backend/src/server/api/endpoints/users/lists/show.ts index fd0612f73..62e730b2f 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/show.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/show.ts @@ -1,5 +1,8 @@ -import { UserLists } from '@/models/index.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UserListsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserListEntityService } from '@/core/entities/UserListEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -35,16 +38,26 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - // Fetch the list - const userList = await UserLists.findOneBy({ - id: ps.listId, - userId: me.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userListsRepository) + private userListsRepository: UserListsRepository, - if (userList == null) { - throw new ApiError(meta.errors.noSuchList); + private userListEntityService: UserListEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch the list + const userList = await this.userListsRepository.findOneBy({ + id: ps.listId, + userId: me.id, + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchList); + } + + return await this.userListEntityService.pack(userList); + }); } - - return await UserLists.pack(userList); -}); +} diff --git a/packages/backend/src/server/api/endpoints/users/lists/update.ts b/packages/backend/src/server/api/endpoints/users/lists/update.ts index 65e708b95..c6669d24d 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/update.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/update.ts @@ -1,5 +1,8 @@ -import { UserLists } from '@/models/index.js'; -import define from '../../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UserListsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserListEntityService } from '@/core/entities/UserListEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -36,20 +39,30 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - // Fetch the list - const userList = await UserLists.findOneBy({ - id: ps.listId, - userId: user.id, - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userListsRepository) + private userListsRepository: UserListsRepository, - if (userList == null) { - throw new ApiError(meta.errors.noSuchList); + private userListEntityService: UserListEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch the list + const userList = await this.userListsRepository.findOneBy({ + id: ps.listId, + userId: me.id, + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchList); + } + + await this.userListsRepository.update(userList.id, { + name: ps.name, + }); + + return await this.userListEntityService.pack(userList.id); + }); } - - await UserLists.update(userList.id, { - name: ps.name, - }); - - return await UserLists.pack(userList.id); -}); +} diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index 9fa56fe83..bb8104584 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -1,12 +1,12 @@ import { Brackets } from 'typeorm'; -import { Notes } from '@/models/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { NotesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import { getUser } from '../../common/getters.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; -import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; -import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; -import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; +import { GetterService } from '../../common/GetterService.js'; export const meta = { tags: ['users', 'notes'], @@ -53,70 +53,82 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - // Lookup user - const user = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw e; - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, - //#region Construct query - const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('note.userId = :userId', { userId: user.id }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + private noteEntityService: NoteEntityService, + private queryService: QueryService, + private getterService: GetterService, + ) { + super(meta, paramDef, async (ps, me) => { + // Lookup user + const user = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); - generateVisibilityQuery(query, me); - if (me) { - generateMutedUserQuery(query, me, user); - generateBlockedUserQuery(query, me); - } + //#region Construct query + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('note.userId = :userId', { userId: user.id }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } - - if (ps.fileType != null) { - query.andWhere('note.fileIds != \'{}\''); - query.andWhere(new Brackets(qb => { - for (const type of ps.fileType!) { - const i = ps.fileType!.indexOf(type); - qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type }); + this.queryService.generateVisibilityQuery(query, me); + if (me) { + this.queryService.generateMutedUserQuery(query, me, user); + this.queryService.generateBlockedUserQuery(query, me); } - })); - if (ps.excludeNsfw) { - query.andWhere('note.cw IS NULL'); - query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)'); - } + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + + if (ps.fileType != null) { + query.andWhere('note.fileIds != \'{}\''); + query.andWhere(new Brackets(qb => { + for (const type of ps.fileType!) { + const i = ps.fileType!.indexOf(type); + qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type }); + } + })); + + if (ps.excludeNsfw) { + query.andWhere('note.cw IS NULL'); + query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)'); + } + } + + if (!ps.includeReplies) { + query.andWhere('note.replyId IS NULL'); + } + + if (ps.includeMyRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.userId != :userId', { userId: user.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + //#endregion + + const timeline = await query.take(ps.limit).getMany(); + + return await this.noteEntityService.packMany(timeline, me); + }); } - - if (!ps.includeReplies) { - query.andWhere('note.replyId IS NULL'); - } - - if (ps.includeMyRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.userId != :userId', { userId: user.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - //#endregion - - const timeline = await query.take(ps.limit).getMany(); - - return await Notes.packMany(timeline, me); -}); +} diff --git a/packages/backend/src/server/api/endpoints/users/pages.ts b/packages/backend/src/server/api/endpoints/users/pages.ts index b1d28af84..96c7ef1e7 100644 --- a/packages/backend/src/server/api/endpoints/users/pages.ts +++ b/packages/backend/src/server/api/endpoints/users/pages.ts @@ -1,6 +1,9 @@ -import { Pages } from '@/models/index.js'; -import define from '../../define.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { PageEntityService } from '@/core/entities/PageEntityService.js'; +import { PagesRepository } from '@/models'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['users', 'pages'], @@ -30,14 +33,25 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - const query = makePaginationQuery(Pages.createQueryBuilder('page'), ps.sinceId, ps.untilId) - .andWhere('page.userId = :userId', { userId: ps.userId }) - .andWhere('page.visibility = \'public\''); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.pagesRepository) + private pagesRepository: PagesRepository, - const pages = await query - .take(ps.limit) - .getMany(); + private pageEntityService: PageEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.pagesRepository.createQueryBuilder('page'), ps.sinceId, ps.untilId) + .andWhere('page.userId = :userId', { userId: ps.userId }) + .andWhere('page.visibility = \'public\''); - return await Pages.packMany(pages); -}); + const pages = await query + .take(ps.limit) + .getMany(); + + return await this.pageEntityService.packMany(pages); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/reactions.ts b/packages/backend/src/server/api/endpoints/users/reactions.ts index 9668bd21b..6b4d882b7 100644 --- a/packages/backend/src/server/api/endpoints/users/reactions.ts +++ b/packages/backend/src/server/api/endpoints/users/reactions.ts @@ -1,7 +1,9 @@ -import { NoteReactions, UserProfiles } from '@/models/index.js'; -import define from '../../define.js'; -import { makePaginationQuery } from '../../common/make-pagination-query.js'; -import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UserProfilesRepository, NoteReactionsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { NoteReactionEntityService } from '@/core/entities/NoteReactionEntityService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -44,23 +46,37 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const profile = await UserProfiles.findOneByOrFail({ userId: ps.userId }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, - if (me == null || (me.id !== ps.userId && !profile.publicReactions)) { - throw new ApiError(meta.errors.reactionsNotPublic); + @Inject(DI.noteReactionsRepository) + private noteReactionsRepository: NoteReactionsRepository, + + private noteReactionEntityService: NoteReactionEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: ps.userId }); + + if (me == null || (me.id !== ps.userId && !profile.publicReactions)) { + throw new ApiError(meta.errors.reactionsNotPublic); + } + + const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('reaction.userId = :userId', { userId: ps.userId }) + .leftJoinAndSelect('reaction.note', 'note'); + + this.queryService.generateVisibilityQuery(query, me); + + const reactions = await query + .take(ps.limit) + .getMany(); + + return await Promise.all(reactions.map(reaction => this.noteReactionEntityService.pack(reaction, me, { withNote: true }))); + }); } - - const query = makePaginationQuery(NoteReactions.createQueryBuilder('reaction'), - ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('reaction.userId = :userId', { userId: ps.userId }) - .leftJoinAndSelect('reaction.note', 'note'); - - generateVisibilityQuery(query, me); - - const reactions = await query - .take(ps.limit) - .getMany(); - - return await Promise.all(reactions.map(reaction => NoteReactions.pack(reaction, me, { withNote: true }))); -}); +} diff --git a/packages/backend/src/server/api/endpoints/users/recommendation.ts b/packages/backend/src/server/api/endpoints/users/recommendation.ts index e7654e171..e50a5706d 100644 --- a/packages/backend/src/server/api/endpoints/users/recommendation.ts +++ b/packages/backend/src/server/api/endpoints/users/recommendation.ts @@ -1,8 +1,10 @@ import ms from 'ms'; -import { Users, Followings } from '@/models/index.js'; -import define from '../../define.js'; -import { generateMutedUserQueryForUsers } from '../../common/generate-muted-user-query.js'; -import { generateBlockedUserQuery, generateBlockQueryForUsers } from '../../common/generate-block-query.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UsersRepository, FollowingsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['users'], @@ -34,29 +36,43 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const query = Users.createQueryBuilder('user') - .where('user.isLocked = FALSE') - .andWhere('user.isExplorable = TRUE') - .andWhere('user.host IS NULL') - .andWhere('user.updatedAt >= :date', { date: new Date(Date.now() - ms('7days')) }) - .andWhere('user.id != :meId', { meId: me.id }) - .orderBy('user.followersCount', 'DESC'); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - generateMutedUserQueryForUsers(query, me); - generateBlockQueryForUsers(query, me); - generateBlockedUserQuery(query, me); + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + private userEntityService: UserEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.usersRepository.createQueryBuilder('user') + .where('user.isLocked = FALSE') + .andWhere('user.isExplorable = TRUE') + .andWhere('user.host IS NULL') + .andWhere('user.updatedAt >= :date', { date: new Date(Date.now() - ms('7days')) }) + .andWhere('user.id != :meId', { meId: me.id }) + .orderBy('user.followersCount', 'DESC'); - const followingQuery = Followings.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: me.id }); + this.queryService.generateMutedUserQueryForUsers(query, me); + this.queryService.generateBlockQueryForUsers(query, me); + this.queryService.generateBlockedUserQuery(query, me); - query - .andWhere(`user.id NOT IN (${ followingQuery.getQuery() })`); + const followingQuery = this.followingsRepository.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: me.id }); - query.setParameters(followingQuery.getParameters()); + query + .andWhere(`user.id NOT IN (${ followingQuery.getQuery() })`); - const users = await query.take(ps.limit).skip(ps.offset).getMany(); + query.setParameters(followingQuery.getParameters()); - return await Users.packMany(users, me, { detail: true }); -}); + const users = await query.take(ps.limit).skip(ps.offset).getMany(); + + return await this.userEntityService.packMany(users, me, { detail: true }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/relation.ts b/packages/backend/src/server/api/endpoints/users/relation.ts index 233a6a90b..aea75ae79 100644 --- a/packages/backend/src/server/api/endpoints/users/relation.ts +++ b/packages/backend/src/server/api/endpoints/users/relation.ts @@ -1,5 +1,8 @@ -import { Users } from '@/models/index.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UsersRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['users'], @@ -112,10 +115,20 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const ids = Array.isArray(ps.userId) ? ps.userId : [ps.userId]; +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - const relations = await Promise.all(ids.map(id => Users.getRelation(me.id, id))); + private userEntityService: UserEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const ids = Array.isArray(ps.userId) ? ps.userId : [ps.userId]; - return Array.isArray(ps.userId) ? relations : relations[0]; -}); + 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 a9987eafa..5c211a901 100644 --- a/packages/backend/src/server/api/endpoints/users/report-abuse.ts +++ b/packages/backend/src/server/api/endpoints/users/report-abuse.ts @@ -1,12 +1,14 @@ import * as sanitizeHtml from 'sanitize-html'; -import { publishAdminStream } from '@/services/stream.js'; -import { AbuseUserReports, Users } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; -import { sendEmail } from '@/services/send-email.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { getUser } from '../../common/getters.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UsersRepository, AbuseUserReportsRepository } from '@/models/index.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 { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; -import define from '../../define.js'; +import { GetterService } from '../../common/GetterService.js'; export const meta = { tags: ['users'], @@ -46,55 +48,72 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - // Lookup user - const user = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw e; - }); +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - if (user.id === me.id) { - throw new ApiError(meta.errors.cannotReportYourself); - } + @Inject(DI.abuseUserReportsRepository) + private abuseUserReportsRepository: AbuseUserReportsRepository, - if (user.isAdmin) { - throw new ApiError(meta.errors.cannotReportAdmin); - } - - const report = await AbuseUserReports.insert({ - id: genId(), - createdAt: new Date(), - targetUserId: user.id, - targetUserHost: user.host, - reporterId: me.id, - reporterHost: null, - comment: ps.comment, - }).then(x => AbuseUserReports.findOneByOrFail(x.identifiers[0])); - - // Publish event to moderators - setImmediate(async () => { - const moderators = await Users.find({ - where: [{ - isAdmin: true, - }, { - isModerator: true, - }], - }); - - for (const moderator of moderators) { - publishAdminStream(moderator.id, 'newAbuseUserReport', { - id: report.id, - targetUserId: report.targetUserId, - reporterId: report.reporterId, - comment: report.comment, + private idService: IdService, + private metaService: MetaService, + private emailService: EmailService, + private getterService: GetterService, + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + // Lookup user + const user = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; }); - } - const meta = await fetchMeta(); - if (meta.email) { - sendEmail(meta.email, 'New abuse report', - sanitizeHtml(ps.comment), - sanitizeHtml(ps.comment)); - } - }); -}); + if (user.id === me.id) { + throw new ApiError(meta.errors.cannotReportYourself); + } + + if (user.isAdmin) { + throw new ApiError(meta.errors.cannotReportAdmin); + } + + const report = await this.abuseUserReportsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + targetUserId: user.id, + targetUserHost: user.host, + reporterId: me.id, + reporterHost: null, + comment: ps.comment, + }).then(x => this.abuseUserReportsRepository.findOneByOrFail(x.identifiers[0])); + + // Publish event to moderators + setImmediate(async () => { + const moderators = await this.usersRepository.find({ + where: [{ + isAdmin: true, + }, { + isModerator: true, + }], + }); + + for (const moderator of moderators) { + this.globalEventService.publishAdminStream(moderator.id, 'newAbuseUserReport', { + id: report.id, + targetUserId: report.targetUserId, + reporterId: report.reporterId, + comment: report.comment, + }); + } + + const meta = await this.metaService.fetch(); + 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/search-by-username-and-host.ts b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts index 6e5bc46bb..1747dc93f 100644 --- a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts +++ b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts @@ -1,8 +1,11 @@ import { Brackets } from 'typeorm'; -import { Followings, Users } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UsersRepository, FollowingsRepository } from '@/models/index.js'; import { USER_ACTIVE_THRESHOLD } from '@/const.js'; -import { User } from '@/models/entities/user.js'; -import define from '../../define.js'; +import type { User } from '@/models/entities/User.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['users'], @@ -39,78 +42,91 @@ export const paramDef = { // TODO: avatar,bannerをJOINしたいけどエラーになる // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - if (ps.host) { - const q = Users.createQueryBuilder('user') - .where('user.isSuspended = FALSE') - .andWhere('user.host LIKE :host', { host: ps.host.toLowerCase() + '%' }); + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, - if (ps.username) { - q.andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' }); - } + private userEntityService: UserEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 - q.andWhere('user.updatedAt IS NOT NULL'); - q.orderBy('user.updatedAt', 'DESC'); + if (ps.host) { + const q = this.usersRepository.createQueryBuilder('user') + .where('user.isSuspended = FALSE') + .andWhere('user.host LIKE :host', { host: ps.host.toLowerCase() + '%' }); - const users = await q.take(ps.limit).getMany(); + if (ps.username) { + q.andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' }); + } - return await Users.packMany(users, me, { detail: ps.detail }); - } else if (ps.username) { - let users: User[] = []; + q.andWhere('user.updatedAt IS NOT NULL'); + q.orderBy('user.updatedAt', 'DESC'); - if (me) { - const followingQuery = Followings.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: me.id }); + const users = await q.take(ps.limit).getMany(); - const query = Users.createQueryBuilder('user') - .where(`user.id IN (${ followingQuery.getQuery() })`) - .andWhere('user.id != :meId', { meId: me.id }) - .andWhere('user.isSuspended = FALSE') - .andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' }) - .andWhere(new Brackets(qb => { qb - .where('user.updatedAt IS NULL') - .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); - })); + return await this.userEntityService.packMany(users, me, { detail: ps.detail }); + } else if (ps.username) { + let users: User[] = []; - query.setParameters(followingQuery.getParameters()); + if (me) { + const followingQuery = this.followingsRepository.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: me.id }); - users = await query - .orderBy('user.usernameLower', 'ASC') - .take(ps.limit) - .getMany(); + const query = this.usersRepository.createQueryBuilder('user') + .where(`user.id IN (${ followingQuery.getQuery() })`) + .andWhere('user.id != :meId', { meId: me.id }) + .andWhere('user.isSuspended = FALSE') + .andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' }) + .andWhere(new Brackets(qb => { qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); + })); - if (users.length < ps.limit) { - const otherQuery = await Users.createQueryBuilder('user') - .where(`user.id NOT IN (${ followingQuery.getQuery() })`) - .andWhere('user.id != :meId', { meId: me.id }) - .andWhere('user.isSuspended = FALSE') - .andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' }) - .andWhere('user.updatedAt IS NOT NULL'); + query.setParameters(followingQuery.getParameters()); - otherQuery.setParameters(followingQuery.getParameters()); + users = await query + .orderBy('user.usernameLower', 'ASC') + .take(ps.limit) + .getMany(); - const otherUsers = await otherQuery - .orderBy('user.updatedAt', 'DESC') - .take(ps.limit - users.length) - .getMany(); + if (users.length < ps.limit) { + const otherQuery = await this.usersRepository.createQueryBuilder('user') + .where(`user.id NOT IN (${ followingQuery.getQuery() })`) + .andWhere('user.id != :meId', { meId: me.id }) + .andWhere('user.isSuspended = FALSE') + .andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' }) + .andWhere('user.updatedAt IS NOT NULL'); - users = users.concat(otherUsers); + otherQuery.setParameters(followingQuery.getParameters()); + + const otherUsers = await otherQuery + .orderBy('user.updatedAt', 'DESC') + .take(ps.limit - users.length) + .getMany(); + + users = users.concat(otherUsers); + } + } else { + users = await this.usersRepository.createQueryBuilder('user') + .where('user.isSuspended = FALSE') + .andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' }) + .andWhere('user.updatedAt IS NOT NULL') + .orderBy('user.updatedAt', 'DESC') + .take(ps.limit - users.length) + .getMany(); + } + + return await this.userEntityService.packMany(users, me, { detail: !!ps.detail }); } - } else { - users = await Users.createQueryBuilder('user') - .where('user.isSuspended = FALSE') - .andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' }) - .andWhere('user.updatedAt IS NOT NULL') - .orderBy('user.updatedAt', 'DESC') - .take(ps.limit - users.length) - .getMany(); - } - return await Users.packMany(users, me, { detail: !!ps.detail }); + return []; + }); } - - return []; -}); +} diff --git a/packages/backend/src/server/api/endpoints/users/search.ts b/packages/backend/src/server/api/endpoints/users/search.ts index 01729de66..9879b1b68 100644 --- a/packages/backend/src/server/api/endpoints/users/search.ts +++ b/packages/backend/src/server/api/endpoints/users/search.ts @@ -1,7 +1,10 @@ import { Brackets } from 'typeorm'; -import { UserProfiles, Users } from '@/models/index.js'; -import { User } from '@/models/entities/user.js'; -import define from '../../define.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UsersRepository, UserProfilesRepository } from '@/models/index.js'; +import type { User } from '@/models/entities/User.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['users'], @@ -34,89 +37,102 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - const isUsername = ps.query.startsWith('@'); + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, - let users: User[] = []; + private userEntityService: UserEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 - if (isUsername) { - const usernameQuery = Users.createQueryBuilder('user') - .where('user.usernameLower LIKE :username', { username: ps.query.replace('@', '').toLowerCase() + '%' }) - .andWhere(new Brackets(qb => { qb - .where('user.updatedAt IS NULL') - .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); - })) - .andWhere('user.isSuspended = FALSE'); + const isUsername = ps.query.startsWith('@'); - if (ps.origin === 'local') { - usernameQuery.andWhere('user.host IS NULL'); - } else if (ps.origin === 'remote') { - usernameQuery.andWhere('user.host IS NOT NULL'); - } + let users: User[] = []; - users = await usernameQuery - .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') - .take(ps.limit) - .skip(ps.offset) - .getMany(); - } else { - const nameQuery = Users.createQueryBuilder('user') - .where(new Brackets(qb => { - qb.where('user.name ILIKE :query', { query: '%' + ps.query + '%' }); + if (isUsername) { + const usernameQuery = this.usersRepository.createQueryBuilder('user') + .where('user.usernameLower LIKE :username', { username: ps.query.replace('@', '').toLowerCase() + '%' }) + .andWhere(new Brackets(qb => { qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); + })) + .andWhere('user.isSuspended = FALSE'); - // Also search username if it qualifies as username - if (Users.validateLocalUsername(ps.query)) { - qb.orWhere('user.usernameLower LIKE :username', { username: '%' + ps.query.toLowerCase() + '%' }); + if (ps.origin === 'local') { + usernameQuery.andWhere('user.host IS NULL'); + } else if (ps.origin === 'remote') { + usernameQuery.andWhere('user.host IS NOT NULL'); } - })) - .andWhere(new Brackets(qb => { qb - .where('user.updatedAt IS NULL') - .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); - })) - .andWhere('user.isSuspended = FALSE'); - if (ps.origin === 'local') { - nameQuery.andWhere('user.host IS NULL'); - } else if (ps.origin === 'remote') { - nameQuery.andWhere('user.host IS NOT NULL'); - } + users = await usernameQuery + .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') + .take(ps.limit) + .skip(ps.offset) + .getMany(); + } else { + const nameQuery = this.usersRepository.createQueryBuilder('user') + .where(new Brackets(qb => { + qb.where('user.name ILIKE :query', { query: '%' + ps.query + '%' }); - users = await nameQuery - .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') - .take(ps.limit) - .skip(ps.offset) - .getMany(); + // Also search username if it qualifies as username + if (this.userEntityService.validateLocalUsername(ps.query)) { + qb.orWhere('user.usernameLower LIKE :username', { username: '%' + ps.query.toLowerCase() + '%' }); + } + })) + .andWhere(new Brackets(qb => { qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); + })) + .andWhere('user.isSuspended = FALSE'); - if (users.length < ps.limit) { - const profQuery = UserProfiles.createQueryBuilder('prof') - .select('prof.userId') - .where('prof.description ILIKE :query', { query: '%' + ps.query + '%' }); + if (ps.origin === 'local') { + nameQuery.andWhere('user.host IS NULL'); + } else if (ps.origin === 'remote') { + nameQuery.andWhere('user.host IS NOT NULL'); + } - if (ps.origin === 'local') { - profQuery.andWhere('prof.userHost IS NULL'); - } else if (ps.origin === 'remote') { - profQuery.andWhere('prof.userHost IS NOT NULL'); + users = await nameQuery + .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') + .take(ps.limit) + .skip(ps.offset) + .getMany(); + + if (users.length < ps.limit) { + const profQuery = this.userProfilesRepository.createQueryBuilder('prof') + .select('prof.userId') + .where('prof.description ILIKE :query', { query: '%' + ps.query + '%' }); + + if (ps.origin === 'local') { + profQuery.andWhere('prof.userHost IS NULL'); + } else if (ps.origin === 'remote') { + profQuery.andWhere('prof.userHost IS NOT NULL'); + } + + const query = this.usersRepository.createQueryBuilder('user') + .where(`user.id IN (${ profQuery.getQuery() })`) + .andWhere(new Brackets(qb => { qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); + })) + .andWhere('user.isSuspended = FALSE') + .setParameters(profQuery.getParameters()); + + users = users.concat(await query + .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') + .take(ps.limit) + .skip(ps.offset) + .getMany(), + ); + } } - const query = Users.createQueryBuilder('user') - .where(`user.id IN (${ profQuery.getQuery() })`) - .andWhere(new Brackets(qb => { qb - .where('user.updatedAt IS NULL') - .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); - })) - .andWhere('user.isSuspended = FALSE') - .setParameters(profQuery.getParameters()); - - users = users.concat(await query - .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') - .take(ps.limit) - .skip(ps.offset) - .getMany(), - ); - } + return await this.userEntityService.packMany(users, me, { detail: ps.detail }); + }); } - - return await Users.packMany(users, me, { detail: ps.detail }); -}); +} diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts index 846d83b49..98f5f0306 100644 --- a/packages/backend/src/server/api/endpoints/users/show.ts +++ b/packages/backend/src/server/api/endpoints/users/show.ts @@ -1,10 +1,14 @@ -import { FindOptionsWhere, In, IsNull } from 'typeorm'; -import { resolveUser } from '@/remote/resolve-user.js'; -import { Users } from '@/models/index.js'; -import { User } from '@/models/entities/user.js'; -import define from '../../define.js'; -import { apiLogger } from '../../logger.js'; +import { In, IsNull } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; +import { UsersRepository } from '@/models/index.js'; +import type { User } from '@/models/entities/User.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ResolveUserService } from '@/core/remote/ResolveUserService.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; +import { ApiLoggerService } from '../../ApiLoggerService.js'; +import type { FindOptionsWhere } from 'typeorm'; export const meta = { tags: ['users'], @@ -78,53 +82,65 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - let user; +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, - const isAdminOrModerator = me && (me.isAdmin || me.isModerator); + private userEntityService: UserEntityService, + private resolveUserService: ResolveUserService, + private apiLoggerService: ApiLoggerService, + ) { + super(meta, paramDef, async (ps, me) => { + let user; - if (ps.userIds) { - if (ps.userIds.length === 0) { - return []; - } + const isAdminOrModerator = me && (me.isAdmin || me.isModerator); - const users = await Users.findBy(isAdminOrModerator ? { - id: In(ps.userIds), - } : { - id: In(ps.userIds), - isSuspended: false, - }); + if (ps.userIds) { + if (ps.userIds.length === 0) { + return []; + } - // リクエストされた通りに並べ替え - const _users: User[] = []; - for (const id of ps.userIds) { - _users.push(users.find(x => x.id === id)!); - } + const users = await this.usersRepository.findBy(isAdminOrModerator ? { + id: In(ps.userIds), + } : { + id: In(ps.userIds), + isSuspended: false, + }); - return await Promise.all(_users.map(u => Users.pack(u, me, { - detail: true, - }))); - } else { - // Lookup user - if (typeof ps.host === 'string' && typeof ps.username === 'string') { - user = await resolveUser(ps.username, ps.host).catch(e => { - apiLogger.warn(`failed to resolve remote user: ${e}`); - throw new ApiError(meta.errors.failedToResolveRemoteUser); - }); - } else { - const q: FindOptionsWhere = ps.userId != null - ? { id: ps.userId } - : { usernameLower: ps.username!.toLowerCase(), host: IsNull() }; + // リクエストされた通りに並べ替え + const _users: User[] = []; + for (const id of ps.userIds) { + _users.push(users.find(x => x.id === id)!); + } - user = await Users.findOneBy(q); - } + return await Promise.all(_users.map(u => this.userEntityService.pack(u, me, { + detail: true, + }))); + } else { + // Lookup user + if (typeof ps.host === 'string' && typeof ps.username === 'string') { + user = await this.resolveUserService.resolveUser(ps.username, ps.host).catch(err => { + this.apiLoggerService.logger.warn(`failed to resolve remote user: ${err}`); + throw new ApiError(meta.errors.failedToResolveRemoteUser); + }); + } else { + const q: FindOptionsWhere = ps.userId != null + ? { id: ps.userId } + : { usernameLower: ps.username!.toLowerCase(), host: IsNull() }; - if (user == null || (!isAdminOrModerator && user.isSuspended)) { - throw new ApiError(meta.errors.noSuchUser); - } + user = await this.usersRepository.findOneBy(q); + } - return await Users.pack(user, me, { - detail: true, + if (user == null || (!isAdminOrModerator && user.isSuspended)) { + throw new ApiError(meta.errors.noSuchUser); + } + + return await this.userEntityService.pack(user, me, { + detail: true, + }); + } }); } -}); +} diff --git a/packages/backend/src/server/api/endpoints/users/stats.ts b/packages/backend/src/server/api/endpoints/users/stats.ts index 47f322ee9..71f4ca0cf 100644 --- a/packages/backend/src/server/api/endpoints/users/stats.ts +++ b/packages/backend/src/server/api/endpoints/users/stats.ts @@ -1,6 +1,8 @@ -import { DriveFiles, Followings, NoteFavorites, NoteReactions, Notes, PageLikes, PollVotes, Users } from '@/models/index.js'; -import { awaitAll } from '@/prelude/await-all.js'; -import define from '../../define.js'; +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 { ApiError } from '../../error.js'; export const meta = { @@ -116,78 +118,109 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, me) => { - const user = await Users.findOneBy({ id: ps.userId }); - if (user == null) { - throw new ApiError(meta.errors.noSuchUser); +@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, me) => { + 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), + }); + + result.followingCount = result.localFollowingCount + result.remoteFollowingCount; + result.followersCount = result.localFollowersCount + result.remoteFollowersCount; + + return result; + }); } - - const result = await awaitAll({ - notesCount: Notes.createQueryBuilder('note') - .where('note.userId = :userId', { userId: user.id }) - .getCount(), - repliesCount: Notes.createQueryBuilder('note') - .where('note.userId = :userId', { userId: user.id }) - .andWhere('note.replyId IS NOT NULL') - .getCount(), - renotesCount: Notes.createQueryBuilder('note') - .where('note.userId = :userId', { userId: user.id }) - .andWhere('note.renoteId IS NOT NULL') - .getCount(), - repliedCount: Notes.createQueryBuilder('note') - .where('note.replyUserId = :userId', { userId: user.id }) - .getCount(), - renotedCount: Notes.createQueryBuilder('note') - .where('note.renoteUserId = :userId', { userId: user.id }) - .getCount(), - pollVotesCount: PollVotes.createQueryBuilder('vote') - .where('vote.userId = :userId', { userId: user.id }) - .getCount(), - pollVotedCount: PollVotes.createQueryBuilder('vote') - .innerJoin('vote.note', 'note') - .where('note.userId = :userId', { userId: user.id }) - .getCount(), - localFollowingCount: Followings.createQueryBuilder('following') - .where('following.followerId = :userId', { userId: user.id }) - .andWhere('following.followeeHost IS NULL') - .getCount(), - remoteFollowingCount: Followings.createQueryBuilder('following') - .where('following.followerId = :userId', { userId: user.id }) - .andWhere('following.followeeHost IS NOT NULL') - .getCount(), - localFollowersCount: Followings.createQueryBuilder('following') - .where('following.followeeId = :userId', { userId: user.id }) - .andWhere('following.followerHost IS NULL') - .getCount(), - remoteFollowersCount: Followings.createQueryBuilder('following') - .where('following.followeeId = :userId', { userId: user.id }) - .andWhere('following.followerHost IS NOT NULL') - .getCount(), - sentReactionsCount: NoteReactions.createQueryBuilder('reaction') - .where('reaction.userId = :userId', { userId: user.id }) - .getCount(), - receivedReactionsCount: NoteReactions.createQueryBuilder('reaction') - .innerJoin('reaction.note', 'note') - .where('note.userId = :userId', { userId: user.id }) - .getCount(), - noteFavoritesCount: NoteFavorites.createQueryBuilder('favorite') - .where('favorite.userId = :userId', { userId: user.id }) - .getCount(), - pageLikesCount: PageLikes.createQueryBuilder('like') - .where('like.userId = :userId', { userId: user.id }) - .getCount(), - pageLikedCount: PageLikes.createQueryBuilder('like') - .innerJoin('like.page', 'page') - .where('page.userId = :userId', { userId: user.id }) - .getCount(), - driveFilesCount: DriveFiles.createQueryBuilder('file') - .where('file.userId = :userId', { userId: user.id }) - .getCount(), - driveUsage: DriveFiles.calcDriveUsageOf(user), - }); - - result.followingCount = result.localFollowingCount + result.remoteFollowingCount; - result.followersCount = result.localFollowersCount + result.remoteFollowersCount; - - return result; -}); +} diff --git a/packages/backend/src/server/api/error.ts b/packages/backend/src/server/api/error.ts index 3f0861fdb..347d5650a 100644 --- a/packages/backend/src/server/api/error.ts +++ b/packages/backend/src/server/api/error.ts @@ -8,8 +8,8 @@ export class ApiError extends Error { public httpStatusCode?: number; public info?: any; - constructor(e?: E | null | undefined, info?: any | null | undefined) { - if (e == null) e = { + constructor(err?: E | null | undefined, info?: any | null | undefined) { + if (err == null) err = { message: 'Internal error occurred. Please contact us if the error persists.', code: 'INTERNAL_ERROR', id: '5d37dbcb-891e-41ca-a3d6-e690c97775ac', @@ -17,12 +17,12 @@ export class ApiError extends Error { httpStatusCode: 500, }; - super(e.message); - this.message = e.message; - this.code = e.code; - this.id = e.id; - this.kind = e.kind || 'client'; - this.httpStatusCode = e.httpStatusCode; + super(err.message); + this.message = err.message; + this.code = err.code; + this.id = err.id; + this.kind = err.kind ?? 'client'; + this.httpStatusCode = err.httpStatusCode; this.info = info; } } diff --git a/packages/backend/src/server/api/index.ts b/packages/backend/src/server/api/index.ts deleted file mode 100644 index 83ece51f5..000000000 --- a/packages/backend/src/server/api/index.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * API Server - */ - -import Koa from 'koa'; -import Router from '@koa/router'; -import multer from '@koa/multer'; -import bodyParser from 'koa-bodyparser'; -import cors from '@koa/cors'; - -import { Instances, AccessTokens, Users } from '@/models/index.js'; -import config from '@/config/index.js'; -import endpoints from './endpoints.js'; -import handler from './api-handler.js'; -import signup from './private/signup.js'; -import signin from './private/signin.js'; -import signupPending from './private/signup-pending.js'; -import discord from './service/discord.js'; -import github from './service/github.js'; -import twitter from './service/twitter.js'; - -// Init app -const app = new Koa(); - -app.use(cors({ - origin: '*', -})); - -// No caching -app.use(async (ctx, next) => { - ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); - await next(); -}); - -app.use(bodyParser({ - // リクエストが multipart/form-data でない限りはJSONだと見なす - detectJSON: ctx => !ctx.is('multipart/form-data'), -})); - -// Init multer instance -const upload = multer({ - storage: multer.diskStorage({}), - limits: { - fileSize: config.maxFileSize || 262144000, - files: 1, - }, -}); - -// Init router -const router = new Router(); - -/** - * Register endpoint handlers - */ -for (const endpoint of endpoints) { - if (endpoint.meta.requireFile) { - router.post(`/${endpoint.name}`, upload.single('file'), handler.bind(null, endpoint)); - } else { - // 後方互換性のため - if (endpoint.name.includes('-')) { - router.post(`/${endpoint.name.replace(/-/g, '_')}`, handler.bind(null, endpoint)); - - if (endpoint.meta.allowGet) { - router.get(`/${endpoint.name.replace(/-/g, '_')}`, handler.bind(null, endpoint)); - } else { - router.get(`/${endpoint.name.replace(/-/g, '_')}`, async ctx => { ctx.status = 405; }); - } - } - - router.post(`/${endpoint.name}`, handler.bind(null, endpoint)); - - if (endpoint.meta.allowGet) { - router.get(`/${endpoint.name}`, handler.bind(null, endpoint)); - } else { - router.get(`/${endpoint.name}`, async ctx => { ctx.status = 405; }); - } - } -} - -router.post('/signup', signup); -router.post('/signin', signin); -router.post('/signup-pending', signupPending); - -router.use(discord.routes()); -router.use(github.routes()); -router.use(twitter.routes()); - -router.get('/v1/instance/peers', async ctx => { - const instances = await Instances.find({ - select: ['host'], - }); - - ctx.body = instances.map(instance => instance.host); -}); - -router.post('/miauth/:session/check', async ctx => { - const token = await AccessTokens.findOneBy({ - session: ctx.params.session, - }); - - if (token && token.session != null && !token.fetched) { - AccessTokens.update(token.id, { - fetched: true, - }); - - ctx.body = { - ok: true, - token: token.token, - user: await Users.pack(token.userId, null, { detail: true }), - }; - } else { - ctx.body = { - ok: false, - }; - } -}); - -// Return 404 for unknown API -router.all('(.*)', async ctx => { - ctx.status = 404; -}); - -// Register router -app.use(router.routes()); - -export default app; diff --git a/packages/backend/src/server/api/integration/DiscordServerService.ts b/packages/backend/src/server/api/integration/DiscordServerService.ts new file mode 100644 index 000000000..ea044c27d --- /dev/null +++ b/packages/backend/src/server/api/integration/DiscordServerService.ts @@ -0,0 +1,315 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Redis } from 'ioredis'; +import Router from '@koa/router'; +import { OAuth2 } from 'oauth'; +import { v4 as uuid } from 'uuid'; +import { IsNull } from 'typeorm'; +import { Config } from '@/config.js'; +import { UserProfilesRepository, UsersRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import type { ILocalUser } from '@/models/entities/User.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { MetaService } from '@/core/MetaService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { SigninService } from '../SigninService.js'; +import type Koa from 'koa'; + +@Injectable() +export class DiscordServerService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.redis) + private redisClient: Redis, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + private userEntityService: UserEntityService, + private httpRequestService: HttpRequestService, + private globalEventService: GlobalEventService, + private metaService: MetaService, + private signinService: SigninService, + ) { + } + + public create() { + const router = new Router(); + + router.get('/disconnect/discord', async ctx => { + if (!this.compareOrigin(ctx)) { + ctx.throw(400, 'invalid origin'); + return; + } + + const userToken = this.getUserToken(ctx); + if (!userToken) { + ctx.throw(400, 'signin required'); + return; + } + + const user = await this.usersRepository.findOneByOrFail({ + host: IsNull(), + token: userToken, + }); + + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + + delete profile.integrations.discord; + + await this.userProfilesRepository.update(user.id, { + integrations: profile.integrations, + }); + + ctx.body = 'Discordの連携を解除しました :v:'; + + // Publish i updated event + this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, { + detail: true, + includeSecrets: true, + })); + }); + + const getOAuth2 = async () => { + const meta = await this.metaService.fetch(true); + + if (meta.enableDiscordIntegration) { + return new OAuth2( + meta.discordClientId!, + meta.discordClientSecret!, + 'https://discord.com/', + 'api/oauth2/authorize', + 'api/oauth2/token'); + } else { + return null; + } + }; + + router.get('/connect/discord', async ctx => { + if (!this.compareOrigin(ctx)) { + ctx.throw(400, 'invalid origin'); + return; + } + + const userToken = this.getUserToken(ctx); + if (!userToken) { + ctx.throw(400, 'signin required'); + return; + } + + const params = { + redirect_uri: `${this.config.url}/api/dc/cb`, + scope: ['identify'], + state: uuid(), + response_type: 'code', + }; + + this.redisClient.set(userToken, JSON.stringify(params)); + + const oauth2 = await getOAuth2(); + ctx.redirect(oauth2!.getAuthorizeUrl(params)); + }); + + router.get('/signin/discord', async ctx => { + const sessid = uuid(); + + const params = { + redirect_uri: `${this.config.url}/api/dc/cb`, + scope: ['identify'], + state: uuid(), + response_type: 'code', + }; + + ctx.cookies.set('signin_with_discord_sid', sessid, { + path: '/', + secure: this.config.url.startsWith('https'), + httpOnly: true, + }); + + this.redisClient.set(sessid, JSON.stringify(params)); + + const oauth2 = await getOAuth2(); + ctx.redirect(oauth2!.getAuthorizeUrl(params)); + }); + + router.get('/dc/cb', async ctx => { + const userToken = this.getUserToken(ctx); + + const oauth2 = await getOAuth2(); + + if (!userToken) { + const sessid = ctx.cookies.get('signin_with_discord_sid'); + + if (!sessid) { + ctx.throw(400, 'invalid session'); + return; + } + + const code = ctx.query.code; + + if (!code || typeof code !== 'string') { + ctx.throw(400, 'invalid session'); + return; + } + + const { redirect_uri, state } = await new Promise((res, rej) => { + this.redisClient.get(sessid, async (_, state) => { + res(JSON.parse(state)); + }); + }); + + if (ctx.query.state !== state) { + ctx.throw(400, 'invalid session'); + return; + } + + const { accessToken, refreshToken, expiresDate } = await new Promise((res, rej) => + oauth2!.getOAuthAccessToken(code, { + grant_type: 'authorization_code', + redirect_uri, + }, (err, accessToken, refreshToken, result) => { + if (err) { + rej(err); + } else if (result.error) { + rej(result.error); + } else { + res({ + accessToken, + refreshToken, + expiresDate: Date.now() + Number(result.expires_in) * 1000, + }); + } + })); + + const { id, username, discriminator } = (await this.httpRequestService.getJson('https://discord.com/api/users/@me', '*/*', 10 * 1000, { + 'Authorization': `Bearer ${accessToken}`, + })) as Record; + + if (typeof id !== 'string' || typeof username !== 'string' || typeof discriminator !== 'string') { + ctx.throw(400, 'invalid session'); + return; + } + + const profile = await this.userProfilesRepository.createQueryBuilder() + .where('"integrations"->\'discord\'->>\'id\' = :id', { id: id }) + .andWhere('"userHost" IS NULL') + .getOne(); + + if (profile == null) { + ctx.throw(404, `@${username}#${discriminator}と連携しているMisskeyアカウントはありませんでした...`); + return; + } + + await this.userProfilesRepository.update(profile.userId, { + integrations: { + ...profile.integrations, + discord: { + id: id, + accessToken: accessToken, + refreshToken: refreshToken, + expiresDate: expiresDate, + username: username, + discriminator: discriminator, + }, + }, + }); + + this.signinService.signin(ctx, await this.usersRepository.findOneBy({ id: profile.userId }) as ILocalUser, true); + } else { + const code = ctx.query.code; + + if (!code || typeof code !== 'string') { + ctx.throw(400, 'invalid session'); + return; + } + + const { redirect_uri, state } = await new Promise((res, rej) => { + this.redisClient.get(userToken, async (_, state) => { + res(JSON.parse(state)); + }); + }); + + if (ctx.query.state !== state) { + ctx.throw(400, 'invalid session'); + return; + } + + const { accessToken, refreshToken, expiresDate } = await new Promise((res, rej) => + oauth2!.getOAuthAccessToken(code, { + grant_type: 'authorization_code', + redirect_uri, + }, (err, accessToken, refreshToken, result) => { + if (err) { + rej(err); + } else if (result.error) { + rej(result.error); + } else { + res({ + accessToken, + refreshToken, + expiresDate: Date.now() + Number(result.expires_in) * 1000, + }); + } + })); + + const { id, username, discriminator } = (await this.httpRequestService.getJson('https://discord.com/api/users/@me', '*/*', 10 * 1000, { + 'Authorization': `Bearer ${accessToken}`, + })) as Record; + if (typeof id !== 'string' || typeof username !== 'string' || typeof discriminator !== 'string') { + ctx.throw(400, 'invalid session'); + return; + } + + const user = await this.usersRepository.findOneByOrFail({ + host: IsNull(), + token: userToken, + }); + + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + + await this.userProfilesRepository.update(user.id, { + integrations: { + ...profile.integrations, + discord: { + accessToken: accessToken, + refreshToken: refreshToken, + expiresDate: expiresDate, + id: id, + username: username, + discriminator: discriminator, + }, + }, + }); + + ctx.body = `Discord: @${username}#${discriminator} を、Misskey: @${user.username} に接続しました!`; + + // Publish i updated event + this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, { + detail: true, + includeSecrets: true, + })); + } + }); + + return router; + } + + private getUserToken(ctx: Koa.BaseContext): string | null { + return ((ctx.headers['cookie'] ?? '').match(/igi=(\w+)/) ?? [null, null])[1]; + } + + private compareOrigin(ctx: Koa.BaseContext): boolean { + function normalizeUrl(url?: string): string { + return url ? url.endsWith('/') ? url.substr(0, url.length - 1) : url : ''; + } + + const referer = ctx.headers['referer']; + + return (normalizeUrl(referer) === normalizeUrl(this.config.url)); + } +} diff --git a/packages/backend/src/server/api/integration/GithubServerService.ts b/packages/backend/src/server/api/integration/GithubServerService.ts new file mode 100644 index 000000000..58b170d0e --- /dev/null +++ b/packages/backend/src/server/api/integration/GithubServerService.ts @@ -0,0 +1,287 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Redis } from 'ioredis'; +import Router from '@koa/router'; +import { OAuth2 } from 'oauth'; +import { v4 as uuid } from 'uuid'; +import { IsNull } from 'typeorm'; +import { Config } from '@/config.js'; +import { UserProfilesRepository, UsersRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import type { ILocalUser } from '@/models/entities/User.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { MetaService } from '@/core/MetaService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { SigninService } from '../SigninService.js'; +import type Koa from 'koa'; + +@Injectable() +export class GithubServerService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.redis) + private redisClient: Redis, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + private userEntityService: UserEntityService, + private httpRequestService: HttpRequestService, + private globalEventService: GlobalEventService, + private metaService: MetaService, + private signinService: SigninService, + ) { + } + + public create() { + const router = new Router(); + + router.get('/disconnect/github', async ctx => { + if (!this.compareOrigin(ctx)) { + ctx.throw(400, 'invalid origin'); + return; + } + + const userToken = this.getUserToken(ctx); + if (!userToken) { + ctx.throw(400, 'signin required'); + return; + } + + const user = await this.usersRepository.findOneByOrFail({ + host: IsNull(), + token: userToken, + }); + + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + + delete profile.integrations.github; + + await this.userProfilesRepository.update(user.id, { + integrations: profile.integrations, + }); + + ctx.body = 'GitHubの連携を解除しました :v:'; + + // Publish i updated event + this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, { + detail: true, + includeSecrets: true, + })); + }); + + const getOath2 = async () => { + const meta = await this.metaService.fetch(true); + + if (meta.enableGithubIntegration && meta.githubClientId && meta.githubClientSecret) { + return new OAuth2( + meta.githubClientId, + meta.githubClientSecret, + 'https://github.com/', + 'login/oauth/authorize', + 'login/oauth/access_token'); + } else { + return null; + } + }; + + router.get('/connect/github', async ctx => { + if (!this.compareOrigin(ctx)) { + ctx.throw(400, 'invalid origin'); + return; + } + + const userToken = this.getUserToken(ctx); + if (!userToken) { + ctx.throw(400, 'signin required'); + return; + } + + const params = { + redirect_uri: `${this.config.url}/api/gh/cb`, + scope: ['read:user'], + state: uuid(), + }; + + this.redisClient.set(userToken, JSON.stringify(params)); + + const oauth2 = await getOath2(); + ctx.redirect(oauth2!.getAuthorizeUrl(params)); + }); + + router.get('/signin/github', async ctx => { + const sessid = uuid(); + + const params = { + redirect_uri: `${this.config.url}/api/gh/cb`, + scope: ['read:user'], + state: uuid(), + }; + + ctx.cookies.set('signin_with_github_sid', sessid, { + path: '/', + secure: this.config.url.startsWith('https'), + httpOnly: true, + }); + + this.redisClient.set(sessid, JSON.stringify(params)); + + const oauth2 = await getOath2(); + ctx.redirect(oauth2!.getAuthorizeUrl(params)); + }); + + router.get('/gh/cb', async ctx => { + const userToken = this.getUserToken(ctx); + + const oauth2 = await getOath2(); + + if (!userToken) { + const sessid = ctx.cookies.get('signin_with_github_sid'); + + if (!sessid) { + ctx.throw(400, 'invalid session'); + return; + } + + const code = ctx.query.code; + + if (!code || typeof code !== 'string') { + ctx.throw(400, 'invalid session'); + return; + } + + const { redirect_uri, state } = await new Promise((res, rej) => { + this.redisClient.get(sessid, async (_, state) => { + res(JSON.parse(state)); + }); + }); + + if (ctx.query.state !== state) { + ctx.throw(400, 'invalid session'); + return; + } + + const { accessToken } = await new Promise((res, rej) => + oauth2!.getOAuthAccessToken(code, { + redirect_uri, + }, (err, accessToken, refresh, result) => { + if (err) { + rej(err); + } else if (result.error) { + rej(result.error); + } else { + res({ accessToken }); + } + })); + + const { login, id } = (await this.httpRequestService.getJson('https://api.github.com/user', 'application/vnd.github.v3+json', 10 * 1000, { + 'Authorization': `bearer ${accessToken}`, + })) as Record; + if (typeof login !== 'string' || typeof id !== 'string') { + ctx.throw(400, 'invalid session'); + return; + } + + const link = await this.userProfilesRepository.createQueryBuilder() + .where('"integrations"->\'github\'->>\'id\' = :id', { id: id }) + .andWhere('"userHost" IS NULL') + .getOne(); + + if (link == null) { + ctx.throw(404, `@${login}と連携しているMisskeyアカウントはありませんでした...`); + return; + } + + this.signinService.signin(ctx, await this.usersRepository.findOneBy({ id: link.userId }) as ILocalUser, true); + } else { + const code = ctx.query.code; + + if (!code || typeof code !== 'string') { + ctx.throw(400, 'invalid session'); + return; + } + + const { redirect_uri, state } = await new Promise((res, rej) => { + this.redisClient.get(userToken, async (_, state) => { + res(JSON.parse(state)); + }); + }); + + if (ctx.query.state !== state) { + ctx.throw(400, 'invalid session'); + return; + } + + const { accessToken } = await new Promise((res, rej) => + oauth2!.getOAuthAccessToken( + code, + { redirect_uri }, + (err, accessToken, refresh, result) => { + if (err) { + rej(err); + } else if (result.error) { + rej(result.error); + } else { + res({ accessToken }); + } + })); + + const { login, id } = (await this.httpRequestService.getJson('https://api.github.com/user', 'application/vnd.github.v3+json', 10 * 1000, { + 'Authorization': `bearer ${accessToken}`, + })) as Record; + + if (typeof login !== 'string' || typeof id !== 'string') { + ctx.throw(400, 'invalid session'); + return; + } + + const user = await this.usersRepository.findOneByOrFail({ + host: IsNull(), + token: userToken, + }); + + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + + await this.userProfilesRepository.update(user.id, { + integrations: { + ...profile.integrations, + github: { + accessToken: accessToken, + id: id, + login: login, + }, + }, + }); + + ctx.body = `GitHub: @${login} を、Misskey: @${user.username} に接続しました!`; + + // Publish i updated event + this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, { + detail: true, + includeSecrets: true, + })); + } + }); + + return router; + } + + private getUserToken(ctx: Koa.BaseContext): string | null { + return ((ctx.headers['cookie'] ?? '').match(/igi=(\w+)/) ?? [null, null])[1]; + } + + private compareOrigin(ctx: Koa.BaseContext): boolean { + function normalizeUrl(url?: string): string { + return url ? url.endsWith('/') ? url.substr(0, url.length - 1) : url : ''; + } + + const referer = ctx.headers['referer']; + + return (normalizeUrl(referer) === normalizeUrl(this.config.url)); + } +} diff --git a/packages/backend/src/server/api/integration/TwitterServerService.ts b/packages/backend/src/server/api/integration/TwitterServerService.ts new file mode 100644 index 000000000..a4a67f6c8 --- /dev/null +++ b/packages/backend/src/server/api/integration/TwitterServerService.ts @@ -0,0 +1,230 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Redis } from 'ioredis'; +import Router from '@koa/router'; +import { v4 as uuid } from 'uuid'; +import { IsNull } from 'typeorm'; +import autwh from 'autwh'; +import { Config } from '@/config.js'; +import { UserProfilesRepository, UsersRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import type { ILocalUser } from '@/models/entities/User.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { MetaService } from '@/core/MetaService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { SigninService } from '../SigninService.js'; +import type Koa from 'koa'; + +@Injectable() +export class TwitterServerService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.redis) + private redisClient: Redis, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + private userEntityService: UserEntityService, + private httpRequestService: HttpRequestService, + private globalEventService: GlobalEventService, + private metaService: MetaService, + private signinService: SigninService, + ) { + } + + public create() { + const router = new Router(); + + router.get('/disconnect/twitter', async ctx => { + if (!this.compareOrigin(ctx)) { + ctx.throw(400, 'invalid origin'); + return; + } + + const userToken = this.getUserToken(ctx); + if (userToken == null) { + ctx.throw(400, 'signin required'); + return; + } + + const user = await this.usersRepository.findOneByOrFail({ + host: IsNull(), + token: userToken, + }); + + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + + delete profile.integrations.twitter; + + await this.userProfilesRepository.update(user.id, { + integrations: profile.integrations, + }); + + ctx.body = 'Twitterの連携を解除しました :v:'; + + // Publish i updated event + this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, { + detail: true, + includeSecrets: true, + })); + }); + + const getTwAuth = async () => { + const meta = await this.metaService.fetch(true); + + if (meta.enableTwitterIntegration && meta.twitterConsumerKey && meta.twitterConsumerSecret) { + return autwh({ + consumerKey: meta.twitterConsumerKey, + consumerSecret: meta.twitterConsumerSecret, + callbackUrl: `${this.config.url}/api/tw/cb`, + }); + } else { + return null; + } + }; + + router.get('/connect/twitter', async ctx => { + if (!this.compareOrigin(ctx)) { + ctx.throw(400, 'invalid origin'); + return; + } + + const userToken = this.getUserToken(ctx); + if (userToken == null) { + ctx.throw(400, 'signin required'); + return; + } + + const twAuth = await getTwAuth(); + const twCtx = await twAuth!.begin(); + this.redisClient.set(userToken, JSON.stringify(twCtx)); + ctx.redirect(twCtx.url); + }); + + router.get('/signin/twitter', async ctx => { + const twAuth = await getTwAuth(); + const twCtx = await twAuth!.begin(); + + const sessid = uuid(); + + this.redisClient.set(sessid, JSON.stringify(twCtx)); + + ctx.cookies.set('signin_with_twitter_sid', sessid, { + path: '/', + secure: this.config.url.startsWith('https'), + httpOnly: true, + }); + + ctx.redirect(twCtx.url); + }); + + router.get('/tw/cb', async ctx => { + const userToken = this.getUserToken(ctx); + + const twAuth = await getTwAuth(); + + if (userToken == null) { + const sessid = ctx.cookies.get('signin_with_twitter_sid'); + + if (sessid == null) { + ctx.throw(400, 'invalid session'); + return; + } + + const get = new Promise((res, rej) => { + this.redisClient.get(sessid, async (_, twCtx) => { + res(twCtx); + }); + }); + + const twCtx = await get; + + const verifier = ctx.query.oauth_verifier; + if (!verifier || typeof verifier !== 'string') { + ctx.throw(400, 'invalid session'); + return; + } + + const result = await twAuth!.done(JSON.parse(twCtx), verifier); + + const link = await this.userProfilesRepository.createQueryBuilder() + .where('"integrations"->\'twitter\'->>\'userId\' = :id', { id: result.userId }) + .andWhere('"userHost" IS NULL') + .getOne(); + + if (link == null) { + ctx.throw(404, `@${result.screenName}と連携しているMisskeyアカウントはありませんでした...`); + return; + } + + this.signinService.signin(ctx, await this.usersRepository.findOneBy({ id: link.userId }) as ILocalUser, true); + } else { + const verifier = ctx.query.oauth_verifier; + + if (!verifier || typeof verifier !== 'string') { + ctx.throw(400, 'invalid session'); + return; + } + + const get = new Promise((res, rej) => { + this.redisClient.get(userToken, async (_, twCtx) => { + res(twCtx); + }); + }); + + const twCtx = await get; + + const result = await twAuth!.done(JSON.parse(twCtx), verifier); + + const user = await this.usersRepository.findOneByOrFail({ + host: IsNull(), + token: userToken, + }); + + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + + await this.userProfilesRepository.update(user.id, { + integrations: { + ...profile.integrations, + twitter: { + accessToken: result.accessToken, + accessTokenSecret: result.accessTokenSecret, + userId: result.userId, + screenName: result.screenName, + }, + }, + }); + + ctx.body = `Twitter: @${result.screenName} を、Misskey: @${user.username} に接続しました!`; + + // Publish i updated event + this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, { + detail: true, + includeSecrets: true, + })); + } + }); + + return router; + } + + private getUserToken(ctx: Koa.BaseContext): string | null { + return ((ctx.headers['cookie'] ?? '').match(/igi=(\w+)/) ?? [null, null])[1]; + } + + private compareOrigin(ctx: Koa.BaseContext): boolean { + function normalizeUrl(url?: string): string { + return url ? url.endsWith('/') ? url.substr(0, url.length - 1) : url : ''; + } + + const referer = ctx.headers['referer']; + + return (normalizeUrl(referer) === normalizeUrl(this.config.url)); + } +} diff --git a/packages/backend/src/server/api/limiter.ts b/packages/backend/src/server/api/limiter.ts deleted file mode 100644 index 9a7751716..000000000 --- a/packages/backend/src/server/api/limiter.ts +++ /dev/null @@ -1,77 +0,0 @@ -import Limiter from 'ratelimiter'; -import { CacheableLocalUser, User } from '@/models/entities/user.js'; -import Logger from '@/services/logger.js'; -import { redisClient } from '../../db/redis.js'; -import { IEndpointMeta } from './endpoints.js'; - -const logger = new Logger('limiter'); - -export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable }, actor: string) => new Promise((ok, reject) => { - if (process.env.NODE_ENV === 'test') ok(); - - const hasShortTermLimit = typeof limitation.minInterval === 'number'; - - const hasLongTermLimit = - typeof limitation.duration === 'number' && - typeof limitation.max === 'number'; - - if (hasShortTermLimit) { - min(); - } else if (hasLongTermLimit) { - max(); - } else { - ok(); - } - - // Short-term limit - function min(): void { - const minIntervalLimiter = new Limiter({ - id: `${actor}:${limitation.key}:min`, - duration: limitation.minInterval, - max: 1, - db: redisClient, - }); - - minIntervalLimiter.get((err, info) => { - if (err) { - return reject('ERR'); - } - - logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`); - - if (info.remaining === 0) { - reject('BRIEF_REQUEST_INTERVAL'); - } else { - if (hasLongTermLimit) { - max(); - } else { - ok(); - } - } - }); - } - - // Long term limit - function max(): void { - const limiter = new Limiter({ - id: `${actor}:${limitation.key}`, - duration: limitation.duration, - max: limitation.max, - db: redisClient, - }); - - limiter.get((err, info) => { - if (err) { - return reject('ERR'); - } - - logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`); - - if (info.remaining === 0) { - reject('RATE_LIMIT_EXCEEDED'); - } else { - ok(); - } - }); - } -}); diff --git a/packages/backend/src/server/api/logger.ts b/packages/backend/src/server/api/logger.ts deleted file mode 100644 index ec22d6c3e..000000000 --- a/packages/backend/src/server/api/logger.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Logger from '@/services/logger.js'; - -export const apiLogger = new Logger('api'); diff --git a/packages/backend/src/server/api/openapi/gen-spec.ts b/packages/backend/src/server/api/openapi/gen-spec.ts deleted file mode 100644 index 68fa81404..000000000 --- a/packages/backend/src/server/api/openapi/gen-spec.ts +++ /dev/null @@ -1,190 +0,0 @@ -import endpoints from '../endpoints.js'; -import config from '@/config/index.js'; -import { errors as basicErrors } from './errors.js'; -import { schemas, convertSchemaToOpenApiSchema } from './schemas.js'; - -export function genOpenapiSpec() { - const spec = { - openapi: '3.0.0', - - info: { - version: 'v1', - title: 'Misskey API', - 'x-logo': { url: '/static-assets/api-doc.png' }, - }, - - externalDocs: { - description: 'Repository', - url: 'https://github.com/misskey-dev/misskey', - }, - - servers: [{ - url: config.apiUrl, - }], - - paths: {} as any, - - components: { - schemas: schemas, - - securitySchemes: { - ApiKeyAuth: { - type: 'apiKey', - in: 'body', - name: 'i', - }, - }, - }, - }; - - for (const endpoint of endpoints.filter(ep => !ep.meta.secure)) { - const errors = {} as any; - - if (endpoint.meta.errors) { - for (const e of Object.values(endpoint.meta.errors)) { - errors[e.code] = { - value: { - error: e, - }, - }; - } - } - - const resSchema = endpoint.meta.res ? convertSchemaToOpenApiSchema(endpoint.meta.res) : {}; - - let desc = (endpoint.meta.description ? endpoint.meta.description : 'No description provided.') + '\n\n'; - desc += `**Credential required**: *${endpoint.meta.requireCredential ? 'Yes' : 'No'}*`; - if (endpoint.meta.kind) { - const kind = endpoint.meta.kind; - desc += ` / **Permission**: *${kind}*`; - } - - const requestType = endpoint.meta.requireFile ? 'multipart/form-data' : 'application/json'; - const schema = endpoint.params; - - if (endpoint.meta.requireFile) { - schema.properties.file = { - type: 'string', - format: 'binary', - description: 'The file contents.', - }; - schema.required.push('file'); - } - - const info = { - operationId: endpoint.name, - summary: endpoint.name, - description: desc, - externalDocs: { - description: 'Source code', - url: `https://github.com/misskey-dev/misskey/blob/develop/packages/backend/src/server/api/endpoints/${endpoint.name}.ts`, - }, - ...(endpoint.meta.tags ? { - tags: [endpoint.meta.tags[0]], - } : {}), - ...(endpoint.meta.requireCredential ? { - security: [{ - ApiKeyAuth: [], - }], - } : {}), - requestBody: { - required: true, - content: { - [requestType]: { - schema, - }, - }, - }, - responses: { - ...(endpoint.meta.res ? { - '200': { - description: 'OK (with results)', - content: { - 'application/json': { - schema: resSchema, - }, - }, - }, - } : { - '204': { - description: 'OK (without any results)', - }, - }), - '400': { - description: 'Client error', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error', - }, - examples: { ...errors, ...basicErrors['400'] }, - }, - }, - }, - '401': { - description: 'Authentication error', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error', - }, - examples: basicErrors['401'], - }, - }, - }, - '403': { - description: 'Forbidden error', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error', - }, - examples: basicErrors['403'], - }, - }, - }, - '418': { - description: 'I\'m Ai', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error', - }, - examples: basicErrors['418'], - }, - }, - }, - ...(endpoint.meta.limit ? { - '429': { - description: 'To many requests', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error', - }, - examples: basicErrors['429'], - }, - }, - }, - } : {}), - '500': { - description: 'Internal server error', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error', - }, - examples: basicErrors['500'], - }, - }, - }, - }, - }; - - spec.paths['/' + endpoint.name] = { - post: info, - }; - } - - return spec; -} diff --git a/packages/backend/src/server/api/openapi/schemas.ts b/packages/backend/src/server/api/openapi/schemas.ts index 14bef9cab..796383f5e 100644 --- a/packages/backend/src/server/api/openapi/schemas.ts +++ b/packages/backend/src/server/api/openapi/schemas.ts @@ -1,4 +1,5 @@ -import { refs, Schema } from '@/misc/schema.js'; +import type { Schema } from '@/misc/schema.js'; +import { refs } from '@/misc/schema.js'; export function convertSchemaToOpenApiSchema(schema: Schema) { const res: any = schema; @@ -55,6 +56,6 @@ export const schemas = { }, ...Object.fromEntries( - Object.entries(refs).map(([key, schema]) => [key, convertSchemaToOpenApiSchema(schema)]) + Object.entries(refs).map(([key, schema]) => [key, convertSchemaToOpenApiSchema(schema)]), ), }; diff --git a/packages/backend/src/server/api/private/signin.ts b/packages/backend/src/server/api/private/signin.ts deleted file mode 100644 index 79b31764f..000000000 --- a/packages/backend/src/server/api/private/signin.ts +++ /dev/null @@ -1,250 +0,0 @@ -import Koa from 'koa'; -import bcrypt from 'bcryptjs'; -import * as speakeasy from 'speakeasy'; -import signin from '../common/signin.js'; -import config from '@/config/index.js'; -import { Users, Signins, UserProfiles, UserSecurityKeys, AttestationChallenges } from '@/models/index.js'; -import { ILocalUser } from '@/models/entities/user.js'; -import { genId } from '@/misc/gen-id.js'; -import { verifyLogin, hash } from '../2fa.js'; -import { randomBytes } from 'node:crypto'; -import { IsNull } from 'typeorm'; -import { limiter } from '../limiter.js'; -import { getIpHash } from '@/misc/get-ip-hash.js'; - -export default async (ctx: Koa.Context) => { - ctx.set('Access-Control-Allow-Origin', config.url); - ctx.set('Access-Control-Allow-Credentials', 'true'); - - const body = ctx.request.body as any; - const username = body['username']; - const password = body['password']; - const token = body['token']; - - function error(status: number, error: { id: string }) { - ctx.status = status; - ctx.body = { error }; - } - - try { - // not more than 1 attempt per second and not more than 10 attempts per hour - await limiter({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(ctx.ip)); - } catch (err) { - ctx.status = 429; - ctx.body = { - error: { - message: 'Too many failed attempts to sign in. Try again later.', - code: 'TOO_MANY_AUTHENTICATION_FAILURES', - id: '22d05606-fbcf-421a-a2db-b32610dcfd1b', - }, - }; - return; - } - - if (typeof username !== 'string') { - ctx.status = 400; - return; - } - - if (typeof password !== 'string') { - ctx.status = 400; - return; - } - - if (token != null && typeof token !== 'string') { - ctx.status = 400; - return; - } - - // Fetch user - const user = await Users.findOneBy({ - usernameLower: username.toLowerCase(), - host: IsNull(), - }) as ILocalUser; - - if (user == null) { - error(404, { - id: '6cc579cc-885d-43d8-95c2-b8c7fc963280', - }); - return; - } - - if (user.isSuspended) { - error(403, { - id: 'e03a5f46-d309-4865-9b69-56282d94e1eb', - }); - return; - } - - const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - - // Compare password - const same = await bcrypt.compare(password, profile.password!); - - async function fail(status?: number, failure?: { id: string }) { - // Append signin history - await Signins.insert({ - id: genId(), - createdAt: new Date(), - userId: user.id, - ip: ctx.ip, - headers: ctx.headers, - success: false, - }); - - error(status || 500, failure || { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' }); - } - - if (!profile.twoFactorEnabled) { - if (same) { - signin(ctx, user); - return; - } else { - await fail(403, { - id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', - }); - return; - } - } - - if (token) { - if (!same) { - await fail(403, { - id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', - }); - return; - } - - const verified = (speakeasy as any).totp.verify({ - secret: profile.twoFactorSecret, - encoding: 'base32', - token: token, - window: 2, - }); - - if (verified) { - signin(ctx, user); - return; - } else { - await fail(403, { - id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f', - }); - return; - } - } else if (body.credentialId) { - if (!same && !profile.usePasswordLessLogin) { - await fail(403, { - id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', - }); - return; - } - - const clientDataJSON = Buffer.from(body.clientDataJSON, 'hex'); - const clientData = JSON.parse(clientDataJSON.toString('utf-8')); - const challenge = await AttestationChallenges.findOneBy({ - userId: user.id, - id: body.challengeId, - registrationChallenge: false, - challenge: hash(clientData.challenge).toString('hex'), - }); - - if (!challenge) { - await fail(403, { - id: '2715a88a-2125-4013-932f-aa6fe72792da', - }); - return; - } - - await AttestationChallenges.delete({ - userId: user.id, - id: body.challengeId, - }); - - if (new Date().getTime() - challenge.createdAt.getTime() >= 5 * 60 * 1000) { - await fail(403, { - id: '2715a88a-2125-4013-932f-aa6fe72792da', - }); - return; - } - - const securityKey = await UserSecurityKeys.findOneBy({ - id: Buffer.from( - body.credentialId - .replace(/-/g, '+') - .replace(/_/g, '/'), - 'base64' - ).toString('hex'), - }); - - if (!securityKey) { - await fail(403, { - id: '66269679-aeaf-4474-862b-eb761197e046', - }); - return; - } - - const isValid = verifyLogin({ - publicKey: Buffer.from(securityKey.publicKey, 'hex'), - authenticatorData: Buffer.from(body.authenticatorData, 'hex'), - clientDataJSON, - clientData, - signature: Buffer.from(body.signature, 'hex'), - challenge: challenge.challenge, - }); - - if (isValid) { - signin(ctx, user); - return; - } else { - await fail(403, { - id: '93b86c4b-72f9-40eb-9815-798928603d1e', - }); - return; - } - } else { - if (!same && !profile.usePasswordLessLogin) { - await fail(403, { - id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', - }); - return; - } - - const keys = await UserSecurityKeys.findBy({ - userId: user.id, - }); - - if (keys.length === 0) { - await fail(403, { - id: 'f27fd449-9af4-4841-9249-1f989b9fa4a4', - }); - return; - } - - // 32 byte challenge - const challenge = randomBytes(32).toString('base64') - .replace(/=/g, '') - .replace(/\+/g, '-') - .replace(/\//g, '_'); - - const challengeId = genId(); - - await AttestationChallenges.insert({ - userId: user.id, - id: challengeId, - challenge: hash(Buffer.from(challenge, 'utf-8')).toString('hex'), - createdAt: new Date(), - registrationChallenge: false, - }); - - ctx.body = { - challenge, - challengeId, - securityKeys: keys.map(key => ({ - id: key.id, - })), - }; - ctx.status = 200; - return; - } - // never get here -}; diff --git a/packages/backend/src/server/api/private/signup-pending.ts b/packages/backend/src/server/api/private/signup-pending.ts deleted file mode 100644 index e5e39ba00..000000000 --- a/packages/backend/src/server/api/private/signup-pending.ts +++ /dev/null @@ -1,35 +0,0 @@ -import Koa from 'koa'; -import { Users, UserPendings, UserProfiles } from '@/models/index.js'; -import { signup } from '../common/signup.js'; -import signin from '../common/signin.js'; - -export default async (ctx: Koa.Context) => { - const body = ctx.request.body; - - const code = body['code']; - - try { - const pendingUser = await UserPendings.findOneByOrFail({ code }); - - const { account, secret } = await signup({ - username: pendingUser.username, - passwordHash: pendingUser.password, - }); - - UserPendings.delete({ - id: pendingUser.id, - }); - - const profile = await UserProfiles.findOneByOrFail({ userId: account.id }); - - await UserProfiles.update({ userId: profile.userId }, { - email: pendingUser.email, - emailVerified: true, - emailVerifyCode: null, - }); - - signin(ctx, account); - } catch (e) { - ctx.throw(400, e); - } -}; diff --git a/packages/backend/src/server/api/private/signup.ts b/packages/backend/src/server/api/private/signup.ts deleted file mode 100644 index 26f172637..000000000 --- a/packages/backend/src/server/api/private/signup.ts +++ /dev/null @@ -1,112 +0,0 @@ -import Koa from 'koa'; -import rndstr from 'rndstr'; -import bcrypt from 'bcryptjs'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { verifyHcaptcha, verifyRecaptcha } from '@/misc/captcha.js'; -import { Users, RegistrationTickets, UserPendings } from '@/models/index.js'; -import { signup } from '../common/signup.js'; -import config from '@/config/index.js'; -import { sendEmail } from '@/services/send-email.js'; -import { genId } from '@/misc/gen-id.js'; -import { validateEmailForAccount } from '@/services/validate-email-for-account.js'; - -export default async (ctx: Koa.Context) => { - const body = ctx.request.body; - - const instance = await fetchMeta(true); - - // Verify *Captcha - // ただしテスト時はこの機構は障害となるため無効にする - if (process.env.NODE_ENV !== 'test') { - if (instance.enableHcaptcha && instance.hcaptchaSecretKey) { - await verifyHcaptcha(instance.hcaptchaSecretKey, body['hcaptcha-response']).catch(e => { - ctx.throw(400, e); - }); - } - - if (instance.enableRecaptcha && instance.recaptchaSecretKey) { - await verifyRecaptcha(instance.recaptchaSecretKey, body['g-recaptcha-response']).catch(e => { - ctx.throw(400, e); - }); - } - } - - const username = body['username']; - const password = body['password']; - const host: string | null = process.env.NODE_ENV === 'test' ? (body['host'] || null) : null; - const invitationCode = body['invitationCode']; - const emailAddress = body['emailAddress']; - - if (instance.emailRequiredForSignup) { - if (emailAddress == null || typeof emailAddress !== 'string') { - ctx.status = 400; - return; - } - - const available = await validateEmailForAccount(emailAddress); - if (!available) { - ctx.status = 400; - return; - } - } - - if (instance.disableRegistration) { - if (invitationCode == null || typeof invitationCode !== 'string') { - ctx.status = 400; - return; - } - - const ticket = await RegistrationTickets.findOneBy({ - code: invitationCode, - }); - - if (ticket == null) { - ctx.status = 400; - return; - } - - RegistrationTickets.delete(ticket.id); - } - - if (instance.emailRequiredForSignup) { - const code = rndstr('a-z0-9', 16); - - // Generate hash of password - const salt = await bcrypt.genSalt(8); - const hash = await bcrypt.hash(password, salt); - - await UserPendings.insert({ - id: genId(), - createdAt: new Date(), - code, - email: emailAddress, - username: username, - password: hash, - }); - - const link = `${config.url}/signup-complete/${code}`; - - sendEmail(emailAddress, 'Signup', - `To complete signup, please click this link:
${link}`, - `To complete signup, please click this link: ${link}`); - - ctx.status = 204; - } else { - try { - const { account, secret } = await signup({ - username, password, host, - }); - - const res = await Users.pack(account, account, { - detail: true, - includeSecrets: true, - }); - - (res as any).token = secret; - - ctx.body = res; - } catch (e) { - ctx.throw(400, e); - } - } -}; diff --git a/packages/backend/src/server/api/service/discord.ts b/packages/backend/src/server/api/service/discord.ts deleted file mode 100644 index 97cbcbecd..000000000 --- a/packages/backend/src/server/api/service/discord.ts +++ /dev/null @@ -1,287 +0,0 @@ -import Koa from 'koa'; -import Router from '@koa/router'; -import { OAuth2 } from 'oauth'; -import { v4 as uuid } from 'uuid'; -import { IsNull } from 'typeorm'; -import { getJson } from '@/misc/fetch.js'; -import config from '@/config/index.js'; -import { publishMainStream } from '@/services/stream.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { Users, UserProfiles } from '@/models/index.js'; -import { ILocalUser } from '@/models/entities/user.js'; -import { redisClient } from '../../../db/redis.js'; -import signin from '../common/signin.js'; - -function getUserToken(ctx: Koa.BaseContext): string | null { - return ((ctx.headers['cookie'] || '').match(/igi=(\w+)/) || [null, null])[1]; -} - -function compareOrigin(ctx: Koa.BaseContext): boolean { - function normalizeUrl(url?: string): string { - return url ? url.endsWith('/') ? url.substr(0, url.length - 1) : url : ''; - } - - const referer = ctx.headers['referer']; - - return (normalizeUrl(referer) === normalizeUrl(config.url)); -} - -// Init router -const router = new Router(); - -router.get('/disconnect/discord', async ctx => { - if (!compareOrigin(ctx)) { - ctx.throw(400, 'invalid origin'); - return; - } - - const userToken = getUserToken(ctx); - if (!userToken) { - ctx.throw(400, 'signin required'); - return; - } - - const user = await Users.findOneByOrFail({ - host: IsNull(), - token: userToken, - }); - - const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - - delete profile.integrations.discord; - - await UserProfiles.update(user.id, { - integrations: profile.integrations, - }); - - ctx.body = 'Discordの連携を解除しました :v:'; - - // Publish i updated event - publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, { - detail: true, - includeSecrets: true, - })); -}); - -async function getOAuth2() { - const meta = await fetchMeta(true); - - if (meta.enableDiscordIntegration) { - return new OAuth2( - meta.discordClientId!, - meta.discordClientSecret!, - 'https://discord.com/', - 'api/oauth2/authorize', - 'api/oauth2/token'); - } else { - return null; - } -} - -router.get('/connect/discord', async ctx => { - if (!compareOrigin(ctx)) { - ctx.throw(400, 'invalid origin'); - return; - } - - const userToken = getUserToken(ctx); - if (!userToken) { - ctx.throw(400, 'signin required'); - return; - } - - const params = { - redirect_uri: `${config.url}/api/dc/cb`, - scope: ['identify'], - state: uuid(), - response_type: 'code', - }; - - redisClient.set(userToken, JSON.stringify(params)); - - const oauth2 = await getOAuth2(); - ctx.redirect(oauth2!.getAuthorizeUrl(params)); -}); - -router.get('/signin/discord', async ctx => { - const sessid = uuid(); - - const params = { - redirect_uri: `${config.url}/api/dc/cb`, - scope: ['identify'], - state: uuid(), - response_type: 'code', - }; - - ctx.cookies.set('signin_with_discord_sid', sessid, { - path: '/', - secure: config.url.startsWith('https'), - httpOnly: true, - }); - - redisClient.set(sessid, JSON.stringify(params)); - - const oauth2 = await getOAuth2(); - ctx.redirect(oauth2!.getAuthorizeUrl(params)); -}); - -router.get('/dc/cb', async ctx => { - const userToken = getUserToken(ctx); - - const oauth2 = await getOAuth2(); - - if (!userToken) { - const sessid = ctx.cookies.get('signin_with_discord_sid'); - - if (!sessid) { - ctx.throw(400, 'invalid session'); - return; - } - - const code = ctx.query.code; - - if (!code || typeof code !== 'string') { - ctx.throw(400, 'invalid session'); - return; - } - - const { redirect_uri, state } = await new Promise((res, rej) => { - redisClient.get(sessid, async (_, state) => { - res(JSON.parse(state)); - }); - }); - - if (ctx.query.state !== state) { - ctx.throw(400, 'invalid session'); - return; - } - - const { accessToken, refreshToken, expiresDate } = await new Promise((res, rej) => - oauth2!.getOAuthAccessToken(code, { - grant_type: 'authorization_code', - redirect_uri, - }, (err, accessToken, refreshToken, result) => { - if (err) { - rej(err); - } else if (result.error) { - rej(result.error); - } else { - res({ - accessToken, - refreshToken, - expiresDate: Date.now() + Number(result.expires_in) * 1000, - }); - } - })); - - const { id, username, discriminator } = (await getJson('https://discord.com/api/users/@me', '*/*', 10 * 1000, { - 'Authorization': `Bearer ${accessToken}`, - })) as Record; - - if (typeof id !== 'string' || typeof username !== 'string' || typeof discriminator !== 'string') { - ctx.throw(400, 'invalid session'); - return; - } - - const profile = await UserProfiles.createQueryBuilder() - .where('"integrations"->\'discord\'->>\'id\' = :id', { id: id }) - .andWhere('"userHost" IS NULL') - .getOne(); - - if (profile == null) { - ctx.throw(404, `@${username}#${discriminator}と連携しているMisskeyアカウントはありませんでした...`); - return; - } - - await UserProfiles.update(profile.userId, { - integrations: { - ...profile.integrations, - discord: { - id: id, - accessToken: accessToken, - refreshToken: refreshToken, - expiresDate: expiresDate, - username: username, - discriminator: discriminator, - }, - }, - }); - - signin(ctx, await Users.findOneBy({ id: profile.userId }) as ILocalUser, true); - } else { - const code = ctx.query.code; - - if (!code || typeof code !== 'string') { - ctx.throw(400, 'invalid session'); - return; - } - - const { redirect_uri, state } = await new Promise((res, rej) => { - redisClient.get(userToken, async (_, state) => { - res(JSON.parse(state)); - }); - }); - - if (ctx.query.state !== state) { - ctx.throw(400, 'invalid session'); - return; - } - - const { accessToken, refreshToken, expiresDate } = await new Promise((res, rej) => - oauth2!.getOAuthAccessToken(code, { - grant_type: 'authorization_code', - redirect_uri, - }, (err, accessToken, refreshToken, result) => { - if (err) { - rej(err); - } else if (result.error) { - rej(result.error); - } else { - res({ - accessToken, - refreshToken, - expiresDate: Date.now() + Number(result.expires_in) * 1000, - }); - } - })); - - const { id, username, discriminator } = (await getJson('https://discord.com/api/users/@me', '*/*', 10 * 1000, { - 'Authorization': `Bearer ${accessToken}`, - })) as Record; - if (typeof id !== 'string' || typeof username !== 'string' || typeof discriminator !== 'string') { - ctx.throw(400, 'invalid session'); - return; - } - - const user = await Users.findOneByOrFail({ - host: IsNull(), - token: userToken, - }); - - const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - - await UserProfiles.update(user.id, { - integrations: { - ...profile.integrations, - discord: { - accessToken: accessToken, - refreshToken: refreshToken, - expiresDate: expiresDate, - id: id, - username: username, - discriminator: discriminator, - }, - }, - }); - - ctx.body = `Discord: @${username}#${discriminator} を、Misskey: @${user.username} に接続しました!`; - - // Publish i updated event - publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, { - detail: true, - includeSecrets: true, - })); - } -}); - -export default router; diff --git a/packages/backend/src/server/api/service/github.ts b/packages/backend/src/server/api/service/github.ts deleted file mode 100644 index 04dbd1f7a..000000000 --- a/packages/backend/src/server/api/service/github.ts +++ /dev/null @@ -1,259 +0,0 @@ -import Koa from 'koa'; -import Router from '@koa/router'; -import { OAuth2 } from 'oauth'; -import { v4 as uuid } from 'uuid'; -import { IsNull } from 'typeorm'; -import { getJson } from '@/misc/fetch.js'; -import config from '@/config/index.js'; -import { publishMainStream } from '@/services/stream.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { Users, UserProfiles } from '@/models/index.js'; -import { ILocalUser } from '@/models/entities/user.js'; -import { redisClient } from '../../../db/redis.js'; -import signin from '../common/signin.js'; - -function getUserToken(ctx: Koa.BaseContext): string | null { - return ((ctx.headers['cookie'] || '').match(/igi=(\w+)/) || [null, null])[1]; -} - -function compareOrigin(ctx: Koa.BaseContext): boolean { - function normalizeUrl(url?: string): string { - return url ? url.endsWith('/') ? url.substr(0, url.length - 1) : url : ''; - } - - const referer = ctx.headers['referer']; - - return (normalizeUrl(referer) === normalizeUrl(config.url)); -} - -// Init router -const router = new Router(); - -router.get('/disconnect/github', async ctx => { - if (!compareOrigin(ctx)) { - ctx.throw(400, 'invalid origin'); - return; - } - - const userToken = getUserToken(ctx); - if (!userToken) { - ctx.throw(400, 'signin required'); - return; - } - - const user = await Users.findOneByOrFail({ - host: IsNull(), - token: userToken, - }); - - const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - - delete profile.integrations.github; - - await UserProfiles.update(user.id, { - integrations: profile.integrations, - }); - - ctx.body = 'GitHubの連携を解除しました :v:'; - - // Publish i updated event - publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, { - detail: true, - includeSecrets: true, - })); -}); - -async function getOath2() { - const meta = await fetchMeta(true); - - if (meta.enableGithubIntegration && meta.githubClientId && meta.githubClientSecret) { - return new OAuth2( - meta.githubClientId, - meta.githubClientSecret, - 'https://github.com/', - 'login/oauth/authorize', - 'login/oauth/access_token'); - } else { - return null; - } -} - -router.get('/connect/github', async ctx => { - if (!compareOrigin(ctx)) { - ctx.throw(400, 'invalid origin'); - return; - } - - const userToken = getUserToken(ctx); - if (!userToken) { - ctx.throw(400, 'signin required'); - return; - } - - const params = { - redirect_uri: `${config.url}/api/gh/cb`, - scope: ['read:user'], - state: uuid(), - }; - - redisClient.set(userToken, JSON.stringify(params)); - - const oauth2 = await getOath2(); - ctx.redirect(oauth2!.getAuthorizeUrl(params)); -}); - -router.get('/signin/github', async ctx => { - const sessid = uuid(); - - const params = { - redirect_uri: `${config.url}/api/gh/cb`, - scope: ['read:user'], - state: uuid(), - }; - - ctx.cookies.set('signin_with_github_sid', sessid, { - path: '/', - secure: config.url.startsWith('https'), - httpOnly: true, - }); - - redisClient.set(sessid, JSON.stringify(params)); - - const oauth2 = await getOath2(); - ctx.redirect(oauth2!.getAuthorizeUrl(params)); -}); - -router.get('/gh/cb', async ctx => { - const userToken = getUserToken(ctx); - - const oauth2 = await getOath2(); - - if (!userToken) { - const sessid = ctx.cookies.get('signin_with_github_sid'); - - if (!sessid) { - ctx.throw(400, 'invalid session'); - return; - } - - const code = ctx.query.code; - - if (!code || typeof code !== 'string') { - ctx.throw(400, 'invalid session'); - return; - } - - const { redirect_uri, state } = await new Promise((res, rej) => { - redisClient.get(sessid, async (_, state) => { - res(JSON.parse(state)); - }); - }); - - if (ctx.query.state !== state) { - ctx.throw(400, 'invalid session'); - return; - } - - const { accessToken } = await new Promise((res, rej) => - oauth2!.getOAuthAccessToken(code, { - redirect_uri, - }, (err, accessToken, refresh, result) => { - if (err) { - rej(err); - } else if (result.error) { - rej(result.error); - } else { - res({ accessToken }); - } - })); - - const { login, id } = (await getJson('https://api.github.com/user', 'application/vnd.github.v3+json', 10 * 1000, { - 'Authorization': `bearer ${accessToken}`, - })) as Record; - if (typeof login !== 'string' || typeof id !== 'string') { - ctx.throw(400, 'invalid session'); - return; - } - - const link = await UserProfiles.createQueryBuilder() - .where('"integrations"->\'github\'->>\'id\' = :id', { id: id }) - .andWhere('"userHost" IS NULL') - .getOne(); - - if (link == null) { - ctx.throw(404, `@${login}と連携しているMisskeyアカウントはありませんでした...`); - return; - } - - signin(ctx, await Users.findOneBy({ id: link.userId }) as ILocalUser, true); - } else { - const code = ctx.query.code; - - if (!code || typeof code !== 'string') { - ctx.throw(400, 'invalid session'); - return; - } - - const { redirect_uri, state } = await new Promise((res, rej) => { - redisClient.get(userToken, async (_, state) => { - res(JSON.parse(state)); - }); - }); - - if (ctx.query.state !== state) { - ctx.throw(400, 'invalid session'); - return; - } - - const { accessToken } = await new Promise((res, rej) => - oauth2!.getOAuthAccessToken( - code, - { redirect_uri }, - (err, accessToken, refresh, result) => { - if (err) { - rej(err); - } else if (result.error) { - rej(result.error); - } else { - res({ accessToken }); - } - })); - - const { login, id } = (await getJson('https://api.github.com/user', 'application/vnd.github.v3+json', 10 * 1000, { - 'Authorization': `bearer ${accessToken}`, - })) as Record; - - if (typeof login !== 'string' || typeof id !== 'string') { - ctx.throw(400, 'invalid session'); - return; - } - - const user = await Users.findOneByOrFail({ - host: IsNull(), - token: userToken, - }); - - const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - - await UserProfiles.update(user.id, { - integrations: { - ...profile.integrations, - github: { - accessToken: accessToken, - id: id, - login: login, - }, - }, - }); - - ctx.body = `GitHub: @${login} を、Misskey: @${user.username} に接続しました!`; - - // Publish i updated event - publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, { - detail: true, - includeSecrets: true, - })); - } -}); - -export default router; diff --git a/packages/backend/src/server/api/service/twitter.ts b/packages/backend/src/server/api/service/twitter.ts deleted file mode 100644 index 2b4f9f6da..000000000 --- a/packages/backend/src/server/api/service/twitter.ts +++ /dev/null @@ -1,201 +0,0 @@ -import Koa from 'koa'; -import Router from '@koa/router'; -import { v4 as uuid } from 'uuid'; -import autwh from 'autwh'; -import { IsNull } from 'typeorm'; -import { publishMainStream } from '@/services/stream.js'; -import config from '@/config/index.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { Users, UserProfiles } from '@/models/index.js'; -import { ILocalUser } from '@/models/entities/user.js'; -import signin from '../common/signin.js'; -import { redisClient } from '../../../db/redis.js'; - -function getUserToken(ctx: Koa.BaseContext): string | null { - return ((ctx.headers['cookie'] || '').match(/igi=(\w+)/) || [null, null])[1]; -} - -function compareOrigin(ctx: Koa.BaseContext): boolean { - function normalizeUrl(url?: string): string { - return url == null ? '' : url.endsWith('/') ? url.substr(0, url.length - 1) : url; - } - - const referer = ctx.headers['referer']; - - return (normalizeUrl(referer) === normalizeUrl(config.url)); -} - -// Init router -const router = new Router(); - -router.get('/disconnect/twitter', async ctx => { - if (!compareOrigin(ctx)) { - ctx.throw(400, 'invalid origin'); - return; - } - - const userToken = getUserToken(ctx); - if (userToken == null) { - ctx.throw(400, 'signin required'); - return; - } - - const user = await Users.findOneByOrFail({ - host: IsNull(), - token: userToken, - }); - - const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - - delete profile.integrations.twitter; - - await UserProfiles.update(user.id, { - integrations: profile.integrations, - }); - - ctx.body = 'Twitterの連携を解除しました :v:'; - - // Publish i updated event - publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, { - detail: true, - includeSecrets: true, - })); -}); - -async function getTwAuth() { - const meta = await fetchMeta(true); - - if (meta.enableTwitterIntegration && meta.twitterConsumerKey && meta.twitterConsumerSecret) { - return autwh({ - consumerKey: meta.twitterConsumerKey, - consumerSecret: meta.twitterConsumerSecret, - callbackUrl: `${config.url}/api/tw/cb`, - }); - } else { - return null; - } -} - -router.get('/connect/twitter', async ctx => { - if (!compareOrigin(ctx)) { - ctx.throw(400, 'invalid origin'); - return; - } - - const userToken = getUserToken(ctx); - if (userToken == null) { - ctx.throw(400, 'signin required'); - return; - } - - const twAuth = await getTwAuth(); - const twCtx = await twAuth!.begin(); - redisClient.set(userToken, JSON.stringify(twCtx)); - ctx.redirect(twCtx.url); -}); - -router.get('/signin/twitter', async ctx => { - const twAuth = await getTwAuth(); - const twCtx = await twAuth!.begin(); - - const sessid = uuid(); - - redisClient.set(sessid, JSON.stringify(twCtx)); - - ctx.cookies.set('signin_with_twitter_sid', sessid, { - path: '/', - secure: config.url.startsWith('https'), - httpOnly: true, - }); - - ctx.redirect(twCtx.url); -}); - -router.get('/tw/cb', async ctx => { - const userToken = getUserToken(ctx); - - const twAuth = await getTwAuth(); - - if (userToken == null) { - const sessid = ctx.cookies.get('signin_with_twitter_sid'); - - if (sessid == null) { - ctx.throw(400, 'invalid session'); - return; - } - - const get = new Promise((res, rej) => { - redisClient.get(sessid, async (_, twCtx) => { - res(twCtx); - }); - }); - - const twCtx = await get; - - const verifier = ctx.query.oauth_verifier; - if (!verifier || typeof verifier !== 'string') { - ctx.throw(400, 'invalid session'); - return; - } - - const result = await twAuth!.done(JSON.parse(twCtx), verifier); - - const link = await UserProfiles.createQueryBuilder() - .where('"integrations"->\'twitter\'->>\'userId\' = :id', { id: result.userId }) - .andWhere('"userHost" IS NULL') - .getOne(); - - if (link == null) { - ctx.throw(404, `@${result.screenName}と連携しているMisskeyアカウントはありませんでした...`); - return; - } - - signin(ctx, await Users.findOneBy({ id: link.userId }) as ILocalUser, true); - } else { - const verifier = ctx.query.oauth_verifier; - - if (!verifier || typeof verifier !== 'string') { - ctx.throw(400, 'invalid session'); - return; - } - - const get = new Promise((res, rej) => { - redisClient.get(userToken, async (_, twCtx) => { - res(twCtx); - }); - }); - - const twCtx = await get; - - const result = await twAuth!.done(JSON.parse(twCtx), verifier); - - const user = await Users.findOneByOrFail({ - host: IsNull(), - token: userToken, - }); - - const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - - await UserProfiles.update(user.id, { - integrations: { - ...profile.integrations, - twitter: { - accessToken: result.accessToken, - accessTokenSecret: result.accessTokenSecret, - userId: result.userId, - screenName: result.screenName, - }, - }, - }); - - ctx.body = `Twitter: @${result.screenName} を、Misskey: @${user.username} に接続しました!`; - - // Publish i updated event - publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, { - detail: true, - includeSecrets: true, - })); - } -}); - -export default router; diff --git a/packages/backend/src/server/api/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts new file mode 100644 index 000000000..d6005b1ee --- /dev/null +++ b/packages/backend/src/server/api/stream/ChannelsService.ts @@ -0,0 +1,62 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { HybridTimelineChannelService } from './channels/hybrid-timeline.js'; +import { LocalTimelineChannelService } from './channels/local-timeline.js'; +import { HomeTimelineChannelService } from './channels/home-timeline.js'; +import { GlobalTimelineChannelService } from './channels/global-timeline.js'; +import { MainChannelService } from './channels/main.js'; +import { ChannelChannelService } from './channels/channel.js'; +import { AdminChannelService } from './channels/admin.js'; +import { ServerStatsChannelService } from './channels/server-stats.js'; +import { QueueStatsChannelService } from './channels/queue-stats.js'; +import { UserListChannelService } from './channels/user-list.js'; +import { AntennaChannelService } from './channels/antenna.js'; +import { MessagingChannelService } from './channels/messaging.js'; +import { MessagingIndexChannelService } from './channels/messaging-index.js'; +import { DriveChannelService } from './channels/drive.js'; +import { HashtagChannelService } from './channels/hashtag.js'; + +@Injectable() +export class ChannelsService { + constructor( + private mainChannelService: MainChannelService, + private homeTimelineChannelService: HomeTimelineChannelService, + private localTimelineChannelService: LocalTimelineChannelService, + private hybridTimelineChannelService: HybridTimelineChannelService, + private globalTimelineChannelService: GlobalTimelineChannelService, + private userListChannelService: UserListChannelService, + private hashtagChannelService: HashtagChannelService, + private antennaChannelService: AntennaChannelService, + private channelChannelService: ChannelChannelService, + private messagingChannelService: MessagingChannelService, + private messagingIndexChannelService: MessagingIndexChannelService, + private driveChannelService: DriveChannelService, + private serverStatsChannelService: ServerStatsChannelService, + private queueStatsChannelService: QueueStatsChannelService, + private adminChannelService: AdminChannelService, + ) { + } + + public getChannelService(name: string) { + switch (name) { + case 'main': return this.mainChannelService; + case 'homeTimeline': return this.homeTimelineChannelService; + case 'localTimeline': return this.localTimelineChannelService; + case 'hybridTimeline': return this.hybridTimelineChannelService; + case 'globalTimeline': return this.globalTimelineChannelService; + case 'userList': return this.userListChannelService; + case 'hashtag': return this.hashtagChannelService; + case 'antenna': return this.antennaChannelService; + case 'channel': return this.channelChannelService; + case 'messaging': return this.messagingChannelService; + case 'messagingIndex': return this.messagingIndexChannelService; + case 'drive': return this.driveChannelService; + case 'serverStats': return this.serverStatsChannelService; + case 'queueStats': return this.queueStatsChannelService; + case 'admin': return this.adminChannelService; + + default: + throw new Error(`no such channel: ${name}`); + } + } +} diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index d2cc5122d..5480c12c0 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -1,4 +1,4 @@ -import Connection from '.'; +import type Connection from '.'; /** * Stream channel diff --git a/packages/backend/src/server/api/stream/channels/admin.ts b/packages/backend/src/server/api/stream/channels/admin.ts index 945182ea1..8c3c0d2ad 100644 --- a/packages/backend/src/server/api/stream/channels/admin.ts +++ b/packages/backend/src/server/api/stream/channels/admin.ts @@ -1,6 +1,7 @@ +import { Inject, Injectable } from '@nestjs/common'; import Channel from '../channel.js'; -export default class extends Channel { +class AdminChannel extends Channel { public readonly chName = 'admin'; public static shouldShare = true; public static requireCredential = true; @@ -12,3 +13,20 @@ export default class extends Channel { }); } } + +@Injectable() +export class AdminChannelService { + public readonly shouldShare = AdminChannel.shouldShare; + public readonly requireCredential = AdminChannel.requireCredential; + + constructor( + ) { + } + + public create(id: string, connection: Channel['connection']): AdminChannel { + return new AdminChannel( + id, + connection, + ); + } +} diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index d28320d92..7c34aef49 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -1,15 +1,22 @@ -import Channel from '../channel.js'; -import { Notes } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import type { NotesRepository } from '@/models/index.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import { StreamMessages } from '../types.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import Channel from '../channel.js'; +import type { StreamMessages } from '../types.js'; -export default class extends Channel { +class AntennaChannel extends Channel { public readonly chName = 'antenna'; public static shouldShare = false; public static requireCredential = false; private antennaId: string; - constructor(id: string, connection: Channel['connection']) { + constructor( + private noteEntityService: NoteEntityService, + + id: string, + connection: Channel['connection'], + ) { super(id, connection); this.onEvent = this.onEvent.bind(this); } @@ -23,7 +30,7 @@ export default class extends Channel { private async onEvent(data: StreamMessages['antenna']['payload']) { if (data.type === 'note') { - const note = await Notes.pack(data.body.id, this.user, { detail: true }); + const note = await this.noteEntityService.pack(data.body.id, this.user, { detail: true }); // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する if (isUserRelated(note, this.muting)) return; @@ -43,3 +50,22 @@ export default class extends Channel { this.subscriber.off(`antennaStream:${this.antennaId}`, this.onEvent); } } + +@Injectable() +export class AntennaChannelService { + public readonly shouldShare = AntennaChannel.shouldShare; + public readonly requireCredential = AntennaChannel.requireCredential; + + constructor( + private noteEntityService: NoteEntityService, + ) { + } + + public create(id: string, connection: Channel['connection']): AntennaChannel { + return new AntennaChannel( + this.noteEntityService, + id, + connection, + ); + } +} diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts index 3cdd89a8b..2ef70e62e 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -1,11 +1,14 @@ -import Channel from '../channel.js'; -import { Notes, Users } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import type { NotesRepository, UsersRepository } from '@/models/index.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import { User } from '@/models/entities/user.js'; -import { StreamMessages } from '../types.js'; -import { Packed } from '@/misc/schema.js'; +import type { User } from '@/models/entities/User.js'; +import type { Packed } from '@/misc/schema.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import Channel from '../channel.js'; +import type { StreamMessages } from '../types.js'; -export default class extends Channel { +class ChannelChannel extends Channel { public readonly chName = 'channel'; public static shouldShare = false; public static requireCredential = false; @@ -13,7 +16,13 @@ export default class extends Channel { private typers: Record = {}; private emitTypersIntervalId: ReturnType; - constructor(id: string, connection: Channel['connection']) { + constructor( + private noteEntityService: NoteEntityService, + private userEntityService: UserEntityService, + + id: string, + connection: Channel['connection'], + ) { super(id, connection); this.onNote = this.onNote.bind(this); this.emitTypers = this.emitTypers.bind(this); @@ -33,13 +42,13 @@ export default class extends Channel { // リプライなら再pack if (note.replyId != null) { - note.reply = await Notes.pack(note.replyId, this.user, { + note.reply = await this.noteEntityService.pack(note.replyId, this.user, { detail: true, }); } // Renoteなら再pack if (note.renoteId != null) { - note.renote = await Notes.pack(note.renoteId, this.user, { + note.renote = await this.noteEntityService.pack(note.renoteId, this.user, { detail: true, }); } @@ -73,7 +82,7 @@ export default class extends Channel { if (now.getTime() - date.getTime() > 5000) delete this.typers[userId]; } - const users = await Users.packMany(Object.keys(this.typers), null, { detail: false }); + const users = await this.userEntityService.packMany(Object.keys(this.typers), null, { detail: false }); this.send({ type: 'typers', @@ -89,3 +98,24 @@ export default class extends Channel { clearInterval(this.emitTypersIntervalId); } } + +@Injectable() +export class ChannelChannelService { + public readonly shouldShare = ChannelChannel.shouldShare; + public readonly requireCredential = ChannelChannel.requireCredential; + + constructor( + private noteEntityService: NoteEntityService, + private userEntityService: UserEntityService, + ) { + } + + public create(id: string, connection: Channel['connection']): ChannelChannel { + return new ChannelChannel( + this.noteEntityService, + this.userEntityService, + id, + connection, + ); + } +} diff --git a/packages/backend/src/server/api/stream/channels/drive.ts b/packages/backend/src/server/api/stream/channels/drive.ts index 140255acd..80d83cd69 100644 --- a/packages/backend/src/server/api/stream/channels/drive.ts +++ b/packages/backend/src/server/api/stream/channels/drive.ts @@ -1,6 +1,7 @@ +import { Inject, Injectable } from '@nestjs/common'; import Channel from '../channel.js'; -export default class extends Channel { +class DriveChannel extends Channel { public readonly chName = 'drive'; public static shouldShare = true; public static requireCredential = true; @@ -12,3 +13,20 @@ export default class extends Channel { }); } } + +@Injectable() +export class DriveChannelService { + public readonly shouldShare = DriveChannel.shouldShare; + public readonly requireCredential = DriveChannel.requireCredential; + + constructor( + ) { + } + + public create(id: string, connection: Channel['connection']): DriveChannel { + return new DriveChannel( + id, + connection, + ); + } +} diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index 5b4ae850e..a8617582d 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -1,23 +1,31 @@ -import Channel from '../channel.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { Notes } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import type { NotesRepository } from '@/models/index.js'; import { checkWordMute } from '@/misc/check-word-mute.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/schema.js'; +import { MetaService } from '@/core/MetaService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import Channel from '../channel.js'; -export default class extends Channel { +class GlobalTimelineChannel extends Channel { public readonly chName = 'globalTimeline'; public static shouldShare = true; public static requireCredential = false; - constructor(id: string, connection: Channel['connection']) { + constructor( + private metaService: MetaService, + private noteEntityService: NoteEntityService, + + id: string, + connection: Channel['connection'], + ) { super(id, connection); this.onNote = this.onNote.bind(this); } public async init(params: any) { - const meta = await fetchMeta(); + const meta = await this.metaService.fetch(); if (meta.disableGlobalTimeline) { if (this.user == null || (!this.user.isAdmin && !this.user.isModerator)) return; } @@ -32,13 +40,13 @@ export default class extends Channel { // リプライなら再pack if (note.replyId != null) { - note.reply = await Notes.pack(note.replyId, this.user, { + note.reply = await this.noteEntityService.pack(note.replyId, this.user, { detail: true, }); } // Renoteなら再pack if (note.renoteId != null) { - note.renote = await Notes.pack(note.renoteId, this.user, { + note.renote = await this.noteEntityService.pack(note.renoteId, this.user, { detail: true, }); } @@ -75,3 +83,24 @@ export default class extends Channel { this.subscriber.off('notesStream', this.onNote); } } + +@Injectable() +export class GlobalTimelineChannelService { + public readonly shouldShare = GlobalTimelineChannel.shouldShare; + public readonly requireCredential = GlobalTimelineChannel.requireCredential; + + constructor( + private metaService: MetaService, + private noteEntityService: NoteEntityService, + ) { + } + + public create(id: string, connection: Channel['connection']): GlobalTimelineChannel { + return new GlobalTimelineChannel( + this.metaService, + this.noteEntityService, + id, + connection, + ); + } +} diff --git a/packages/backend/src/server/api/stream/channels/hashtag.ts b/packages/backend/src/server/api/stream/channels/hashtag.ts index 741db447e..0f6c081c1 100644 --- a/packages/backend/src/server/api/stream/channels/hashtag.ts +++ b/packages/backend/src/server/api/stream/channels/hashtag.ts @@ -1,16 +1,23 @@ -import Channel from '../channel.js'; -import { Notes } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import type { NotesRepository } from '@/models/index.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/schema.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import Channel from '../channel.js'; -export default class extends Channel { +class HashtagChannel extends Channel { public readonly chName = 'hashtag'; public static shouldShare = false; public static requireCredential = false; private q: string[][]; - constructor(id: string, connection: Channel['connection']) { + constructor( + private noteEntityService: NoteEntityService, + + id: string, + connection: Channel['connection'], + ) { super(id, connection); this.onNote = this.onNote.bind(this); } @@ -31,7 +38,7 @@ export default class extends Channel { // Renoteなら再pack if (note.renoteId != null) { - note.renote = await Notes.pack(note.renoteId, this.user, { + note.renote = await this.noteEntityService.pack(note.renoteId, this.user, { detail: true, }); } @@ -51,3 +58,22 @@ export default class extends Channel { this.subscriber.off('notesStream', this.onNote); } } + +@Injectable() +export class HashtagChannelService { + public readonly shouldShare = HashtagChannel.shouldShare; + public readonly requireCredential = HashtagChannel.requireCredential; + + constructor( + private noteEntityService: NoteEntityService, + ) { + } + + public create(id: string, connection: Channel['connection']): HashtagChannel { + return new HashtagChannel( + this.noteEntityService, + id, + connection, + ); + } +} diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index 075a242ef..16e0cebc7 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -1,16 +1,23 @@ -import Channel from '../channel.js'; -import { Notes } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import type { NotesRepository } from '@/models/index.js'; import { checkWordMute } from '@/misc/check-word-mute.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; -import { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/schema.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import Channel from '../channel.js'; -export default class extends Channel { +class HomeTimelineChannel extends Channel { public readonly chName = 'homeTimeline'; public static shouldShare = true; public static requireCredential = true; - constructor(id: string, connection: Channel['connection']) { + constructor( + private noteEntityService: NoteEntityService, + + id: string, + connection: Channel['connection'], + ) { super(id, connection); this.onNote = this.onNote.bind(this); } @@ -32,7 +39,7 @@ export default class extends Channel { if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return; if (['followers', 'specified'].includes(note.visibility)) { - note = await Notes.pack(note.id, this.user!, { + note = await this.noteEntityService.pack(note.id, this.user!, { detail: true, }); @@ -42,13 +49,13 @@ export default class extends Channel { } else { // リプライなら再pack if (note.replyId != null) { - note.reply = await Notes.pack(note.replyId, this.user!, { + note.reply = await this.noteEntityService.pack(note.replyId, this.user!, { detail: true, }); } // Renoteなら再pack if (note.renoteId != null) { - note.renote = await Notes.pack(note.renoteId, this.user!, { + note.renote = await this.noteEntityService.pack(note.renoteId, this.user!, { detail: true, }); } @@ -83,3 +90,22 @@ export default class extends Channel { this.subscriber.off('notesStream', this.onNote); } } + +@Injectable() +export class HomeTimelineChannelService { + public readonly shouldShare = HomeTimelineChannel.shouldShare; + public readonly requireCredential = HomeTimelineChannel.requireCredential; + + constructor( + private noteEntityService: NoteEntityService, + ) { + } + + public create(id: string, connection: Channel['connection']): HomeTimelineChannel { + return new HomeTimelineChannel( + this.noteEntityService, + id, + connection, + ); + } +} diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index f5dedf77c..f1ce82258 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -1,23 +1,32 @@ -import Channel from '../channel.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { Notes } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import type { NotesRepository } from '@/models/index.js'; import { checkWordMute } from '@/misc/check-word-mute.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; -import { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/schema.js'; +import { DI } from '@/di-symbols.js'; +import { MetaService } from '@/core/MetaService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import Channel from '../channel.js'; -export default class extends Channel { +class HybridTimelineChannel extends Channel { public readonly chName = 'hybridTimeline'; public static shouldShare = true; public static requireCredential = true; - constructor(id: string, connection: Channel['connection']) { + constructor( + private metaService: MetaService, + private noteEntityService: NoteEntityService, + + id: string, + connection: Channel['connection'], + ) { super(id, connection); this.onNote = this.onNote.bind(this); } - public async init(params: any) { - const meta = await fetchMeta(); + public async init(params: any): Promise { + const meta = await this.metaService.fetch(); if (meta.disableLocalTimeline && !this.user!.isAdmin && !this.user!.isModerator) return; // Subscribe events @@ -37,7 +46,7 @@ export default class extends Channel { )) return; if (['followers', 'specified'].includes(note.visibility)) { - note = await Notes.pack(note.id, this.user!, { + note = await this.noteEntityService.pack(note.id, this.user!, { detail: true, }); @@ -47,13 +56,13 @@ export default class extends Channel { } else { // リプライなら再pack if (note.replyId != null) { - note.reply = await Notes.pack(note.replyId, this.user!, { + note.reply = await this.noteEntityService.pack(note.replyId, this.user!, { detail: true, }); } // Renoteなら再pack if (note.renoteId != null) { - note.renote = await Notes.pack(note.renoteId, this.user!, { + note.renote = await this.noteEntityService.pack(note.renoteId, this.user!, { detail: true, }); } @@ -86,8 +95,29 @@ export default class extends Channel { this.send('note', note); } - public dispose() { + public dispose(): void { // Unsubscribe events this.subscriber.off('notesStream', this.onNote); } } + +@Injectable() +export class HybridTimelineChannelService { + public readonly shouldShare = HybridTimelineChannel.shouldShare; + public readonly requireCredential = HybridTimelineChannel.requireCredential; + + constructor( + private metaService: MetaService, + private noteEntityService: NoteEntityService, + ) { + } + + public create(id: string, connection: Channel['connection']): HybridTimelineChannel { + return new HybridTimelineChannel( + this.metaService, + this.noteEntityService, + id, + connection, + ); + } +} diff --git a/packages/backend/src/server/api/stream/channels/index.ts b/packages/backend/src/server/api/stream/channels/index.ts deleted file mode 100644 index d422edde8..000000000 --- a/packages/backend/src/server/api/stream/channels/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -import main from './main.js'; -import homeTimeline from './home-timeline.js'; -import localTimeline from './local-timeline.js'; -import hybridTimeline from './hybrid-timeline.js'; -import globalTimeline from './global-timeline.js'; -import serverStats from './server-stats.js'; -import queueStats from './queue-stats.js'; -import userList from './user-list.js'; -import antenna from './antenna.js'; -import messaging from './messaging.js'; -import messagingIndex from './messaging-index.js'; -import drive from './drive.js'; -import hashtag from './hashtag.js'; -import channel from './channel.js'; -import admin from './admin.js'; - -export default { - main, - homeTimeline, - localTimeline, - hybridTimeline, - globalTimeline, - serverStats, - queueStats, - userList, - antenna, - messaging, - messagingIndex, - drive, - hashtag, - channel, - admin, -}; diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index f01f47723..5a5a43f84 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -1,22 +1,30 @@ -import Channel from '../channel.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { Notes } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import type { NotesRepository } from '@/models/index.js'; import { checkWordMute } from '@/misc/check-word-mute.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/schema.js'; +import { MetaService } from '@/core/MetaService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import Channel from '../channel.js'; -export default class extends Channel { +class LocalTimelineChannel extends Channel { public readonly chName = 'localTimeline'; public static shouldShare = true; public static requireCredential = false; - constructor(id: string, connection: Channel['connection']) { + constructor( + private metaService: MetaService, + private noteEntityService: NoteEntityService, + + id: string, + connection: Channel['connection'], + ) { super(id, connection); this.onNote = this.onNote.bind(this); } public async init(params: any) { - const meta = await fetchMeta(); + const meta = await this.metaService.fetch(); if (meta.disableLocalTimeline) { if (this.user == null || (!this.user.isAdmin && !this.user.isModerator)) return; } @@ -32,13 +40,13 @@ export default class extends Channel { // リプライなら再pack if (note.replyId != null) { - note.reply = await Notes.pack(note.replyId, this.user, { + note.reply = await this.noteEntityService.pack(note.replyId, this.user, { detail: true, }); } // Renoteなら再pack if (note.renoteId != null) { - note.renote = await Notes.pack(note.renoteId, this.user, { + note.renote = await this.noteEntityService.pack(note.renoteId, this.user, { detail: true, }); } @@ -72,3 +80,24 @@ export default class extends Channel { this.subscriber.off('notesStream', this.onNote); } } + +@Injectable() +export class LocalTimelineChannelService { + public readonly shouldShare = LocalTimelineChannel.shouldShare; + public readonly requireCredential = LocalTimelineChannel.requireCredential; + + constructor( + private metaService: MetaService, + private noteEntityService: NoteEntityService, + ) { + } + + public create(id: string, connection: Channel['connection']): LocalTimelineChannel { + return new LocalTimelineChannel( + this.metaService, + this.noteEntityService, + id, + connection, + ); + } +} diff --git a/packages/backend/src/server/api/stream/channels/main.ts b/packages/backend/src/server/api/stream/channels/main.ts index 9cfea0bfc..12908e07b 100644 --- a/packages/backend/src/server/api/stream/channels/main.ts +++ b/packages/backend/src/server/api/stream/channels/main.ts @@ -1,12 +1,23 @@ -import Channel from '../channel.js'; -import { Notes } from '@/models/index.js'; +import { Inject, Injectable } from '@nestjs/common'; +import type { NotesRepository } from '@/models/index.js'; import { isInstanceMuted, isUserFromMutedInstance } from '@/misc/is-instance-muted.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import Channel from '../channel.js'; -export default class extends Channel { +class MainChannel extends Channel { public readonly chName = 'main'; public static shouldShare = true; public static requireCredential = true; + constructor( + private noteEntityService: NoteEntityService, + + id: string, + connection: Channel['connection'], + ) { + super(id, connection); + } + public async init(params: any) { // Subscribe main stream channel this.subscriber.on(`mainStream:${this.user!.id}`, async data => { @@ -17,7 +28,7 @@ export default class extends Channel { if (data.body.userId && this.muting.has(data.body.userId)) return; if (data.body.note && data.body.note.isHidden) { - const note = await Notes.pack(data.body.note.id, this.user, { + const note = await this.noteEntityService.pack(data.body.note.id, this.user, { detail: true, }); this.connection.cacheNote(note); @@ -30,7 +41,7 @@ export default class extends Channel { if (this.muting.has(data.body.userId)) return; if (data.body.isHidden) { - const note = await Notes.pack(data.body.id, this.user, { + const note = await this.noteEntityService.pack(data.body.id, this.user, { detail: true, }); this.connection.cacheNote(note); @@ -44,3 +55,22 @@ export default class extends Channel { }); } } + +@Injectable() +export class MainChannelService { + public readonly shouldShare = MainChannel.shouldShare; + public readonly requireCredential = MainChannel.requireCredential; + + constructor( + private noteEntityService: NoteEntityService, + ) { + } + + public create(id: string, connection: Channel['connection']): MainChannel { + return new MainChannel( + this.noteEntityService, + id, + connection, + ); + } +} diff --git a/packages/backend/src/server/api/stream/channels/messaging-index.ts b/packages/backend/src/server/api/stream/channels/messaging-index.ts index b930785d2..bebc07f4a 100644 --- a/packages/backend/src/server/api/stream/channels/messaging-index.ts +++ b/packages/backend/src/server/api/stream/channels/messaging-index.ts @@ -1,6 +1,7 @@ +import { Inject, Injectable } from '@nestjs/common'; import Channel from '../channel.js'; -export default class extends Channel { +class MessagingIndexChannel extends Channel { public readonly chName = 'messagingIndex'; public static shouldShare = true; public static requireCredential = true; @@ -12,3 +13,20 @@ export default class extends Channel { }); } } + +@Injectable() +export class MessagingIndexChannelService { + public readonly shouldShare = MessagingIndexChannel.shouldShare; + public readonly requireCredential = MessagingIndexChannel.requireCredential; + + constructor( + ) { + } + + public create(id: string, connection: Channel['connection']): MessagingIndexChannel { + return new MessagingIndexChannel( + id, + connection, + ); + } +} diff --git a/packages/backend/src/server/api/stream/channels/messaging.ts b/packages/backend/src/server/api/stream/channels/messaging.ts index 877d44c38..5bf20c410 100644 --- a/packages/backend/src/server/api/stream/channels/messaging.ts +++ b/packages/backend/src/server/api/stream/channels/messaging.ts @@ -1,11 +1,14 @@ -import { readUserMessagingMessage, readGroupMessagingMessage, deliverReadActivity } from '../../common/read-messaging-message.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UserGroupJoiningsRepository, UsersRepository, MessagingMessagesRepository } from '@/models/index.js'; +import type { User, ILocalUser, IRemoteUser } from '@/models/entities/User.js'; +import type { UserGroup } from '@/models/entities/UserGroup.js'; +import { MessagingService } from '@/core/MessagingService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DI } from '@/di-symbols.js'; import Channel from '../channel.js'; -import { UserGroupJoinings, Users, MessagingMessages } from '@/models/index.js'; -import { User, ILocalUser, IRemoteUser } from '@/models/entities/user.js'; -import { UserGroup } from '@/models/entities/user-group.js'; -import { StreamMessages } from '../types.js'; +import type { StreamMessages } from '../types.js'; -export default class extends Channel { +class MessagingChannel extends Channel { public readonly chName = 'messaging'; public static shouldShare = false; public static requireCredential = true; @@ -17,7 +20,16 @@ export default class extends Channel { private typers: Record = {}; private emitTypersIntervalId: ReturnType; - constructor(id: string, connection: Channel['connection']) { + constructor( + private usersRepository: UsersRepository, + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + private messagingMessagesRepository: MessagingMessagesRepository, + private userEntityService: UserEntityService, + private messagingService: MessagingService, + + id: string, + connection: Channel['connection'], + ) { super(id, connection); this.onEvent = this.onEvent.bind(this); this.onMessage = this.onMessage.bind(this); @@ -26,12 +38,12 @@ export default class extends Channel { public async init(params: any) { this.otherpartyId = params.otherparty; - this.otherparty = this.otherpartyId ? await Users.findOneByOrFail({ id: this.otherpartyId }) : null; + this.otherparty = this.otherpartyId ? await this.usersRepository.findOneByOrFail({ id: this.otherpartyId }) : null; this.groupId = params.group; // Check joining if (this.groupId) { - const joining = await UserGroupJoinings.findOneBy({ + const joining = await this.userGroupJoiningsRepository.findOneBy({ userId: this.user!.id, userGroupId: this.groupId, }); @@ -68,16 +80,16 @@ export default class extends Channel { switch (type) { case 'read': if (this.otherpartyId) { - readUserMessagingMessage(this.user!.id, this.otherpartyId, [body.id]); + this.messagingService.readUserMessagingMessage(this.user!.id, this.otherpartyId, [body.id]); // リモートユーザーからのメッセージだったら既読配信 - if (Users.isLocalUser(this.user!) && Users.isRemoteUser(this.otherparty!)) { - MessagingMessages.findOneBy({ id: body.id }).then(message => { - if (message) deliverReadActivity(this.user as ILocalUser, this.otherparty as IRemoteUser, message); + if (this.userEntityService.isLocalUser(this.user!) && this.userEntityService.isRemoteUser(this.otherparty!)) { + this.messagingMessagesRepository.findOneBy({ id: body.id }).then(message => { + if (message) this.messagingService.deliverReadActivity(this.user as ILocalUser, this.otherparty as IRemoteUser, message); }); } } else if (this.groupId) { - readGroupMessagingMessage(this.user!.id, this.groupId, [body.id]); + this.messagingService.readGroupMessagingMessage(this.user!.id, this.groupId, [body.id]); } break; } @@ -91,7 +103,7 @@ export default class extends Channel { if (now.getTime() - date.getTime() > 5000) delete this.typers[userId]; } - const users = await Users.packMany(Object.keys(this.typers), null, { detail: false }); + const users = await this.userEntityService.packMany(Object.keys(this.typers), null, { detail: false }); this.send({ type: 'typers', @@ -105,3 +117,36 @@ export default class extends Channel { clearInterval(this.emitTypersIntervalId); } } + +@Injectable() +export class MessagingChannelService { + public readonly shouldShare = MessagingChannel.shouldShare; + public readonly requireCredential = MessagingChannel.requireCredential; + + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, + + private userEntityService: UserEntityService, + private messagingService: MessagingService, + ) { + } + + public create(id: string, connection: Channel['connection']): MessagingChannel { + return new MessagingChannel( + this.usersRepository, + this.userGroupJoiningsRepository, + this.messagingMessagesRepository, + this.userEntityService, + this.messagingService, + id, + connection, + ); + } +} diff --git a/packages/backend/src/server/api/stream/channels/queue-stats.ts b/packages/backend/src/server/api/stream/channels/queue-stats.ts index b67600474..1802c6723 100644 --- a/packages/backend/src/server/api/stream/channels/queue-stats.ts +++ b/packages/backend/src/server/api/stream/channels/queue-stats.ts @@ -1,9 +1,10 @@ import Xev from 'xev'; +import { Inject, Injectable } from '@nestjs/common'; import Channel from '../channel.js'; const ev = new Xev(); -export default class extends Channel { +class QueueStatsChannel extends Channel { public readonly chName = 'queueStats'; public static shouldShare = true; public static requireCredential = false; @@ -40,3 +41,20 @@ export default class extends Channel { ev.removeListener('queueStats', this.onStats); } } + +@Injectable() +export class QueueStatsChannelService { + public readonly shouldShare = QueueStatsChannel.shouldShare; + public readonly requireCredential = QueueStatsChannel.requireCredential; + + constructor( + ) { + } + + public create(id: string, connection: Channel['connection']): QueueStatsChannel { + return new QueueStatsChannel( + id, + connection, + ); + } +} diff --git a/packages/backend/src/server/api/stream/channels/server-stats.ts b/packages/backend/src/server/api/stream/channels/server-stats.ts index db75a6fa3..e2b00de25 100644 --- a/packages/backend/src/server/api/stream/channels/server-stats.ts +++ b/packages/backend/src/server/api/stream/channels/server-stats.ts @@ -1,9 +1,10 @@ import Xev from 'xev'; +import { Inject, Injectable } from '@nestjs/common'; import Channel from '../channel.js'; const ev = new Xev(); -export default class extends Channel { +class ServerStatsChannel extends Channel { public readonly chName = 'serverStats'; public static shouldShare = true; public static requireCredential = false; @@ -40,3 +41,20 @@ export default class extends Channel { ev.removeListener('serverStats', this.onStats); } } + +@Injectable() +export class ServerStatsChannelService { + public readonly shouldShare = ServerStatsChannel.shouldShare; + public readonly requireCredential = ServerStatsChannel.requireCredential; + + constructor( + ) { + } + + public create(id: string, connection: Channel['connection']): ServerStatsChannel { + return new ServerStatsChannel( + id, + connection, + ); + } +} diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index 97ad2983c..a45c7d946 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -1,10 +1,14 @@ -import Channel from '../channel.js'; -import { Notes, UserListJoinings, UserLists } from '@/models/index.js'; -import { User } from '@/models/entities/user.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { UserListJoiningsRepository, UserListsRepository } from '@/models/index.js'; +import type { NotesRepository } from '@/models/index.js'; +import type { User } from '@/models/entities/User.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/schema.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { DI } from '@/di-symbols.js'; +import Channel from '../channel.js'; -export default class extends Channel { +class UserListChannel extends Channel { public readonly chName = 'userList'; public static shouldShare = false; public static requireCredential = false; @@ -12,7 +16,14 @@ export default class extends Channel { public listUsers: User['id'][] = []; private listUsersClock: NodeJS.Timer; - constructor(id: string, connection: Channel['connection']) { + constructor( + private userListsRepository: UserListsRepository, + private userListJoiningsRepository: UserListJoiningsRepository, + private noteEntityService: NoteEntityService, + + id: string, + connection: Channel['connection'], + ) { super(id, connection); this.updateListUsers = this.updateListUsers.bind(this); this.onNote = this.onNote.bind(this); @@ -22,7 +33,7 @@ export default class extends Channel { this.listId = params.listId as string; // Check existence and owner - const list = await UserLists.findOneBy({ + const list = await this.userListsRepository.findOneBy({ id: this.listId, userId: this.user!.id, }); @@ -38,7 +49,7 @@ export default class extends Channel { } private async updateListUsers() { - const users = await UserListJoinings.find({ + const users = await this.userListJoiningsRepository.find({ where: { userListId: this.listId, }, @@ -52,7 +63,7 @@ export default class extends Channel { if (!this.listUsers.includes(note.userId)) return; if (['followers', 'specified'].includes(note.visibility)) { - note = await Notes.pack(note.id, this.user, { + note = await this.noteEntityService.pack(note.id, this.user, { detail: true, }); @@ -62,13 +73,13 @@ export default class extends Channel { } else { // リプライなら再pack if (note.replyId != null) { - note.reply = await Notes.pack(note.replyId, this.user, { + note.reply = await this.noteEntityService.pack(note.replyId, this.user, { detail: true, }); } // Renoteなら再pack if (note.renoteId != null) { - note.renote = await Notes.pack(note.renoteId, this.user, { + note.renote = await this.noteEntityService.pack(note.renoteId, this.user, { detail: true, }); } @@ -90,3 +101,30 @@ export default class extends Channel { clearInterval(this.listUsersClock); } } + +@Injectable() +export class UserListChannelService { + public readonly shouldShare = UserListChannel.shouldShare; + public readonly requireCredential = UserListChannel.requireCredential; + + constructor( + @Inject(DI.userListsRepository) + private userListsRepository: UserListsRepository, + + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, + + private noteEntityService: NoteEntityService, + ) { + } + + public create(id: string, connection: Channel['connection']): UserListChannel { + return new UserListChannel( + this.userListsRepository, + this.userListJoiningsRepository, + this.noteEntityService, + id, + connection, + ); + } +} diff --git a/packages/backend/src/server/api/stream/index.ts b/packages/backend/src/server/api/stream/index.ts index 2d23145f1..0c5066b73 100644 --- a/packages/backend/src/server/api/stream/index.ts +++ b/packages/backend/src/server/api/stream/index.ts @@ -1,18 +1,18 @@ -import { EventEmitter } from 'events'; -import * as websocket from 'websocket'; -import readNote from '@/services/note/read.js'; -import { User } from '@/models/entities/user.js'; -import { Channel as ChannelModel } from '@/models/entities/channel.js'; -import { Users, Followings, Mutings, UserProfiles, ChannelFollowings, Blockings } from '@/models/index.js'; -import { AccessToken } from '@/models/entities/access-token.js'; -import { UserProfile } from '@/models/entities/user-profile.js'; -import { publishChannelStream, publishGroupMessagingStream, publishMessagingStream } from '@/services/stream.js'; -import { UserGroup } from '@/models/entities/user-group.js'; -import { Packed } from '@/misc/schema.js'; -import { readNotification } from '../common/read-notification.js'; -import channels from './channels/index.js'; -import Channel from './channel.js'; -import { StreamEventEmitter, StreamMessages } from './types.js'; +import type { User } from '@/models/entities/User.js'; +import type { Channel as ChannelModel } from '@/models/entities/Channel.js'; +import type { FollowingsRepository, MutingsRepository, UserProfilesRepository, ChannelFollowingsRepository, BlockingsRepository } from '@/models/index.js'; +import type { AccessToken } from '@/models/entities/AccessToken.js'; +import type { UserProfile } from '@/models/entities/UserProfile.js'; +import type { UserGroup } from '@/models/entities/UserGroup.js'; +import type { Packed } from '@/misc/schema.js'; +import type { GlobalEventService } from '@/core/GlobalEventService.js'; +import type { NoteReadService } from '@/core/NoteReadService.js'; +import type { NotificationService } from '@/core/NotificationService.js'; +import type { ChannelsService } from './ChannelsService.js'; +import type * as websocket from 'websocket'; +import type { EventEmitter } from 'events'; +import type Channel from './channel.js'; +import type { StreamEventEmitter, StreamMessages } from './types.js'; /** * Main stream connection @@ -32,6 +32,16 @@ export default class Connection { private cachedNotes: Packed<'Note'>[] = []; constructor( + private followingsRepository: FollowingsRepository, + private mutingsRepository: MutingsRepository, + private blockingsRepository: BlockingsRepository, + private channelFollowingsRepository: ChannelFollowingsRepository, + private userProfilesRepository: UserProfilesRepository, + private channelsService: ChannelsService, + private globalEventService: GlobalEventService, + private noteReadService: NoteReadService, + private notificationService: NotificationService, + wsConnection: websocket.connection, subscriber: EventEmitter, user: User | null | undefined, @@ -173,7 +183,7 @@ export default class Connection { if (note == null) return; if (this.user && (note.userId !== this.user.id)) { - readNote(this.user.id, [note], { + this.noteReadService.read(this.user.id, [note], { following: this.following, followingChannels: this.followingChannels, }); @@ -182,7 +192,7 @@ export default class Connection { private onReadNotification(payload: any) { if (!payload.id) return; - readNotification(this.user!.id, [payload.id]); + this.notificationService.readNotification(this.user!.id, [payload.id]); } /** @@ -253,16 +263,18 @@ export default class Connection { * チャンネルに接続 */ public connectChannel(id: string, params: any, channel: string, pong = false) { - if ((channels as any)[channel].requireCredential && this.user == null) { + const channelService = this.channelsService.getChannelService(channel); + + if (channelService.requireCredential && this.user == null) { return; } // 共有可能チャンネルに接続しようとしていて、かつそのチャンネルに既に接続していたら無意味なので無視 - if ((channels as any)[channel].shouldShare && this.channels.some(c => c.chName === channel)) { + if (channelService.shouldShare && this.channels.some(c => c.chName === channel)) { return; } - const ch: Channel = new (channels as any)[channel](id, this); + const ch: Channel = channelService.create(id, this); this.channels.push(ch); ch.init(params); @@ -299,22 +311,22 @@ export default class Connection { private typingOnChannel(channel: ChannelModel['id']) { if (this.user) { - publishChannelStream(channel, 'typing', this.user.id); + this.globalEventService.publishChannelStream(channel, 'typing', this.user.id); } } private typingOnMessaging(param: { partner?: User['id']; group?: UserGroup['id']; }) { if (this.user) { if (param.partner) { - publishMessagingStream(param.partner, this.user.id, 'typing', this.user.id); + this.globalEventService.publishMessagingStream(param.partner, this.user.id, 'typing', this.user.id); } else if (param.group) { - publishGroupMessagingStream(param.group, 'typing', this.user.id); + this.globalEventService.publishGroupMessagingStream(param.group, 'typing', this.user.id); } } } private async updateFollowing() { - const followings = await Followings.find({ + const followings = await this.followingsRepository.find({ where: { followerId: this.user!.id, }, @@ -325,7 +337,7 @@ export default class Connection { } private async updateMuting() { - const mutings = await Mutings.find({ + const mutings = await this.mutingsRepository.find({ where: { muterId: this.user!.id, }, @@ -336,7 +348,7 @@ export default class Connection { } private async updateBlocking() { // ここでいうBlockingは被Blockingの意 - const blockings = await Blockings.find({ + const blockings = await this.blockingsRepository.find({ where: { blockeeId: this.user!.id, }, @@ -347,7 +359,7 @@ export default class Connection { } private async updateFollowingChannels() { - const followings = await ChannelFollowings.find({ + const followings = await this.channelFollowingsRepository.find({ where: { followerId: this.user!.id, }, @@ -358,7 +370,7 @@ export default class Connection { } private async updateUserProfile() { - this.userProfile = await UserProfiles.findOneBy({ + this.userProfile = await this.userProfilesRepository.findOneBy({ userId: this.user!.id, }); } diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts index 3b0a75d79..000f9a25d 100644 --- a/packages/backend/src/server/api/stream/types.ts +++ b/packages/backend/src/server/api/stream/types.ts @@ -1,21 +1,20 @@ -import { EventEmitter } from 'events'; -import Emitter from 'strict-event-emitter-types'; -import { Channel } from '@/models/entities/channel.js'; -import { User } from '@/models/entities/user.js'; -import { UserProfile } from '@/models/entities/user-profile.js'; -import { Note } from '@/models/entities/note.js'; -import { Antenna } from '@/models/entities/antenna.js'; -import { DriveFile } from '@/models/entities/drive-file.js'; -import { DriveFolder } from '@/models/entities/drive-folder.js'; -import { Emoji } from '@/models/entities/emoji.js'; -import { UserList } from '@/models/entities/user-list.js'; -import { MessagingMessage } from '@/models/entities/messaging-message.js'; -import { UserGroup } from '@/models/entities/user-group.js'; -import { AbuseUserReport } from '@/models/entities/abuse-user-report.js'; -import { Signin } from '@/models/entities/signin.js'; -import { Page } from '@/models/entities/page.js'; -import { Packed } from '@/misc/schema.js'; -import { Webhook } from '@/models/entities/webhook'; +import type { Channel } from '@/models/entities/Channel.js'; +import type { User } from '@/models/entities/User.js'; +import type { UserProfile } from '@/models/entities/UserProfile.js'; +import type { Note } from '@/models/entities/Note.js'; +import type { Antenna } from '@/models/entities/Antenna.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import type { DriveFolder } from '@/models/entities/DriveFolder.js'; +import type { UserList } from '@/models/entities/UserList.js'; +import type { MessagingMessage } from '@/models/entities/MessagingMessage.js'; +import type { UserGroup } from '@/models/entities/UserGroup.js'; +import type { AbuseUserReport } from '@/models/entities/AbuseUserReport.js'; +import type { Signin } from '@/models/entities/Signin.js'; +import type { Page } from '@/models/entities/Page.js'; +import type { Packed } from '@/misc/schema.js'; +import type { Webhook } from '@/models/entities/Webhook.js'; +import type Emitter from 'strict-event-emitter-types'; +import type { EventEmitter } from 'events'; //#region Stream type-body definitions export interface InternalStreamTypes { diff --git a/packages/backend/src/server/api/streaming.ts b/packages/backend/src/server/api/streaming.ts deleted file mode 100644 index f8e42d27f..000000000 --- a/packages/backend/src/server/api/streaming.ts +++ /dev/null @@ -1,67 +0,0 @@ -import * as http from 'node:http'; -import * as websocket from 'websocket'; - -import MainStreamConnection from './stream/index.js'; -import { ParsedUrlQuery } from 'querystring'; -import authenticate from './authenticate.js'; -import { EventEmitter } from 'events'; -import { subsdcriber as redisClient } from '../../db/redis.js'; -import { Users } from '@/models/index.js'; - -export const initializeStreamingServer = (server: http.Server) => { - // Init websocket server - const ws = new websocket.server({ - httpServer: server, - }); - - ws.on('request', async (request) => { - const q = request.resourceURL.query as ParsedUrlQuery; - - // TODO: トークンが間違ってるなどしてauthenticateに失敗したら - // コネクション切断するなりエラーメッセージ返すなりする - // (現状はエラーがキャッチされておらずサーバーのログに流れて邪魔なので) - const [user, app] = await authenticate(q.i as string); - - if (user?.isSuspended) { - request.reject(400); - return; - } - - const connection = request.accept(); - - const ev = new EventEmitter(); - - async function onRedisMessage(_: string, data: string) { - const parsed = JSON.parse(data); - ev.emit(parsed.channel, parsed.message); - } - - redisClient.on('message', onRedisMessage); - - const main = new MainStreamConnection(connection, ev, user, app); - - const intervalId = user ? setInterval(() => { - Users.update(user.id, { - lastActiveDate: new Date(), - }); - }, 1000 * 60 * 5) : null; - if (user) { - Users.update(user.id, { - lastActiveDate: new Date(), - }); - } - - connection.once('close', () => { - ev.removeAllListeners(); - main.dispose(); - redisClient.off('message', onRedisMessage); - if (intervalId) clearInterval(intervalId); - }); - - connection.on('message', async (data) => { - if (data.type === 'utf8' && data.utf8Data === 'ping') { - connection.send('pong'); - } - }); - }); -}; diff --git a/packages/backend/src/server/file/assets/bad-egg.png b/packages/backend/src/server/assets/bad-egg.png similarity index 100% rename from packages/backend/src/server/file/assets/bad-egg.png rename to packages/backend/src/server/assets/bad-egg.png diff --git a/packages/backend/src/server/file/assets/cache-expired.png b/packages/backend/src/server/assets/cache-expired.png similarity index 100% rename from packages/backend/src/server/file/assets/cache-expired.png rename to packages/backend/src/server/assets/cache-expired.png diff --git a/packages/backend/src/server/file/assets/dummy.png b/packages/backend/src/server/assets/dummy.png similarity index 100% rename from packages/backend/src/server/file/assets/dummy.png rename to packages/backend/src/server/assets/dummy.png diff --git a/packages/backend/src/server/file/assets/not-an-image.png b/packages/backend/src/server/assets/not-an-image.png similarity index 100% rename from packages/backend/src/server/file/assets/not-an-image.png rename to packages/backend/src/server/assets/not-an-image.png diff --git a/packages/backend/src/server/file/assets/thumbnail-not-available.png b/packages/backend/src/server/assets/thumbnail-not-available.png similarity index 100% rename from packages/backend/src/server/file/assets/thumbnail-not-available.png rename to packages/backend/src/server/assets/thumbnail-not-available.png diff --git a/packages/backend/src/server/file/assets/tombstone.png b/packages/backend/src/server/assets/tombstone.png similarity index 100% rename from packages/backend/src/server/file/assets/tombstone.png rename to packages/backend/src/server/assets/tombstone.png diff --git a/packages/backend/src/server/file/index.ts b/packages/backend/src/server/file/index.ts deleted file mode 100644 index 07a493700..000000000 --- a/packages/backend/src/server/file/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * File Server - */ - -import * as fs from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; -import Koa from 'koa'; -import cors from '@koa/cors'; -import Router from '@koa/router'; -import sendDriveFile from './send-drive-file.js'; - -const _filename = fileURLToPath(import.meta.url); -const _dirname = dirname(_filename); - -// Init app -const app = new Koa(); -app.use(cors()); -app.use(async (ctx, next) => { - ctx.set('Content-Security-Policy', `default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'`); - await next(); -}); - -// Init router -const router = new Router(); - -router.get('/app-default.jpg', ctx => { - const file = fs.createReadStream(`${_dirname}/assets/dummy.png`); - ctx.body = file; - ctx.set('Content-Type', 'image/jpeg'); - ctx.set('Cache-Control', 'max-age=31536000, immutable'); -}); - -router.get('/:key', sendDriveFile); -router.get('/:key/(.*)', sendDriveFile); - -// Register router -app.use(router.routes()); - -export default app; diff --git a/packages/backend/src/server/file/send-drive-file.ts b/packages/backend/src/server/file/send-drive-file.ts deleted file mode 100644 index c34e04314..000000000 --- a/packages/backend/src/server/file/send-drive-file.ts +++ /dev/null @@ -1,126 +0,0 @@ -import * as fs from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; -import Koa from 'koa'; -import send from 'koa-send'; -import rename from 'rename'; -import { serverLogger } from '../index.js'; -import { contentDisposition } from '@/misc/content-disposition.js'; -import { DriveFiles } from '@/models/index.js'; -import { InternalStorage } from '@/services/drive/internal-storage.js'; -import { createTemp } from '@/misc/create-temp.js'; -import { downloadUrl } from '@/misc/download-url.js'; -import { detectType } from '@/misc/get-file-info.js'; -import { convertToWebp, convertToJpeg, convertToPng } from '@/services/drive/image-processor.js'; -import { GenerateVideoThumbnail } from '@/services/drive/generate-video-thumbnail.js'; -import { StatusError } from '@/misc/fetch.js'; -import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; - -const _filename = fileURLToPath(import.meta.url); -const _dirname = dirname(_filename); - -const assets = `${_dirname}/../../server/file/assets/`; - -const commonReadableHandlerGenerator = (ctx: Koa.Context) => (e: Error): void => { - serverLogger.error(e); - ctx.status = 500; - ctx.set('Cache-Control', 'max-age=300'); -}; - -// eslint-disable-next-line import/no-default-export -export default async function(ctx: Koa.Context) { - const key = ctx.params.key; - - // Fetch drive file - const file = await DriveFiles.createQueryBuilder('file') - .where('file.accessKey = :accessKey', { accessKey: key }) - .orWhere('file.thumbnailAccessKey = :thumbnailAccessKey', { thumbnailAccessKey: key }) - .orWhere('file.webpublicAccessKey = :webpublicAccessKey', { webpublicAccessKey: key }) - .getOne(); - - if (file == null) { - ctx.status = 404; - ctx.set('Cache-Control', 'max-age=86400'); - await send(ctx as any, '/dummy.png', { root: assets }); - return; - } - - const isThumbnail = file.thumbnailAccessKey === key; - const isWebpublic = file.webpublicAccessKey === key; - - if (!file.storedInternal) { - if (file.isLink && file.uri) { // 期限切れリモートファイル - const [path, cleanup] = await createTemp(); - - try { - await downloadUrl(file.uri, path); - - const { mime, ext } = await detectType(path); - - const convertFile = async () => { - if (isThumbnail) { - if (['image/jpeg', 'image/webp', 'image/png', 'image/svg+xml'].includes(mime)) { - return await convertToWebp(path, 498, 280); - } else if (mime.startsWith('video/')) { - return await GenerateVideoThumbnail(path); - } - } - - if (isWebpublic) { - if (['image/svg+xml'].includes(mime)) { - return await convertToPng(path, 2048, 2048); - } - } - - return { - data: fs.readFileSync(path), - ext, - type: mime, - }; - }; - - const image = await convertFile(); - ctx.body = image.data; - ctx.set('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream'); - ctx.set('Cache-Control', 'max-age=31536000, immutable'); - } catch (e) { - serverLogger.error(`${e}`); - - if (e instanceof StatusError && e.isClientError) { - ctx.status = e.statusCode; - ctx.set('Cache-Control', 'max-age=86400'); - } else { - ctx.status = 500; - ctx.set('Cache-Control', 'max-age=300'); - } - } finally { - cleanup(); - } - return; - } - - ctx.status = 204; - ctx.set('Cache-Control', 'max-age=86400'); - return; - } - - if (isThumbnail || isWebpublic) { - const { mime, ext } = await detectType(InternalStorage.resolvePath(key)); - const filename = rename(file.name, { - suffix: isThumbnail ? '-thumb' : '-web', - extname: ext ? `.${ext}` : undefined, - }).toString(); - - ctx.body = InternalStorage.read(key); - ctx.set('Content-Type', FILE_TYPE_BROWSERSAFE.includes(mime) ? mime : 'application/octet-stream'); - ctx.set('Cache-Control', 'max-age=31536000, immutable'); - ctx.set('Content-Disposition', contentDisposition('inline', filename)); - } else { - const readable = InternalStorage.read(file.accessKey!); - readable.on('error', commonReadableHandlerGenerator(ctx)); - ctx.body = readable; - ctx.set('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.type) ? file.type : 'application/octet-stream'); - ctx.set('Cache-Control', 'max-age=31536000, immutable'); - ctx.set('Content-Disposition', contentDisposition('inline', file.name)); - } -} diff --git a/packages/backend/src/server/index.ts b/packages/backend/src/server/index.ts deleted file mode 100644 index f31de2b7f..000000000 --- a/packages/backend/src/server/index.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * Core Server - */ - -import cluster from 'node:cluster'; -import * as fs from 'node:fs'; -import * as http from 'node:http'; -import Koa from 'koa'; -import Router from '@koa/router'; -import mount from 'koa-mount'; -import koaLogger from 'koa-logger'; -import * as slow from 'koa-slow'; - -import { IsNull } from 'typeorm'; -import config from '@/config/index.js'; -import Logger from '@/services/logger.js'; -import { UserProfiles, Users } from '@/models/index.js'; -import { genIdenticon } from '@/misc/gen-identicon.js'; -import { createTemp } from '@/misc/create-temp.js'; -import { publishMainStream } from '@/services/stream.js'; -import * as Acct from '@/misc/acct.js'; -import { envOption } from '../env.js'; -import activityPub from './activitypub.js'; -import nodeinfo from './nodeinfo.js'; -import wellKnown from './well-known.js'; -import apiServer from './api/index.js'; -import fileServer from './file/index.js'; -import proxyServer from './proxy/index.js'; -import webServer from './web/index.js'; -import { initializeStreamingServer } from './api/streaming.js'; - -export const serverLogger = new Logger('server', 'gray', false); - -// Init app -const app = new Koa(); -app.proxy = true; - -if (!['production', 'test'].includes(process.env.NODE_ENV || '')) { - // Logger - app.use(koaLogger(str => { - serverLogger.info(str); - })); - - // Delay - if (envOption.slow) { - app.use(slow({ - delay: 3000, - })); - } -} - -// HSTS -// 6months (15552000sec) -if (config.url.startsWith('https') && !config.disableHsts) { - app.use(async (ctx, next) => { - ctx.set('strict-transport-security', 'max-age=15552000; preload'); - await next(); - }); -} - -app.use(mount('/api', apiServer)); -app.use(mount('/files', fileServer)); -app.use(mount('/proxy', proxyServer)); - -// Init router -const router = new Router(); - -// Routing -router.use(activityPub.routes()); -router.use(nodeinfo.routes()); -router.use(wellKnown.routes()); - -router.get('/avatar/@:acct', async ctx => { - const { username, host } = Acct.parse(ctx.params.acct); - const user = await Users.findOne({ - where: { - usernameLower: username.toLowerCase(), - host: (host == null) || (host === config.host) ? IsNull() : host, - isSuspended: false, - }, - relations: ['avatar'], - }); - - if (user) { - ctx.redirect(Users.getAvatarUrlSync(user)); - } else { - ctx.redirect('/static-assets/user-unknown.png'); - } -}); - -router.get('/identicon/:x', async ctx => { - const [temp, cleanup] = await createTemp(); - await genIdenticon(ctx.params.x, fs.createWriteStream(temp)); - ctx.set('Content-Type', 'image/png'); - ctx.body = fs.createReadStream(temp).on('close', () => cleanup()); -}); - -router.get('/verify-email/:code', async ctx => { - const profile = await UserProfiles.findOneBy({ - emailVerifyCode: ctx.params.code, - }); - - if (profile != null) { - ctx.body = 'Verify succeeded!'; - ctx.status = 200; - - await UserProfiles.update({ userId: profile.userId }, { - emailVerified: true, - emailVerifyCode: null, - }); - - publishMainStream(profile.userId, 'meUpdated', await Users.pack(profile.userId, { id: profile.userId }, { - detail: true, - includeSecrets: true, - })); - } else { - ctx.status = 404; - } -}); - -// Register router -app.use(router.routes()); - -app.use(mount(webServer)); - -function createServer() { - return http.createServer(app.callback()); -} - -// For testing -export const startServer = () => { - const server = createServer(); - - initializeStreamingServer(server); - - server.listen(config.port); - - return server; -}; - -export default () => new Promise(resolve => { - const server = createServer(); - - initializeStreamingServer(server); - - server.on('error', e => { - switch ((e as any).code) { - case 'EACCES': - serverLogger.error(`You do not have permission to listen on port ${config.port}.`); - break; - case 'EADDRINUSE': - serverLogger.error(`Port ${config.port} is already in use by another process.`); - break; - default: - serverLogger.error(e); - break; - } - - if (cluster.isWorker) { - process.send!('listenFailed'); - } else { - // disableClustering - process.exit(1); - } - }); - - server.listen(config.port, resolve); -}); diff --git a/packages/backend/src/server/nodeinfo.ts b/packages/backend/src/server/nodeinfo.ts deleted file mode 100644 index f139d203d..000000000 --- a/packages/backend/src/server/nodeinfo.ts +++ /dev/null @@ -1,104 +0,0 @@ -import Router from '@koa/router'; -import config from '@/config/index.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { Users, Notes } from '@/models/index.js'; -import { IsNull, MoreThan } from 'typeorm'; -import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; -import { Cache } from '@/misc/cache.js'; - -const router = new Router(); - -const nodeinfo2_1path = '/nodeinfo/2.1'; -const nodeinfo2_0path = '/nodeinfo/2.0'; - -export const links = [/* (awaiting release) { - rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1', - href: config.url + nodeinfo2_1path -}, */{ - rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0', - href: config.url + nodeinfo2_0path, -}]; - -const nodeinfo2 = async () => { - const now = Date.now(); - const [ - meta, - total, - activeHalfyear, - activeMonth, - localPosts, - ] = await Promise.all([ - fetchMeta(true), - Users.count({ where: { host: IsNull() } }), - Users.count({ where: { host: IsNull(), lastActiveDate: MoreThan(new Date(now - 15552000000)) } }), - Users.count({ where: { host: IsNull(), lastActiveDate: MoreThan(new Date(now - 2592000000)) } }), - Notes.count({ where: { userHost: IsNull() } }), - ]); - - const proxyAccount = meta.proxyAccountId ? await Users.pack(meta.proxyAccountId).catch(() => null) : null; - - return { - software: { - name: 'misskey', - version: config.version, - repository: meta.repositoryUrl, - }, - protocols: ['activitypub'], - services: { - inbound: [] as string[], - outbound: ['atom1.0', 'rss2.0'], - }, - openRegistrations: !meta.disableRegistration, - usage: { - users: { total, activeHalfyear, activeMonth }, - localPosts, - localComments: 0, - }, - metadata: { - nodeName: meta.name, - nodeDescription: meta.description, - maintainer: { - name: meta.maintainerName, - email: meta.maintainerEmail, - }, - langs: meta.langs, - tosUrl: meta.ToSUrl, - repositoryUrl: meta.repositoryUrl, - feedbackUrl: meta.feedbackUrl, - disableRegistration: meta.disableRegistration, - disableLocalTimeline: meta.disableLocalTimeline, - disableGlobalTimeline: meta.disableGlobalTimeline, - emailRequiredForSignup: meta.emailRequiredForSignup, - enableHcaptcha: meta.enableHcaptcha, - enableRecaptcha: meta.enableRecaptcha, - maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, - enableTwitterIntegration: meta.enableTwitterIntegration, - enableGithubIntegration: meta.enableGithubIntegration, - enableDiscordIntegration: meta.enableDiscordIntegration, - enableEmail: meta.enableEmail, - enableServiceWorker: meta.enableServiceWorker, - proxyAccountName: proxyAccount ? proxyAccount.username : null, - themeColor: meta.themeColor || '#86b300', - }, - }; -}; - -const cache = new Cache>>(1000 * 60 * 10); - -router.get(nodeinfo2_1path, async ctx => { - const base = await cache.fetch(null, () => nodeinfo2()); - - ctx.body = { version: '2.1', ...base }; - ctx.set('Cache-Control', 'public, max-age=600'); -}); - -router.get(nodeinfo2_0path, async ctx => { - const base = await cache.fetch(null, () => nodeinfo2()); - - delete base.software.repository; - - ctx.body = { version: '2.0', ...base }; - ctx.set('Cache-Control', 'public, max-age=600'); -}); - -export default router; diff --git a/packages/backend/src/server/proxy/index.ts b/packages/backend/src/server/proxy/index.ts deleted file mode 100644 index 506ba10ef..000000000 --- a/packages/backend/src/server/proxy/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Media Proxy - */ - -import Koa from 'koa'; -import cors from '@koa/cors'; -import Router from '@koa/router'; -import { proxyMedia } from './proxy-media.js'; - -// Init app -const app = new Koa(); -app.use(cors()); -app.use(async (ctx, next) => { - ctx.set('Content-Security-Policy', `default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'`); - await next(); -}); - -// Init router -const router = new Router(); - -router.get('/:url*', proxyMedia); - -// Register router -app.use(router.routes()); - -export default app; diff --git a/packages/backend/src/server/proxy/proxy-media.ts b/packages/backend/src/server/proxy/proxy-media.ts deleted file mode 100644 index ca036e8fd..000000000 --- a/packages/backend/src/server/proxy/proxy-media.ts +++ /dev/null @@ -1,98 +0,0 @@ -import * as fs from 'node:fs'; -import Koa from 'koa'; -import sharp from 'sharp'; -import { IImage, convertToWebp } from '@/services/drive/image-processor.js'; -import { createTemp } from '@/misc/create-temp.js'; -import { downloadUrl } from '@/misc/download-url.js'; -import { detectType } from '@/misc/get-file-info.js'; -import { StatusError } from '@/misc/fetch.js'; -import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; -import { serverLogger } from '../index.js'; -import { isMimeImage } from '@/misc/is-mime-image.js'; - -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export async function proxyMedia(ctx: Koa.Context) { - const url = 'url' in ctx.query ? ctx.query.url : 'https://' + ctx.params.url; - - if (typeof url !== 'string') { - ctx.status = 400; - return; - } - - // Create temp file - const [path, cleanup] = await createTemp(); - - try { - await downloadUrl(url, path); - - const { mime, ext } = await detectType(path); - const isConvertibleImage = isMimeImage(mime, 'sharp-convertible-image'); - - let image: IImage; - - if ('static' in ctx.query && isConvertibleImage) { - image = await convertToWebp(path, 498, 280); - } else if ('preview' in ctx.query && isConvertibleImage) { - image = await convertToWebp(path, 200, 200); - } else if ('badge' in ctx.query) { - if (!isConvertibleImage) { - // 画像でないなら404でお茶を濁す - throw new StatusError('Unexpected mime', 404); - } - - const mask = sharp(path) - .resize(96, 96, { - fit: 'inside', - withoutEnlargement: false, - }) - .greyscale() - .normalise() - .linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast - .flatten({ background: '#000' }) - .toColorspace('b-w'); - - const stats = await mask.clone().stats(); - - if (stats.entropy < 0.1) { - // エントロピーがあまりない場合は404にする - throw new StatusError('Skip to provide badge', 404); - } - - const data = sharp({ - create: { width: 96, height: 96, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, - }) - .pipelineColorspace('b-w') - .boolean(await mask.png().toBuffer(), 'eor'); - - image = { - data: await data.png().toBuffer(), - ext: 'png', - type: 'image/png', - }; - } else if (mime === 'image/svg+xml') { - image = await convertToWebp(path, 2048, 2048, 1); - } else if (!mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(mime)) { - throw new StatusError('Rejected type', 403, 'Rejected type'); - } else { - image = { - data: fs.readFileSync(path), - ext, - type: mime, - }; - } - - ctx.set('Content-Type', image.type); - ctx.set('Cache-Control', 'max-age=31536000, immutable'); - ctx.body = image.data; - } catch (e) { - serverLogger.error(`${e}`); - - if (e instanceof StatusError && (e.statusCode === 302 || e.isClientError)) { - ctx.status = e.statusCode; - } else { - ctx.status = 500; - } - } finally { - cleanup(); - } -} diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts new file mode 100644 index 000000000..85b31312b --- /dev/null +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -0,0 +1,594 @@ +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { PathOrFileDescriptor, readFileSync } from 'node:fs'; +import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; +import Koa from 'koa'; +import Router from '@koa/router'; +import send from 'koa-send'; +import favicon from 'koa-favicon'; +import views from 'koa-views'; +import sharp from 'sharp'; +import { createBullBoard } from '@bull-board/api'; +import { BullAdapter } from '@bull-board/api/bullAdapter.js'; +import { KoaAdapter } from '@bull-board/koa'; +import { In, IsNull } from 'typeorm'; +import { Config } from '@/config.js'; +import { getNoteSummary } from '@/misc/get-note-summary.js'; +import { DI } from '@/di-symbols.js'; +import * as Acct from '@/misc/acct.js'; +import { MetaService } from '@/core/MetaService.js'; +import { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue } from '@/core/queue/QueueModule.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { PageEntityService } from '@/core/entities/PageEntityService.js'; +import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; +import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; +import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; +import manifest from './manifest.json' assert { type: 'json' }; +import { FeedService } from './FeedService.js'; +import { UrlPreviewService } from './UrlPreviewService.js'; + +const _filename = fileURLToPath(import.meta.url); +const _dirname = dirname(_filename); + +const staticAssets = `${_dirname}/../../../assets/`; +const clientAssets = `${_dirname}/../../../../client/assets/`; +const assets = `${_dirname}/../../../../../built/_client_dist_/`; +const swAssets = `${_dirname}/../../../../../built/_sw_dist_/`; + +@Injectable() +export class ClientServerService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.galleryPostsRepository) + private galleryPostsRepository: GalleryPostsRepository, + + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + @Inject(DI.clipsRepository) + private clipsRepository: ClipsRepository, + + @Inject(DI.pagesRepository) + private pagesRepository: PagesRepository, + + private userEntityService: UserEntityService, + private noteEntityService: NoteEntityService, + private pageEntityService: PageEntityService, + private galleryPostEntityService: GalleryPostEntityService, + private clipEntityService: ClipEntityService, + private channelEntityService: ChannelEntityService, + private metaService: MetaService, + private urlPreviewService: UrlPreviewService, + private feedService: FeedService, + + @Inject('queue:system') public systemQueue: SystemQueue, + @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, + @Inject('queue:deliver') public deliverQueue: DeliverQueue, + @Inject('queue:inbox') public inboxQueue: InboxQueue, + @Inject('queue:db') public dbQueue: DbQueue, + @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, + @Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, + ) { + } + + private async manifestHandler(ctx: Koa.Context) { + // TODO + //const res = structuredClone(manifest); + const res = JSON.parse(JSON.stringify(manifest)); + + const instance = await this.metaService.fetch(true); + + res.short_name = instance.name ?? 'Misskey'; + res.name = instance.name ?? 'Misskey'; + if (instance.themeColor) res.theme_color = instance.themeColor; + + ctx.set('Cache-Control', 'max-age=300'); + ctx.body = res; + } + + public createApp() { + const app = new Koa(); + + //#region Bull Dashboard + const bullBoardPath = '/queue'; + + // Authenticate + app.use(async (ctx, next) => { + if (ctx.path === bullBoardPath || ctx.path.startsWith(bullBoardPath + '/')) { + const token = ctx.cookies.get('token'); + if (token == null) { + ctx.status = 401; + return; + } + const user = await this.usersRepository.findOneBy({ token }); + if (user == null || !(user.isAdmin || user.isModerator)) { + ctx.status = 403; + return; + } + } + await next(); + }); + + const serverAdapter = new KoaAdapter(); + + createBullBoard({ + queues: [ + this.systemQueue, + this.endedPollNotificationQueue, + this.deliverQueue, + this.inboxQueue, + this.dbQueue, + this.objectStorageQueue, + this.webhookDeliverQueue, + ].map(q => new BullAdapter(q)), + serverAdapter, + }); + + serverAdapter.setBasePath(bullBoardPath); + app.use(serverAdapter.registerPlugin()); + //#endregion + + // Init renderer + app.use(views(_dirname + '/views', { + extension: 'pug', + options: { + version: this.config.version, + getClientEntry: () => process.env.NODE_ENV === 'production' ? + this.config.clientEntry : + JSON.parse(readFileSync(`${_dirname}/../../../../../built/_client_dist_/manifest.json`, 'utf-8'))['src/init.ts'], + config: this.config, + }, + })); + + // Serve favicon + app.use(favicon(`${_dirname}/../../../assets/favicon.ico`)); + + // Common request handler + app.use(async (ctx, next) => { + // IFrameの中に入れられないようにする + ctx.set('X-Frame-Options', 'DENY'); + await next(); + }); + + // Init router + const router = new Router(); + + //#region static assets + + router.get('/static-assets/(.*)', async ctx => { + await send(ctx as any, ctx.path.replace('/static-assets/', ''), { + root: staticAssets, + maxage: ms('7 days'), + }); + }); + + router.get('/client-assets/(.*)', async ctx => { + await send(ctx as any, ctx.path.replace('/client-assets/', ''), { + root: clientAssets, + maxage: ms('7 days'), + }); + }); + + router.get('/assets/(.*)', async ctx => { + await send(ctx as any, ctx.path.replace('/assets/', ''), { + root: assets, + maxage: ms('7 days'), + }); + }); + + // Apple touch icon + router.get('/apple-touch-icon.png', async ctx => { + await send(ctx as any, '/apple-touch-icon.png', { + root: staticAssets, + }); + }); + + router.get('/twemoji/(.*)', async ctx => { + const path = ctx.path.replace('/twemoji/', ''); + + if (!path.match(/^[0-9a-f-]+\.svg$/)) { + ctx.status = 404; + return; + } + + ctx.set('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); + + await send(ctx as any, path, { + root: `${_dirname}/../../../node_modules/@discordapp/twemoji/dist/svg/`, + maxage: ms('30 days'), + }); + }); + + router.get('/twemoji-badge/(.*)', async ctx => { + const path = ctx.path.replace('/twemoji-badge/', ''); + + if (!path.match(/^[0-9a-f-]+\.png$/)) { + ctx.status = 404; + return; + } + + const mask = await sharp( + `${_dirname}/../../../node_modules/@discordapp/twemoji/dist/svg/${path.replace('.png', '')}.svg`, + { density: 1000 }, + ) + .resize(488, 488) + .greyscale() + .normalise() + .linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast + .flatten({ background: '#000' }) + .extend({ + top: 12, + bottom: 12, + left: 12, + right: 12, + background: '#000', + }) + .toColorspace('b-w') + .png() + .toBuffer(); + + const buffer = await sharp({ + create: { width: 512, height: 512, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, + }) + .pipelineColorspace('b-w') + .boolean(mask, 'eor') + .resize(96, 96) + .png() + .toBuffer(); + + ctx.set('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); + ctx.set('Cache-Control', 'max-age=2592000'); + ctx.set('Content-Type', 'image/png'); + ctx.body = buffer; + }); + + // ServiceWorker + router.get('/sw.js', async ctx => { + await send(ctx as any, '/sw.js', { + root: swAssets, + maxage: ms('10 minutes'), + }); + }); + + // Manifest + router.get('/manifest.json', ctx => this.manifestHandler(ctx)); + + router.get('/robots.txt', async ctx => { + await send(ctx as any, '/robots.txt', { + root: staticAssets, + }); + }); + + //#endregion + + // Docs + router.get('/api-doc', async ctx => { + await send(ctx as any, '/redoc.html', { + root: staticAssets, + }); + }); + + // URL preview endpoint + router.get('/url', ctx => this.urlPreviewService.handle(ctx)); + + router.get('/api.json', async ctx => { + ctx.body = genOpenapiSpec(); + }); + + const getFeed = async (acct: string) => { + const { username, host } = Acct.parse(acct); + const user = await this.usersRepository.findOneBy({ + usernameLower: username.toLowerCase(), + host: host ?? IsNull(), + isSuspended: false, + }); + + return user && await this.feedService.packFeed(user); + }; + + // Atom + router.get('/@:user.atom', async ctx => { + const feed = await getFeed(ctx.params.user); + + if (feed) { + ctx.set('Content-Type', 'application/atom+xml; charset=utf-8'); + ctx.body = feed.atom1(); + } else { + ctx.status = 404; + } + }); + + // RSS + router.get('/@:user.rss', async ctx => { + const feed = await getFeed(ctx.params.user); + + if (feed) { + ctx.set('Content-Type', 'application/rss+xml; charset=utf-8'); + ctx.body = feed.rss2(); + } else { + ctx.status = 404; + } + }); + + // JSON + router.get('/@:user.json', async ctx => { + const feed = await getFeed(ctx.params.user); + + if (feed) { + ctx.set('Content-Type', 'application/json; charset=utf-8'); + ctx.body = feed.json1(); + } else { + ctx.status = 404; + } + }); + + //#region SSR (for crawlers) + // User + router.get(['/@:user', '/@:user/:sub'], async (ctx, next) => { + const { username, host } = Acct.parse(ctx.params.user); + const user = await this.usersRepository.findOneBy({ + usernameLower: username.toLowerCase(), + host: host ?? IsNull(), + isSuspended: false, + }); + + if (user != null) { + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + const meta = await this.metaService.fetch(); + const me = profile.fields + ? profile.fields + .filter(filed => filed.value != null && filed.value.match(/^https?:/)) + .map(field => field.value) + : []; + + await ctx.render('user', { + user, profile, me, + avatarUrl: await this.userEntityService.getAvatarUrl(user), + sub: ctx.params.sub, + instanceName: meta.name ?? 'Misskey', + icon: meta.iconUrl, + themeColor: meta.themeColor, + }); + ctx.set('Cache-Control', 'public, max-age=15'); + } else { + // リモートユーザーなので + // モデレータがAPI経由で参照可能にするために404にはしない + await next(); + } + }); + + router.get('/users/:user', async ctx => { + const user = await this.usersRepository.findOneBy({ + id: ctx.params.user, + host: IsNull(), + isSuspended: false, + }); + + if (user == null) { + ctx.status = 404; + return; + } + + ctx.redirect(`/@${user.username}${ user.host == null ? '' : '@' + user.host}`); + }); + + // Note + router.get('/notes/:note', async (ctx, next) => { + const note = await this.notesRepository.findOneBy({ + id: ctx.params.note, + visibility: In(['public', 'home']), + }); + + if (note) { + const _note = await this.noteEntityService.pack(note); + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: note.userId }); + const meta = await this.metaService.fetch(); + await ctx.render('note', { + note: _note, + profile, + avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: note.userId })), + // TODO: Let locale changeable by instance setting + summary: getNoteSummary(_note), + instanceName: meta.name ?? 'Misskey', + icon: meta.iconUrl, + themeColor: meta.themeColor, + }); + + ctx.set('Cache-Control', 'public, max-age=15'); + + return; + } + + await next(); + }); + + // Page + router.get('/@:user/pages/:page', async (ctx, next) => { + const { username, host } = Acct.parse(ctx.params.user); + const user = await this.usersRepository.findOneBy({ + usernameLower: username.toLowerCase(), + host: host ?? IsNull(), + }); + + if (user == null) return; + + const page = await this.pagesRepository.findOneBy({ + name: ctx.params.page, + userId: user.id, + }); + + if (page) { + const _page = await this.pageEntityService.pack(page); + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: page.userId }); + const meta = await this.metaService.fetch(); + await ctx.render('page', { + page: _page, + profile, + avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: page.userId })), + instanceName: meta.name ?? 'Misskey', + icon: meta.iconUrl, + themeColor: meta.themeColor, + }); + + if (['public'].includes(page.visibility)) { + ctx.set('Cache-Control', 'public, max-age=15'); + } else { + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); + } + + return; + } + + await next(); + }); + + // Clip + // TODO: 非publicなclipのハンドリング + router.get('/clips/:clip', async (ctx, next) => { + const clip = await this.clipsRepository.findOneBy({ + id: ctx.params.clip, + }); + + if (clip) { + const _clip = await this.clipEntityService.pack(clip); + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: clip.userId }); + const meta = await this.metaService.fetch(); + await ctx.render('clip', { + clip: _clip, + profile, + avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: clip.userId })), + instanceName: meta.name ?? 'Misskey', + icon: meta.iconUrl, + themeColor: meta.themeColor, + }); + + ctx.set('Cache-Control', 'public, max-age=15'); + + return; + } + + await next(); + }); + + // Gallery post + router.get('/gallery/:post', async (ctx, next) => { + const post = await this.galleryPostsRepository.findOneBy({ id: ctx.params.post }); + + if (post) { + const _post = await this.galleryPostEntityService.pack(post); + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: post.userId }); + const meta = await this.metaService.fetch(); + await ctx.render('gallery-post', { + post: _post, + profile, + avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: post.userId })), + instanceName: meta.name ?? 'Misskey', + icon: meta.iconUrl, + themeColor: meta.themeColor, + }); + + ctx.set('Cache-Control', 'public, max-age=15'); + + return; + } + + await next(); + }); + + // Channel + router.get('/channels/:channel', async (ctx, next) => { + const channel = await this.channelsRepository.findOneBy({ + id: ctx.params.channel, + }); + + if (channel) { + const _channel = await this.channelEntityService.pack(channel); + const meta = await this.metaService.fetch(); + await ctx.render('channel', { + channel: _channel, + instanceName: meta.name ?? 'Misskey', + icon: meta.iconUrl, + themeColor: meta.themeColor, + }); + + ctx.set('Cache-Control', 'public, max-age=15'); + + return; + } + + await next(); + }); + //#endregion + + router.get('/_info_card_', async ctx => { + const meta = await this.metaService.fetch(true); + + ctx.remove('X-Frame-Options'); + + await ctx.render('info-card', { + version: this.config.version, + host: this.config.host, + meta: meta, + originalUsersCount: await this.usersRepository.countBy({ host: IsNull() }), + originalNotesCount: await this.notesRepository.countBy({ userHost: IsNull() }), + }); + }); + + router.get('/bios', async ctx => { + await ctx.render('bios', { + version: this.config.version, + }); + }); + + router.get('/cli', async ctx => { + await ctx.render('cli', { + version: this.config.version, + }); + }); + + const override = (source: string, target: string, depth = 0) => + [, ...target.split('/').filter(x => x), ...source.split('/').filter(x => x).splice(depth)].join('/'); + + router.get('/flush', async ctx => { + await ctx.render('flush'); + }); + + // streamingに非WebSocketリクエストが来た場合にbase htmlをキャシュ付きで返すと、Proxy等でそのパスがキャッシュされておかしくなる + router.get('/streaming', async ctx => { + ctx.status = 503; + ctx.set('Cache-Control', 'private, max-age=0'); + }); + + // Render base html for all requests + router.get('(.*)', async ctx => { + const meta = await this.metaService.fetch(); + await ctx.render('base', { + img: meta.bannerUrl, + title: meta.name ?? 'Misskey', + instanceName: meta.name ?? 'Misskey', + desc: meta.description, + icon: meta.iconUrl, + themeColor: meta.themeColor, + }); + ctx.set('Cache-Control', 'public, max-age=15'); + }); + + // Register router + app.use(router.routes()); + + return app; + } +} diff --git a/packages/backend/src/server/web/FeedService.ts b/packages/backend/src/server/web/FeedService.ts new file mode 100644 index 000000000..8b676aebe --- /dev/null +++ b/packages/backend/src/server/web/FeedService.ts @@ -0,0 +1,86 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { In, IsNull } from 'typeorm'; +import { Feed } from 'feed'; +import { DI } from '@/di-symbols.js'; +import { DriveFilesRepository, NotesRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type { User } from '@/models/entities/User.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; + +@Injectable() +export class FeedService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private userEntityService: UserEntityService, + private driveFileEntityService: DriveFileEntityService, + ) { + } + + public async packFeed(user: User) { + const author = { + link: `${this.config.url}/@${user.username}`, + name: user.name ?? user.username, + }; + + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + + const notes = await this.notesRepository.find({ + where: { + userId: user.id, + renoteId: IsNull(), + visibility: In(['public', 'home']), + }, + order: { createdAt: -1 }, + take: 20, + }); + + const feed = new Feed({ + id: author.link, + title: `${author.name} (@${user.username}@${this.config.host})`, + updated: notes[0].createdAt, + generator: 'Misskey', + description: `${user.notesCount} Notes, ${profile.ffVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.ffVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`, + link: author.link, + image: await this.userEntityService.getAvatarUrl(user), + feedLinks: { + json: `${author.link}.json`, + atom: `${author.link}.atom`, + }, + author, + copyright: user.name ?? user.username, + }); + + for (const note of notes) { + const files = note.fileIds.length > 0 ? await this.driveFilesRepository.findBy({ + id: In(note.fileIds), + }) : []; + const file = files.find(file => file.type.startsWith('image/')); + + feed.addItem({ + title: `New note by ${author.name}`, + link: `${this.config.url}/notes/${note.id}`, + date: note.createdAt, + description: note.cw ?? undefined, + content: note.text ?? undefined, + image: file ? this.driveFileEntityService.getPublicUrl(file) ?? undefined : undefined, + }); + } + + return feed; + } +} diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts new file mode 100644 index 000000000..1cbb3f36c --- /dev/null +++ b/packages/backend/src/server/web/UrlPreviewService.ts @@ -0,0 +1,86 @@ +import { Inject, Injectable } from '@nestjs/common'; +import summaly from 'summaly'; +import { DI } from '@/di-symbols.js'; +import { UsersRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import { MetaService } from '@/core/MetaService.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import type Logger from '@/logger.js'; +import { query } from '@/misc/prelude/url.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import type Koa from 'koa'; + +@Injectable() +export class UrlPreviewService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private metaService: MetaService, + private httpRequestService: HttpRequestService, + private loggerService: LoggerService, + ) { + this.logger = this.loggerService.getLogger('url-preview'); + } + + private wrap(url?: string): string | null { + return url != null + ? url.match(/^https?:\/\//) + ? `${this.config.url}/proxy/preview.webp?${query({ + url, + preview: '1', + })}` + : url + : null; + } + + public async handle(ctx: Koa.Context) { + const url = ctx.query.url; + if (typeof url !== 'string') { + ctx.status = 400; + return; + } + + const lang = ctx.query.lang; + if (Array.isArray(lang)) { + ctx.status = 400; + return; + } + + const meta = await this.metaService.fetch(); + + this.logger.info(meta.summalyProxy + ? `(Proxy) Getting preview of ${url}@${lang} ...` + : `Getting preview of ${url}@${lang} ...`); + + try { + const summary = meta.summalyProxy ? await this.httpRequestService.getJson(`${meta.summalyProxy}?${query({ + url: url, + lang: lang ?? 'ja-JP', + })}`) : await summaly.default(url, { + followRedirects: false, + lang: lang ?? 'ja-JP', + }); + + this.logger.succ(`Got preview of ${url}: ${summary.title}`); + + summary.icon = this.wrap(summary.icon); + summary.thumbnail = this.wrap(summary.thumbnail); + + // Cache 7days + ctx.set('Cache-Control', 'max-age=604800, immutable'); + + ctx.body = summary; + } catch (err) { + this.logger.warn(`Failed to get preview of ${url}: ${err}`); + ctx.status = 200; + ctx.set('Cache-Control', 'max-age=86400, immutable'); + ctx.body = '{}'; + } + } +} diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index b0d529ec3..2aef689d3 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -92,6 +92,10 @@ } } } + const colorSchema = localStorage.getItem('colorSchema'); + if (colorSchema) { + document.documentElement.style.setProperty('color-schema', colorSchema); + } //#endregion const fontSize = localStorage.getItem('fontSize'); diff --git a/packages/backend/src/server/web/feed.ts b/packages/backend/src/server/web/feed.ts deleted file mode 100644 index 4abe2885c..000000000 --- a/packages/backend/src/server/web/feed.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Feed } from 'feed'; -import { In, IsNull } from 'typeorm'; -import config from '@/config/index.js'; -import { User } from '@/models/entities/user.js'; -import { Notes, DriveFiles, UserProfiles, Users } from '@/models/index.js'; - -export default async function(user: User) { - const author = { - link: `${config.url}/@${user.username}`, - name: user.name || user.username, - }; - - const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - - const notes = await Notes.find({ - where: { - userId: user.id, - renoteId: IsNull(), - visibility: In(['public', 'home']), - }, - order: { createdAt: -1 }, - take: 20, - }); - - const feed = new Feed({ - id: author.link, - title: `${author.name} (@${user.username}@${config.host})`, - updated: notes[0].createdAt, - generator: 'Misskey', - description: `${user.notesCount} Notes, ${profile.ffVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.ffVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`, - link: author.link, - image: await Users.getAvatarUrl(user), - feedLinks: { - json: `${author.link}.json`, - atom: `${author.link}.atom`, - }, - author, - copyright: user.name || user.username, - }); - - for (const note of notes) { - const files = note.fileIds.length > 0 ? await DriveFiles.findBy({ - id: In(note.fileIds), - }) : []; - const file = files.find(file => file.type.startsWith('image/')); - - feed.addItem({ - title: `New note by ${author.name}`, - link: `${config.url}/notes/${note.id}`, - date: note.createdAt, - description: note.cw || undefined, - content: note.text || undefined, - image: file ? DriveFiles.getPublicUrl(file) || undefined : undefined, - }); - } - - return feed; -} diff --git a/packages/backend/src/server/web/index.ts b/packages/backend/src/server/web/index.ts deleted file mode 100644 index be95becb6..000000000 --- a/packages/backend/src/server/web/index.ts +++ /dev/null @@ -1,521 +0,0 @@ -/** - * Web Client Server - */ - -import { dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { PathOrFileDescriptor, readFileSync } from 'node:fs'; -import ms from 'ms'; -import Koa from 'koa'; -import Router from '@koa/router'; -import send from 'koa-send'; -import favicon from 'koa-favicon'; -import views from 'koa-views'; -import sharp from 'sharp'; -import { createBullBoard } from '@bull-board/api'; -import { BullAdapter } from '@bull-board/api/bullAdapter.js'; -import { KoaAdapter } from '@bull-board/koa'; - -import { In, IsNull } from 'typeorm'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import config from '@/config/index.js'; -import { Users, Notes, UserProfiles, Pages, Channels, Clips, GalleryPosts } from '@/models/index.js'; -import * as Acct from '@/misc/acct.js'; -import { getNoteSummary } from '@/misc/get-note-summary.js'; -import { queues } from '@/queue/queues.js'; -import { genOpenapiSpec } from '../api/openapi/gen-spec.js'; -import { urlPreviewHandler } from './url-preview.js'; -import { manifestHandler } from './manifest.js'; -import packFeed from './feed.js'; - -const _filename = fileURLToPath(import.meta.url); -const _dirname = dirname(_filename); - -const staticAssets = `${_dirname}/../../../assets/`; -const clientAssets = `${_dirname}/../../../../client/assets/`; -const assets = `${_dirname}/../../../../../built/_client_dist_/`; -const swAssets = `${_dirname}/../../../../../built/_sw_dist_/`; - -// Init app -const app = new Koa(); - -//#region Bull Dashboard -const bullBoardPath = '/queue'; - -// Authenticate -app.use(async (ctx, next) => { - if (ctx.path === bullBoardPath || ctx.path.startsWith(bullBoardPath + '/')) { - const token = ctx.cookies.get('token'); - if (token == null) { - ctx.status = 401; - return; - } - const user = await Users.findOneBy({ token }); - if (user == null || !(user.isAdmin || user.isModerator)) { - ctx.status = 403; - return; - } - } - await next(); -}); - -const serverAdapter = new KoaAdapter(); - -createBullBoard({ - queues: queues.map(q => new BullAdapter(q)), - serverAdapter, -}); - -serverAdapter.setBasePath(bullBoardPath); -app.use(serverAdapter.registerPlugin()); -//#endregion - -// Init renderer -app.use(views(_dirname + '/views', { - extension: 'pug', - options: { - version: config.version, - getClientEntry: () => process.env.NODE_ENV === 'production' ? - config.clientEntry : - JSON.parse(readFileSync(`${_dirname}/../../../../../built/_client_dist_/manifest.json`, 'utf-8'))['src/init.ts'], - config, - }, -})); - -// Serve favicon -app.use(favicon(`${_dirname}/../../../assets/favicon.ico`)); - -// Common request handler -app.use(async (ctx, next) => { - // IFrameの中に入れられないようにする - ctx.set('X-Frame-Options', 'DENY'); - await next(); -}); - -// Init router -const router = new Router(); - -//#region static assets - -router.get('/static-assets/(.*)', async ctx => { - await send(ctx as any, ctx.path.replace('/static-assets/', ''), { - root: staticAssets, - maxage: ms('7 days'), - }); -}); - -router.get('/client-assets/(.*)', async ctx => { - await send(ctx as any, ctx.path.replace('/client-assets/', ''), { - root: clientAssets, - maxage: ms('7 days'), - }); -}); - -router.get('/assets/(.*)', async ctx => { - await send(ctx as any, ctx.path.replace('/assets/', ''), { - root: assets, - maxage: ms('7 days'), - }); -}); - -// Apple touch icon -router.get('/apple-touch-icon.png', async ctx => { - await send(ctx as any, '/apple-touch-icon.png', { - root: staticAssets, - }); -}); - -router.get('/twemoji/(.*)', async ctx => { - const path = ctx.path.replace('/twemoji/', ''); - - if (!path.match(/^[0-9a-f-]+\.svg$/)) { - ctx.status = 404; - return; - } - - ctx.set('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); - - await send(ctx as any, path, { - root: `${_dirname}/../../../node_modules/@discordapp/twemoji/dist/svg/`, - maxage: ms('30 days'), - }); -}); - -router.get('/twemoji-badge/(.*)', async ctx => { - const path = ctx.path.replace('/twemoji-badge/', ''); - - if (!path.match(/^[0-9a-f-]+\.png$/)) { - ctx.status = 404; - return; - } - - const mask = await sharp( - `${_dirname}/../../../node_modules/@discordapp/twemoji/dist/svg/${path.replace('.png', '')}.svg`, - { density: 1000 }, - ) - .resize(488, 488) - .greyscale() - .normalise() - .linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast - .flatten({ background: '#000' }) - .extend({ - top: 12, - bottom: 12, - left: 12, - right: 12, - background: '#000', - }) - .toColorspace('b-w') - .png() - .toBuffer(); - - const buffer = await sharp({ - create: { width: 512, height: 512, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, - }) - .pipelineColorspace('b-w') - .boolean(mask, 'eor') - .resize(96, 96) - .png() - .toBuffer(); - - ctx.set('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); - ctx.set('Cache-Control', 'max-age=2592000'); - ctx.set('Content-Type', 'image/png'); - ctx.body = buffer; -}); - -// ServiceWorker -router.get(`/sw.js`, async ctx => { - await send(ctx as any, `/sw.js`, { - root: swAssets, - maxage: ms('10 minutes'), - }); -}); - -// Manifest -router.get('/manifest.json', manifestHandler); - -router.get('/robots.txt', async ctx => { - await send(ctx as any, '/robots.txt', { - root: staticAssets, - }); -}); - -//#endregion - -// Docs -router.get('/api-doc', async ctx => { - await send(ctx as any, '/redoc.html', { - root: staticAssets, - }); -}); - -// URL preview endpoint -router.get('/url', urlPreviewHandler); - -router.get('/api.json', async ctx => { - ctx.body = genOpenapiSpec(); -}); - -const getFeed = async (acct: string) => { - const { username, host } = Acct.parse(acct); - const user = await Users.findOneBy({ - usernameLower: username.toLowerCase(), - host: host ?? IsNull(), - isSuspended: false, - }); - - return user && await packFeed(user); -}; - -// Atom -router.get('/@:user.atom', async ctx => { - const feed = await getFeed(ctx.params.user); - - if (feed) { - ctx.set('Content-Type', 'application/atom+xml; charset=utf-8'); - ctx.body = feed.atom1(); - } else { - ctx.status = 404; - } -}); - -// RSS -router.get('/@:user.rss', async ctx => { - const feed = await getFeed(ctx.params.user); - - if (feed) { - ctx.set('Content-Type', 'application/rss+xml; charset=utf-8'); - ctx.body = feed.rss2(); - } else { - ctx.status = 404; - } -}); - -// JSON -router.get('/@:user.json', async ctx => { - const feed = await getFeed(ctx.params.user); - - if (feed) { - ctx.set('Content-Type', 'application/json; charset=utf-8'); - ctx.body = feed.json1(); - } else { - ctx.status = 404; - } -}); - -//#region SSR (for crawlers) -// User -router.get(['/@:user', '/@:user/:sub'], async (ctx, next) => { - const { username, host } = Acct.parse(ctx.params.user); - const user = await Users.findOneBy({ - usernameLower: username.toLowerCase(), - host: host ?? IsNull(), - isSuspended: false, - }); - - if (user != null) { - const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - const meta = await fetchMeta(); - const me = profile.fields - ? profile.fields - .filter(filed => filed.value != null && filed.value.match(/^https?:/)) - .map(field => field.value) - : []; - - await ctx.render('user', { - user, profile, me, - avatarUrl: await Users.getAvatarUrl(user), - sub: ctx.params.sub, - instanceName: meta.name || 'Misskey', - icon: meta.iconUrl, - themeColor: meta.themeColor, - }); - ctx.set('Cache-Control', 'public, max-age=15'); - } else { - // リモートユーザーなので - // モデレータがAPI経由で参照可能にするために404にはしない - await next(); - } -}); - -router.get('/users/:user', async ctx => { - const user = await Users.findOneBy({ - id: ctx.params.user, - host: IsNull(), - isSuspended: false, - }); - - if (user == null) { - ctx.status = 404; - return; - } - - ctx.redirect(`/@${user.username}${ user.host == null ? '' : '@' + user.host}`); -}); - -// Note -router.get('/notes/:note', async (ctx, next) => { - const note = await Notes.findOneBy({ - id: ctx.params.note, - visibility: In(['public', 'home']), - }); - - if (note) { - const _note = await Notes.pack(note); - const profile = await UserProfiles.findOneByOrFail({ userId: note.userId }); - const meta = await fetchMeta(); - await ctx.render('note', { - note: _note, - profile, - avatarUrl: await Users.getAvatarUrl(await Users.findOneByOrFail({ id: note.userId })), - // TODO: Let locale changeable by instance setting - summary: getNoteSummary(_note), - instanceName: meta.name || 'Misskey', - icon: meta.iconUrl, - themeColor: meta.themeColor, - }); - - ctx.set('Cache-Control', 'public, max-age=15'); - - return; - } - - await next(); -}); - -// Page -router.get('/@:user/pages/:page', async (ctx, next) => { - const { username, host } = Acct.parse(ctx.params.user); - const user = await Users.findOneBy({ - usernameLower: username.toLowerCase(), - host: host ?? IsNull(), - }); - - if (user == null) return; - - const page = await Pages.findOneBy({ - name: ctx.params.page, - userId: user.id, - }); - - if (page) { - const _page = await Pages.pack(page); - const profile = await UserProfiles.findOneByOrFail({ userId: page.userId }); - const meta = await fetchMeta(); - await ctx.render('page', { - page: _page, - profile, - avatarUrl: await Users.getAvatarUrl(await Users.findOneByOrFail({ id: page.userId })), - instanceName: meta.name || 'Misskey', - icon: meta.iconUrl, - themeColor: meta.themeColor, - }); - - if (['public'].includes(page.visibility)) { - ctx.set('Cache-Control', 'public, max-age=15'); - } else { - ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); - } - - return; - } - - await next(); -}); - -// Clip -// TODO: 非publicなclipのハンドリング -router.get('/clips/:clip', async (ctx, next) => { - const clip = await Clips.findOneBy({ - id: ctx.params.clip, - }); - - if (clip) { - const _clip = await Clips.pack(clip); - const profile = await UserProfiles.findOneByOrFail({ userId: clip.userId }); - const meta = await fetchMeta(); - await ctx.render('clip', { - clip: _clip, - profile, - avatarUrl: await Users.getAvatarUrl(await Users.findOneByOrFail({ id: clip.userId })), - instanceName: meta.name || 'Misskey', - icon: meta.iconUrl, - themeColor: meta.themeColor, - }); - - ctx.set('Cache-Control', 'public, max-age=15'); - - return; - } - - await next(); -}); - -// Gallery post -router.get('/gallery/:post', async (ctx, next) => { - const post = await GalleryPosts.findOneBy({ id: ctx.params.post }); - - if (post) { - const _post = await GalleryPosts.pack(post); - const profile = await UserProfiles.findOneByOrFail({ userId: post.userId }); - const meta = await fetchMeta(); - await ctx.render('gallery-post', { - post: _post, - profile, - avatarUrl: await Users.getAvatarUrl(await Users.findOneByOrFail({ id: post.userId })), - instanceName: meta.name || 'Misskey', - icon: meta.iconUrl, - themeColor: meta.themeColor, - }); - - ctx.set('Cache-Control', 'public, max-age=15'); - - return; - } - - await next(); -}); - -// Channel -router.get('/channels/:channel', async (ctx, next) => { - const channel = await Channels.findOneBy({ - id: ctx.params.channel, - }); - - if (channel) { - const _channel = await Channels.pack(channel); - const meta = await fetchMeta(); - await ctx.render('channel', { - channel: _channel, - instanceName: meta.name || 'Misskey', - icon: meta.iconUrl, - themeColor: meta.themeColor, - }); - - ctx.set('Cache-Control', 'public, max-age=15'); - - return; - } - - await next(); -}); -//#endregion - -router.get('/_info_card_', async ctx => { - const meta = await fetchMeta(true); - - ctx.remove('X-Frame-Options'); - - await ctx.render('info-card', { - version: config.version, - host: config.host, - meta: meta, - originalUsersCount: await Users.countBy({ host: IsNull() }), - originalNotesCount: await Notes.countBy({ userHost: IsNull() }), - }); -}); - -router.get('/bios', async ctx => { - await ctx.render('bios', { - version: config.version, - }); -}); - -router.get('/cli', async ctx => { - await ctx.render('cli', { - version: config.version, - }); -}); - -const override = (source: string, target: string, depth = 0) => - [, ...target.split('/').filter(x => x), ...source.split('/').filter(x => x).splice(depth)].join('/'); - -router.get('/flush', async ctx => { - await ctx.render('flush'); -}); - -// streamingに非WebSocketリクエストが来た場合にbase htmlをキャシュ付きで返すと、Proxy等でそのパスがキャッシュされておかしくなる -router.get('/streaming', async ctx => { - ctx.status = 503; - ctx.set('Cache-Control', 'private, max-age=0'); -}); - -// Render base html for all requests -router.get('(.*)', async ctx => { - const meta = await fetchMeta(); - await ctx.render('base', { - img: meta.bannerUrl, - title: meta.name || 'Misskey', - instanceName: meta.name || 'Misskey', - desc: meta.description, - icon: meta.iconUrl, - themeColor: meta.themeColor, - }); - ctx.set('Cache-Control', 'public, max-age=15'); -}); - -// Register router -app.use(router.routes()); - -export default app; diff --git a/packages/backend/src/server/web/manifest.ts b/packages/backend/src/server/web/manifest.ts deleted file mode 100644 index ee568b807..000000000 --- a/packages/backend/src/server/web/manifest.ts +++ /dev/null @@ -1,18 +0,0 @@ -import Koa from 'koa'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import manifest from './manifest.json' assert { type: 'json' }; - -export const manifestHandler = async (ctx: Koa.Context) => { - // TODO - //const res = structuredClone(manifest); - const res = JSON.parse(JSON.stringify(manifest)); - - const instance = await fetchMeta(true); - - res.short_name = instance.name || 'Misskey'; - res.name = instance.name || 'Misskey'; - if (instance.themeColor) res.theme_color = instance.themeColor; - - ctx.set('Cache-Control', 'max-age=300'); - ctx.body = res; -}; diff --git a/packages/backend/src/server/web/url-preview.ts b/packages/backend/src/server/web/url-preview.ts deleted file mode 100644 index 1e259649f..000000000 --- a/packages/backend/src/server/web/url-preview.ts +++ /dev/null @@ -1,65 +0,0 @@ -import Koa from 'koa'; -import summaly from 'summaly'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import Logger from '@/services/logger.js'; -import config from '@/config/index.js'; -import { query } from '@/prelude/url.js'; -import { getJson } from '@/misc/fetch.js'; - -const logger = new Logger('url-preview'); - -export const urlPreviewHandler = async (ctx: Koa.Context) => { - const url = ctx.query.url; - if (typeof url !== 'string') { - ctx.status = 400; - return; - } - - const lang = ctx.query.lang; - if (Array.isArray(lang)) { - ctx.status = 400; - return; - } - - const meta = await fetchMeta(); - - logger.info(meta.summalyProxy - ? `(Proxy) Getting preview of ${url}@${lang} ...` - : `Getting preview of ${url}@${lang} ...`); - - try { - const summary = meta.summalyProxy ? await getJson(`${meta.summalyProxy}?${query({ - url: url, - lang: lang ?? 'ja-JP', - })}`) : await summaly.default(url, { - followRedirects: false, - lang: lang ?? 'ja-JP', - }); - - logger.succ(`Got preview of ${url}: ${summary.title}`); - - summary.icon = wrap(summary.icon); - summary.thumbnail = wrap(summary.thumbnail); - - // Cache 7days - ctx.set('Cache-Control', 'max-age=604800, immutable'); - - ctx.body = summary; - } catch (err) { - logger.warn(`Failed to get preview of ${url}: ${err}`); - ctx.status = 200; - ctx.set('Cache-Control', 'max-age=86400, immutable'); - ctx.body = '{}'; - } -}; - -function wrap(url?: string): string | null { - return url != null - ? url.match(/^https?:\/\//) - ? `${config.url}/proxy/preview.webp?${query({ - url, - preview: '1', - })}` - : url - : null; -} diff --git a/packages/backend/src/server/well-known.ts b/packages/backend/src/server/well-known.ts deleted file mode 100644 index 1d094f2ed..000000000 --- a/packages/backend/src/server/well-known.ts +++ /dev/null @@ -1,151 +0,0 @@ -import Router from '@koa/router'; - -import config from '@/config/index.js'; -import * as Acct from '@/misc/acct.js'; -import { links } from './nodeinfo.js'; -import { escapeAttribute, escapeValue } from '@/prelude/xml.js'; -import { Users } from '@/models/index.js'; -import { User } from '@/models/entities/user.js'; -import { FindOptionsWhere, IsNull } from 'typeorm'; - -// Init router -const router = new Router(); - -const XRD = (...x: { element: string, value?: string, attributes?: Record }[]) => - `${x.map(({ element, value, attributes }) => - `<${ - Object.entries(typeof attributes === 'object' && attributes || {}).reduce((a, [k, v]) => `${a} ${k}="${escapeAttribute(v)}"`, element) - }${ - typeof value === 'string' ? `>${escapeValue(value)}`).reduce((a, c) => a + c, '')}`; - -const allPath = '/.well-known/(.*)'; -const webFingerPath = '/.well-known/webfinger'; -const jrd = 'application/jrd+json'; -const xrd = 'application/xrd+xml'; - -router.use(allPath, async (ctx, next) => { - ctx.set({ - 'Access-Control-Allow-Headers': 'Accept', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Expose-Headers': 'Vary', - }); - await next(); -}); - -router.options(allPath, async ctx => { - ctx.status = 204; -}); - -router.get('/.well-known/host-meta', async ctx => { - ctx.set('Content-Type', xrd); - ctx.body = XRD({ element: 'Link', attributes: { - rel: 'lrdd', - type: xrd, - template: `${config.url}${webFingerPath}?resource={uri}`, - } }); -}); - -router.get('/.well-known/host-meta.json', async ctx => { - ctx.set('Content-Type', jrd); - ctx.body = { - links: [{ - rel: 'lrdd', - type: jrd, - template: `${config.url}${webFingerPath}?resource={uri}`, - }], - }; -}); - -router.get('/.well-known/nodeinfo', async ctx => { - ctx.body = { links }; -}); - -/* TODO -router.get('/.well-known/change-password', async ctx => { -}); -*/ - -router.get(webFingerPath, async ctx => { - const fromId = (id: User['id']): FindOptionsWhere => ({ - id, - host: IsNull(), - isSuspended: false, - }); - - const generateQuery = (resource: string): FindOptionsWhere | number => - resource.startsWith(`${config.url.toLowerCase()}/users/`) ? - fromId(resource.split('/').pop()!) : - fromAcct(Acct.parse( - resource.startsWith(`${config.url.toLowerCase()}/@`) ? resource.split('/').pop()! : - resource.startsWith('acct:') ? resource.slice('acct:'.length) : - resource)); - - const fromAcct = (acct: Acct.Acct): FindOptionsWhere | number => - !acct.host || acct.host === config.host.toLowerCase() ? { - usernameLower: acct.username, - host: IsNull(), - isSuspended: false, - } : 422; - - if (typeof ctx.query.resource !== 'string') { - ctx.status = 400; - return; - } - - const query = generateQuery(ctx.query.resource.toLowerCase()); - - if (typeof query === 'number') { - ctx.status = query; - return; - } - - const user = await Users.findOneBy(query); - - if (user == null) { - ctx.status = 404; - return; - } - - const subject = `acct:${user.username}@${config.host}`; - const self = { - rel: 'self', - type: 'application/activity+json', - href: `${config.url}/users/${user.id}`, - }; - const profilePage = { - rel: 'http://webfinger.net/rel/profile-page', - type: 'text/html', - href: `${config.url}/@${user.username}`, - }; - const subscribe = { - rel: 'http://ostatus.org/schema/1.0/subscribe', - template: `${config.url}/authorize-follow?acct={uri}`, - }; - - if (ctx.accepts(jrd, xrd) === xrd) { - ctx.body = XRD( - { element: 'Subject', value: subject }, - { element: 'Link', attributes: self }, - { element: 'Link', attributes: profilePage }, - { element: 'Link', attributes: subscribe }); - ctx.type = xrd; - } else { - ctx.body = { - subject, - links: [self, profilePage, subscribe], - }; - ctx.type = jrd; - } - - ctx.vary('Accept'); - ctx.set('Cache-Control', 'public, max-age=180'); -}); - -// Return 404 for other .well-known -router.all(allPath, async ctx => { - ctx.status = 404; -}); - -export default router; diff --git a/packages/backend/src/services/add-note-to-antenna.ts b/packages/backend/src/services/add-note-to-antenna.ts deleted file mode 100644 index 1f344222e..000000000 --- a/packages/backend/src/services/add-note-to-antenna.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Antenna } from '@/models/entities/antenna.js'; -import { Note } from '@/models/entities/note.js'; -import { AntennaNotes, Mutings, Notes } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; -import { isUserRelated } from '@/misc/is-user-related.js'; -import { publishAntennaStream, publishMainStream } from '@/services/stream.js'; -import { User } from '@/models/entities/user.js'; - -export async function addNoteToAntenna(antenna: Antenna, note: Note, noteUser: { id: User['id']; }) { - // 通知しない設定になっているか、自分自身の投稿なら既読にする - const read = !antenna.notify || (antenna.userId === noteUser.id); - - AntennaNotes.insert({ - id: genId(), - antennaId: antenna.id, - noteId: note.id, - read: read, - }); - - publishAntennaStream(antenna.id, 'note', note); - - if (!read) { - const mutings = await Mutings.find({ - where: { - muterId: antenna.userId, - }, - select: ['muteeId'], - }); - - // Copy - const _note: Note = { - ...note, - }; - - if (note.replyId != null) { - _note.reply = await Notes.findOneByOrFail({ id: note.replyId }); - } - if (note.renoteId != null) { - _note.renote = await Notes.findOneByOrFail({ id: note.renoteId }); - } - - if (isUserRelated(_note, new Set(mutings.map(x => x.muteeId)))) { - return; - } - - // 2秒経っても既読にならなかったら通知 - setTimeout(async () => { - const unread = await AntennaNotes.findOneBy({ antennaId: antenna.id, read: false }); - if (unread) { - publishMainStream(antenna.userId, 'unreadAntenna', antenna); - } - }, 2000); - } -} diff --git a/packages/backend/src/services/blocking/create.ts b/packages/backend/src/services/blocking/create.ts deleted file mode 100644 index a2c61cca2..000000000 --- a/packages/backend/src/services/blocking/create.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { publishMainStream, publishUserEvent } from '@/services/stream.js'; -import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import renderFollow from '@/remote/activitypub/renderer/follow.js'; -import renderUndo from '@/remote/activitypub/renderer/undo.js'; -import { renderBlock } from '@/remote/activitypub/renderer/block.js'; -import { deliver } from '@/queue/index.js'; -import renderReject from '@/remote/activitypub/renderer/reject.js'; -import { Blocking } from '@/models/entities/blocking.js'; -import { User } from '@/models/entities/user.js'; -import { Blockings, Users, FollowRequests, Followings, UserListJoinings, UserLists } from '@/models/index.js'; -import { perUserFollowingChart } from '@/services/chart/index.js'; -import { genId } from '@/misc/gen-id.js'; -import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { getActiveWebhooks } from '@/misc/webhook-cache.js'; -import { webhookDeliver } from '@/queue/index.js'; - -export default async function(blocker: User, blockee: User) { - await Promise.all([ - cancelRequest(blocker, blockee), - cancelRequest(blockee, blocker), - unFollow(blocker, blockee), - unFollow(blockee, blocker), - removeFromList(blockee, blocker), - ]); - - const blocking = { - id: genId(), - createdAt: new Date(), - blocker, - blockerId: blocker.id, - blockee, - blockeeId: blockee.id, - } as Blocking; - - await Blockings.insert(blocking); - - if (Users.isLocalUser(blocker) && Users.isRemoteUser(blockee)) { - const content = renderActivity(renderBlock(blocking)); - deliver(blocker, content, blockee.inbox); - } -} - -async function cancelRequest(follower: User, followee: User) { - const request = await FollowRequests.findOneBy({ - followeeId: followee.id, - followerId: follower.id, - }); - - if (request == null) { - return; - } - - await FollowRequests.delete({ - followeeId: followee.id, - followerId: follower.id, - }); - - if (Users.isLocalUser(followee)) { - Users.pack(followee, followee, { - detail: true, - }).then(packed => publishMainStream(followee.id, 'meUpdated', packed)); - } - - if (Users.isLocalUser(follower)) { - Users.pack(followee, follower, { - detail: true, - }).then(async packed => { - publishUserEvent(follower.id, 'unfollow', packed); - publishMainStream(follower.id, 'unfollow', packed); - - const webhooks = (await getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); - for (const webhook of webhooks) { - webhookDeliver(webhook, 'unfollow', { - user: packed, - }); - } - }); - } - - // リモートにフォローリクエストをしていたらUndoFollow送信 - if (Users.isLocalUser(follower) && Users.isRemoteUser(followee)) { - const content = renderActivity(renderUndo(renderFollow(follower, followee), follower)); - deliver(follower, content, followee.inbox); - } - - // リモートからフォローリクエストを受けていたらReject送信 - if (Users.isRemoteUser(follower) && Users.isLocalUser(followee)) { - const content = renderActivity(renderReject(renderFollow(follower, followee, request.requestId!), followee)); - deliver(followee, content, follower.inbox); - } -} - -async function unFollow(follower: User, followee: User) { - const following = await Followings.findOneBy({ - followerId: follower.id, - followeeId: followee.id, - }); - - if (following == null) { - return; - } - - await Promise.all([ - Followings.delete(following.id), - Users.decrement({ id: follower.id }, 'followingCount', 1), - Users.decrement({ id: followee.id }, 'followersCount', 1), - perUserFollowingChart.update(follower, followee, false), - ]); - - // Publish unfollow event - if (Users.isLocalUser(follower)) { - Users.pack(followee, follower, { - detail: true, - }).then(async packed => { - publishUserEvent(follower.id, 'unfollow', packed); - publishMainStream(follower.id, 'unfollow', packed); - - const webhooks = (await getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); - for (const webhook of webhooks) { - webhookDeliver(webhook, 'unfollow', { - user: packed, - }); - } - }); - } - - // リモートにフォローをしていたらUndoFollow送信 - if (Users.isLocalUser(follower) && Users.isRemoteUser(followee)) { - const content = renderActivity(renderUndo(renderFollow(follower, followee), follower)); - deliver(follower, content, followee.inbox); - } -} - -async function removeFromList(listOwner: User, user: User) { - const userLists = await UserLists.findBy({ - userId: listOwner.id, - }); - - for (const userList of userLists) { - await UserListJoinings.delete({ - userListId: userList.id, - userId: user.id, - }); - } -} diff --git a/packages/backend/src/services/blocking/delete.ts b/packages/backend/src/services/blocking/delete.ts deleted file mode 100644 index cb16651bc..000000000 --- a/packages/backend/src/services/blocking/delete.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import { renderBlock } from '@/remote/activitypub/renderer/block.js'; -import renderUndo from '@/remote/activitypub/renderer/undo.js'; -import { deliver } from '@/queue/index.js'; -import Logger from '../logger.js'; -import { CacheableUser, User } from '@/models/entities/user.js'; -import { Blockings, Users } from '@/models/index.js'; - -const logger = new Logger('blocking/delete'); - -export default async function(blocker: CacheableUser, blockee: CacheableUser) { - const blocking = await Blockings.findOneBy({ - blockerId: blocker.id, - blockeeId: blockee.id, - }); - - if (blocking == null) { - logger.warn('ブロック解除がリクエストされましたがブロックしていませんでした'); - return; - } - - // Since we already have the blocker and blockee, we do not need to fetch - // them in the query above and can just manually insert them here. - blocking.blocker = blocker; - blocking.blockee = blockee; - - Blockings.delete(blocking.id); - - // deliver if remote bloking - if (Users.isLocalUser(blocker) && Users.isRemoteUser(blockee)) { - const content = renderActivity(renderUndo(renderBlock(blocking), blocker)); - deliver(blocker, content, blockee.inbox); - } -} diff --git a/packages/backend/src/services/chart/charts/hashtag.ts b/packages/backend/src/services/chart/charts/hashtag.ts deleted file mode 100644 index 31f7fa95d..000000000 --- a/packages/backend/src/services/chart/charts/hashtag.ts +++ /dev/null @@ -1,29 +0,0 @@ -import Chart, { KVs } from '../core.js'; -import { User } from '@/models/entities/user.js'; -import { Users } from '@/models/index.js'; -import { name, schema } from './entities/hashtag.js'; - -/** - * ハッシュタグに関するチャート - */ -// eslint-disable-next-line import/no-default-export -export default class HashtagChart extends Chart { - constructor() { - super(name, schema, true); - } - - protected async tickMajor(): Promise>> { - return {}; - } - - protected async tickMinor(): Promise>> { - return {}; - } - - public async update(hashtag: string, user: { id: User['id'], host: User['host'] }): Promise { - await this.commit({ - 'local.users': Users.isLocalUser(user) ? [user.id] : [], - 'remote.users': Users.isLocalUser(user) ? [] : [user.id], - }, hashtag); - } -} diff --git a/packages/backend/src/services/chart/charts/per-user-drive.ts b/packages/backend/src/services/chart/charts/per-user-drive.ts deleted file mode 100644 index 5f75dc688..000000000 --- a/packages/backend/src/services/chart/charts/per-user-drive.ts +++ /dev/null @@ -1,42 +0,0 @@ -import Chart, { KVs } from '../core.js'; -import { DriveFiles } from '@/models/index.js'; -import { DriveFile } from '@/models/entities/drive-file.js'; -import { name, schema } from './entities/per-user-drive.js'; - -/** - * ユーザーごとのドライブに関するチャート - */ -// eslint-disable-next-line import/no-default-export -export default class PerUserDriveChart extends Chart { - constructor() { - super(name, schema, true); - } - - protected async tickMajor(group: string): Promise>> { - const [count, size] = await Promise.all([ - DriveFiles.countBy({ userId: group }), - DriveFiles.calcDriveUsageOf(group), - ]); - - return { - 'totalCount': count, - 'totalSize': size, - }; - } - - protected async tickMinor(): Promise>> { - return {}; - } - - public async update(file: DriveFile, isAdditional: boolean): Promise { - const fileSizeKb = file.size / 1000; - await this.commit({ - 'totalCount': isAdditional ? 1 : -1, - 'totalSize': isAdditional ? fileSizeKb : -fileSizeKb, - 'incCount': isAdditional ? 1 : 0, - 'incSize': isAdditional ? fileSizeKb : 0, - 'decCount': isAdditional ? 0 : 1, - 'decSize': isAdditional ? 0 : fileSizeKb, - }, file.userId); - } -} diff --git a/packages/backend/src/services/chart/charts/per-user-following.ts b/packages/backend/src/services/chart/charts/per-user-following.ts deleted file mode 100644 index 02b149f52..000000000 --- a/packages/backend/src/services/chart/charts/per-user-following.ts +++ /dev/null @@ -1,56 +0,0 @@ -import Chart, { KVs } from '../core.js'; -import { Followings, Users } from '@/models/index.js'; -import { Not, IsNull } from 'typeorm'; -import { User } from '@/models/entities/user.js'; -import { name, schema } from './entities/per-user-following.js'; - -/** - * ユーザーごとのフォローに関するチャート - */ -// eslint-disable-next-line import/no-default-export -export default class PerUserFollowingChart extends Chart { - constructor() { - super(name, schema, true); - } - - protected async tickMajor(group: string): Promise>> { - const [ - localFollowingsCount, - localFollowersCount, - remoteFollowingsCount, - remoteFollowersCount, - ] = await Promise.all([ - Followings.countBy({ followerId: group, followeeHost: IsNull() }), - Followings.countBy({ followeeId: group, followerHost: IsNull() }), - Followings.countBy({ followerId: group, followeeHost: Not(IsNull()) }), - Followings.countBy({ followeeId: group, followerHost: Not(IsNull()) }), - ]); - - return { - 'local.followings.total': localFollowingsCount, - 'local.followers.total': localFollowersCount, - 'remote.followings.total': remoteFollowingsCount, - 'remote.followers.total': remoteFollowersCount, - }; - } - - protected async tickMinor(): Promise>> { - return {}; - } - - public async update(follower: { id: User['id']; host: User['host']; }, followee: { id: User['id']; host: User['host']; }, isFollow: boolean): Promise { - const prefixFollower = Users.isLocalUser(follower) ? 'local' : 'remote'; - const prefixFollowee = Users.isLocalUser(followee) ? 'local' : 'remote'; - - this.commit({ - [`${prefixFollower}.followings.total`]: isFollow ? 1 : -1, - [`${prefixFollower}.followings.inc`]: isFollow ? 1 : 0, - [`${prefixFollower}.followings.dec`]: isFollow ? 0 : 1, - }, follower.id); - this.commit({ - [`${prefixFollowee}.followers.total`]: isFollow ? 1 : -1, - [`${prefixFollowee}.followers.inc`]: isFollow ? 1 : 0, - [`${prefixFollowee}.followers.dec`]: isFollow ? 0 : 1, - }, followee.id); - } -} diff --git a/packages/backend/src/services/chart/charts/per-user-reactions.ts b/packages/backend/src/services/chart/charts/per-user-reactions.ts deleted file mode 100644 index 3a830e118..000000000 --- a/packages/backend/src/services/chart/charts/per-user-reactions.ts +++ /dev/null @@ -1,30 +0,0 @@ -import Chart, { KVs } from '../core.js'; -import { User } from '@/models/entities/user.js'; -import { Note } from '@/models/entities/note.js'; -import { Users } from '@/models/index.js'; -import { name, schema } from './entities/per-user-reactions.js'; - -/** - * ユーザーごとのリアクションに関するチャート - */ -// eslint-disable-next-line import/no-default-export -export default class PerUserReactionsChart extends Chart { - constructor() { - super(name, schema, true); - } - - protected async tickMajor(group: string): Promise>> { - return {}; - } - - protected async tickMinor(): Promise>> { - return {}; - } - - public async update(user: { id: User['id'], host: User['host'] }, note: Note): Promise { - const prefix = Users.isLocalUser(user) ? 'local' : 'remote'; - this.commit({ - [`${prefix}.count`]: 1, - }, note.userId); - } -} diff --git a/packages/backend/src/services/chart/charts/test-unique.ts b/packages/backend/src/services/chart/charts/test-unique.ts deleted file mode 100644 index d714f1d40..000000000 --- a/packages/backend/src/services/chart/charts/test-unique.ts +++ /dev/null @@ -1,26 +0,0 @@ -import Chart, { KVs } from '../core.js'; -import { name, schema } from './entities/test-unique.js'; - -/** - * For testing - */ -// eslint-disable-next-line import/no-default-export -export default class TestUniqueChart extends Chart { - constructor() { - super(name, schema); - } - - protected async tickMajor(): Promise>> { - return {}; - } - - protected async tickMinor(): Promise>> { - return {}; - } - - public async uniqueIncrement(key: string): Promise { - await this.commit({ - foo: [key], - }); - } -} diff --git a/packages/backend/src/services/chart/charts/users.ts b/packages/backend/src/services/chart/charts/users.ts deleted file mode 100644 index acb16ead8..000000000 --- a/packages/backend/src/services/chart/charts/users.ts +++ /dev/null @@ -1,41 +0,0 @@ -import Chart, { KVs } from '../core.js'; -import { Users } from '@/models/index.js'; -import { Not, IsNull } from 'typeorm'; -import { User } from '@/models/entities/user.js'; -import { name, schema } from './entities/users.js'; - -/** - * ユーザー数に関するチャート - */ -// eslint-disable-next-line import/no-default-export -export default class UsersChart extends Chart { - constructor() { - super(name, schema); - } - - protected async tickMajor(): Promise>> { - const [localCount, remoteCount] = await Promise.all([ - Users.countBy({ host: IsNull() }), - Users.countBy({ host: Not(IsNull()) }), - ]); - - return { - 'local.total': localCount, - 'remote.total': remoteCount, - }; - } - - protected async tickMinor(): Promise>> { - return {}; - } - - public async update(user: { id: User['id'], host: User['host'] }, isAdditional: boolean): Promise { - const prefix = Users.isLocalUser(user) ? 'local' : 'remote'; - - await this.commit({ - [`${prefix}.total`]: isAdditional ? 1 : -1, - [`${prefix}.inc`]: isAdditional ? 1 : 0, - [`${prefix}.dec`]: isAdditional ? 0 : 1, - }); - } -} diff --git a/packages/backend/src/services/chart/index.ts b/packages/backend/src/services/chart/index.ts deleted file mode 100644 index 8bf2d8f65..000000000 --- a/packages/backend/src/services/chart/index.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { beforeShutdown } from '@/misc/before-shutdown.js'; - -import FederationChart from './charts/federation.js'; -import NotesChart from './charts/notes.js'; -import UsersChart from './charts/users.js'; -import ActiveUsersChart from './charts/active-users.js'; -import InstanceChart from './charts/instance.js'; -import PerUserNotesChart from './charts/per-user-notes.js'; -import DriveChart from './charts/drive.js'; -import PerUserReactionsChart from './charts/per-user-reactions.js'; -import HashtagChart from './charts/hashtag.js'; -import PerUserFollowingChart from './charts/per-user-following.js'; -import PerUserDriveChart from './charts/per-user-drive.js'; -import ApRequestChart from './charts/ap-request.js'; - -export const federationChart = new FederationChart(); -export const notesChart = new NotesChart(); -export const usersChart = new UsersChart(); -export const activeUsersChart = new ActiveUsersChart(); -export const instanceChart = new InstanceChart(); -export const perUserNotesChart = new PerUserNotesChart(); -export const driveChart = new DriveChart(); -export const perUserReactionsChart = new PerUserReactionsChart(); -export const hashtagChart = new HashtagChart(); -export const perUserFollowingChart = new PerUserFollowingChart(); -export const perUserDriveChart = new PerUserDriveChart(); -export const apRequestChart = new ApRequestChart(); - -const charts = [ - federationChart, - notesChart, - usersChart, - activeUsersChart, - instanceChart, - perUserNotesChart, - driveChart, - perUserReactionsChart, - hashtagChart, - perUserFollowingChart, - perUserDriveChart, - apRequestChart, -]; - -// 20分おきにメモリ情報をDBに書き込み -setInterval(() => { - for (const chart of charts) { - chart.save(); - } -}, 1000 * 60 * 20); - -beforeShutdown(() => Promise.all(charts.map(chart => chart.save()))); diff --git a/packages/backend/src/services/create-notification.ts b/packages/backend/src/services/create-notification.ts deleted file mode 100644 index d53a4235b..000000000 --- a/packages/backend/src/services/create-notification.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { publishMainStream } from '@/services/stream.js'; -import { pushNotification } from '@/services/push-notification.js'; -import { Notifications, Mutings, UserProfiles, Users } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; -import { User } from '@/models/entities/user.js'; -import { Notification } from '@/models/entities/notification.js'; -import { sendEmailNotification } from './send-email-notification.js'; - -export async function createNotification( - notifieeId: User['id'], - type: Notification['type'], - data: Partial -) { - if (data.notifierId && (notifieeId === data.notifierId)) { - return null; - } - - const profile = await UserProfiles.findOneBy({ userId: notifieeId }); - - const isMuted = profile?.mutingNotificationTypes.includes(type); - - // Create notification - const notification = await Notifications.insert({ - id: genId(), - createdAt: new Date(), - notifieeId: notifieeId, - type: type, - // 相手がこの通知をミュートしているようなら、既読を予めつけておく - isRead: isMuted, - ...data, - } as Partial) - .then(x => Notifications.findOneByOrFail(x.identifiers[0])); - - const packed = await Notifications.pack(notification, {}); - - // Publish notification event - publishMainStream(notifieeId, 'notification', packed); - - // 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する - setTimeout(async () => { - const fresh = await Notifications.findOneBy({ id: notification.id }); - if (fresh == null) return; // 既に削除されているかもしれない - if (fresh.isRead) return; - - //#region ただしミュートしているユーザーからの通知なら無視 - const mutings = await Mutings.findBy({ - muterId: notifieeId, - }); - if (data.notifierId && mutings.map(m => m.muteeId).includes(data.notifierId)) { - return; - } - //#endregion - - publishMainStream(notifieeId, 'unreadNotification', packed); - pushNotification(notifieeId, 'notification', packed); - - if (type === 'follow') sendEmailNotification.follow(notifieeId, await Users.findOneByOrFail({ id: data.notifierId! })); - if (type === 'receiveFollowRequest') sendEmailNotification.receiveFollowRequest(notifieeId, await Users.findOneByOrFail({ id: data.notifierId! })); - }, 2000); - - return notification; -} diff --git a/packages/backend/src/services/create-system-user.ts b/packages/backend/src/services/create-system-user.ts deleted file mode 100644 index bae91ec4c..000000000 --- a/packages/backend/src/services/create-system-user.ts +++ /dev/null @@ -1,68 +0,0 @@ -import bcrypt from 'bcryptjs'; -import { v4 as uuid } from 'uuid'; -import generateNativeUserToken from '../server/api/common/generate-native-user-token.js'; -import { genRsaKeyPair } from '@/misc/gen-key-pair.js'; -import { User } from '@/models/entities/user.js'; -import { UserProfile } from '@/models/entities/user-profile.js'; -import { IsNull } from 'typeorm'; -import { genId } from '@/misc/gen-id.js'; -import { UserKeypair } from '@/models/entities/user-keypair.js'; -import { UsedUsername } from '@/models/entities/used-username.js'; -import { db } from '@/db/postgre.js'; - -export async function createSystemUser(username: string) { - const password = uuid(); - - // Generate hash of password - const salt = await bcrypt.genSalt(8); - const hash = await bcrypt.hash(password, salt); - - // Generate secret - const secret = generateNativeUserToken(); - - const keyPair = await genRsaKeyPair(4096); - - let account!: User; - - // Start transaction - await db.transaction(async transactionalEntityManager => { - const exist = await transactionalEntityManager.findOneBy(User, { - usernameLower: username.toLowerCase(), - host: IsNull(), - }); - - if (exist) throw new Error('the user is already exists'); - - account = await transactionalEntityManager.insert(User, { - id: genId(), - createdAt: new Date(), - username: username, - usernameLower: username.toLowerCase(), - host: null, - token: secret, - isAdmin: false, - isLocked: true, - isExplorable: false, - isBot: true, - }).then(x => transactionalEntityManager.findOneByOrFail(User, x.identifiers[0])); - - await transactionalEntityManager.insert(UserKeypair, { - publicKey: keyPair.publicKey, - privateKey: keyPair.privateKey, - userId: account.id, - }); - - await transactionalEntityManager.insert(UserProfile, { - userId: account.id, - autoAcceptFollowed: false, - password: hash, - }); - - await transactionalEntityManager.insert(UsedUsername, { - createdAt: new Date(), - username: username.toLowerCase(), - }); - }); - - return account; -} diff --git a/packages/backend/src/services/delete-account.ts b/packages/backend/src/services/delete-account.ts deleted file mode 100644 index 0fdceb671..000000000 --- a/packages/backend/src/services/delete-account.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Users } from '@/models/index.js'; -import { createDeleteAccountJob } from '@/queue/index.js'; -import { publishUserEvent } from './stream.js'; -import { doPostSuspend } from './suspend-user.js'; - -export async function deleteAccount(user: { - id: string; - host: string | null; -}): Promise { - // 物理削除する前にDelete activityを送信する - await doPostSuspend(user).catch(e => {}); - - createDeleteAccountJob(user, { - soft: false, - }); - - await Users.update(user.id, { - isDeleted: true, - }); - - // Terminate streaming - publishUserEvent(user.id, 'terminate', {}); -} diff --git a/packages/backend/src/services/detect-sensitive.ts b/packages/backend/src/services/detect-sensitive.ts deleted file mode 100644 index 2ade39d52..000000000 --- a/packages/backend/src/services/detect-sensitive.ts +++ /dev/null @@ -1,48 +0,0 @@ -import * as fs from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; -import * as nsfw from 'nsfwjs'; -import si from 'systeminformation'; - -const _filename = fileURLToPath(import.meta.url); -const _dirname = dirname(_filename); - -const REQUIRED_CPU_FLAGS = ['avx2', 'fma']; -let isSupportedCpu: undefined | boolean = undefined; - -let model: nsfw.NSFWJS; - -export async function detectSensitive(path: string): Promise { - try { - if (isSupportedCpu === undefined) { - const cpuFlags = await getCpuFlags(); - isSupportedCpu = REQUIRED_CPU_FLAGS.every(required => cpuFlags.includes(required)); - } - - if (!isSupportedCpu) { - console.error('This CPU cannot use TensorFlow.'); - return null; - } - - const tf = await import('@tensorflow/tfjs-node'); - - if (model == null) model = await nsfw.load(`file://${_dirname}/../../nsfw-model/`, { size: 299 }); - - const buffer = await fs.promises.readFile(path); - const image = await tf.node.decodeImage(buffer, 3) as any; - try { - const predictions = await model.classify(image); - return predictions; - } finally { - image.dispose(); - } - } catch (err) { - console.error(err); - return null; - } -} - -async function getCpuFlags(): Promise { - const str = await si.cpuFlags(); - return str.split(/\s+/); -} diff --git a/packages/backend/src/services/drive/add-file.ts b/packages/backend/src/services/drive/add-file.ts deleted file mode 100644 index 709db88f2..000000000 --- a/packages/backend/src/services/drive/add-file.ts +++ /dev/null @@ -1,540 +0,0 @@ -import * as fs from 'node:fs'; - -import { v4 as uuid } from 'uuid'; - -import S3 from 'aws-sdk/clients/s3.js'; -import sharp from 'sharp'; -import { IsNull } from 'typeorm'; -import { publishMainStream, publishDriveStream } from '@/services/stream.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { contentDisposition } from '@/misc/content-disposition.js'; -import { getFileInfo } from '@/misc/get-file-info.js'; -import { DriveFiles, DriveFolders, Users, Instances, UserProfiles } from '@/models/index.js'; -import { DriveFile } from '@/models/entities/drive-file.js'; -import { IRemoteUser, User } from '@/models/entities/user.js'; -import { driveChart, perUserDriveChart, instanceChart } from '@/services/chart/index.js'; -import { genId } from '@/misc/gen-id.js'; -import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; -import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; -import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { getS3 } from './s3.js'; -import { InternalStorage } from './internal-storage.js'; -import { IImage, convertSharpToJpeg, convertSharpToWebp, convertSharpToPng } from './image-processor.js'; -import { driveLogger } from './logger.js'; -import { GenerateVideoThumbnail } from './generate-video-thumbnail.js'; -import { deleteFile } from './delete-file.js'; - -const logger = driveLogger.createSubLogger('register', 'yellow'); - -/*** - * Save file - * @param path Path for original - * @param name Name for original - * @param type Content-Type for original - * @param hash Hash for original - * @param size Size for original - */ -async function save(file: DriveFile, path: string, name: string, type: string, hash: string, size: number): Promise { - // thunbnail, webpublic を必要なら生成 - const alts = await generateAlts(path, type, !file.uri); - - const meta = await fetchMeta(); - - if (meta.useObjectStorage) { - //#region ObjectStorage params - let [ext] = (name.match(/\.([a-zA-Z0-9_-]+)$/) || ['']); - - if (ext === '') { - if (type === 'image/jpeg') ext = '.jpg'; - if (type === 'image/png') ext = '.png'; - if (type === 'image/webp') ext = '.webp'; - if (type === 'image/apng') ext = '.apng'; - if (type === 'image/vnd.mozilla.apng') ext = '.apng'; - } - - // 拡張子からContent-Typeを設定してそうな挙動を示すオブジェクトストレージ (upcloud?) も存在するので、 - // 許可されているファイル形式でしか拡張子をつけない - if (!FILE_TYPE_BROWSERSAFE.includes(type)) { - ext = ''; - } - - const baseUrl = meta.objectStorageBaseUrl - || `${ meta.objectStorageUseSSL ? 'https' : 'http' }://${ meta.objectStorageEndpoint }${ meta.objectStoragePort ? `:${meta.objectStoragePort}` : '' }/${ meta.objectStorageBucket }`; - - // for original - const key = `${meta.objectStoragePrefix}/${uuid()}${ext}`; - const url = `${ baseUrl }/${ key }`; - - // for alts - let webpublicKey: string | null = null; - let webpublicUrl: string | null = null; - let thumbnailKey: string | null = null; - let thumbnailUrl: string | null = null; - //#endregion - - //#region Uploads - logger.info(`uploading original: ${key}`); - const uploads = [ - upload(key, fs.createReadStream(path), type, name), - ]; - - if (alts.webpublic) { - webpublicKey = `${meta.objectStoragePrefix}/webpublic-${uuid()}.${alts.webpublic.ext}`; - webpublicUrl = `${ baseUrl }/${ webpublicKey }`; - - logger.info(`uploading webpublic: ${webpublicKey}`); - uploads.push(upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, name)); - } - - if (alts.thumbnail) { - thumbnailKey = `${meta.objectStoragePrefix}/thumbnail-${uuid()}.${alts.thumbnail.ext}`; - thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`; - - logger.info(`uploading thumbnail: ${thumbnailKey}`); - uploads.push(upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type)); - } - - await Promise.all(uploads); - //#endregion - - file.url = url; - file.thumbnailUrl = thumbnailUrl; - file.webpublicUrl = webpublicUrl; - file.accessKey = key; - file.thumbnailAccessKey = thumbnailKey; - file.webpublicAccessKey = webpublicKey; - file.webpublicType = alts.webpublic?.type ?? null; - file.name = name; - file.type = type; - file.md5 = hash; - file.size = size; - file.storedInternal = false; - - return await DriveFiles.insert(file).then(x => DriveFiles.findOneByOrFail(x.identifiers[0])); - } else { // use internal storage - const accessKey = uuid(); - const thumbnailAccessKey = 'thumbnail-' + uuid(); - const webpublicAccessKey = 'webpublic-' + uuid(); - - const url = InternalStorage.saveFromPath(accessKey, path); - - let thumbnailUrl: string | null = null; - let webpublicUrl: string | null = null; - - if (alts.thumbnail) { - thumbnailUrl = InternalStorage.saveFromBuffer(thumbnailAccessKey, alts.thumbnail.data); - logger.info(`thumbnail stored: ${thumbnailAccessKey}`); - } - - if (alts.webpublic) { - webpublicUrl = InternalStorage.saveFromBuffer(webpublicAccessKey, alts.webpublic.data); - logger.info(`web stored: ${webpublicAccessKey}`); - } - - file.storedInternal = true; - file.url = url; - file.thumbnailUrl = thumbnailUrl; - file.webpublicUrl = webpublicUrl; - file.accessKey = accessKey; - file.thumbnailAccessKey = thumbnailAccessKey; - file.webpublicAccessKey = webpublicAccessKey; - file.webpublicType = alts.webpublic?.type ?? null; - file.name = name; - file.type = type; - file.md5 = hash; - file.size = size; - - return await DriveFiles.insert(file).then(x => DriveFiles.findOneByOrFail(x.identifiers[0])); - } -} - -/** - * Generate webpublic, thumbnail, etc - * @param path Path for original - * @param type Content-Type for original - * @param generateWeb Generate webpublic or not - */ -export async function generateAlts(path: string, type: string, generateWeb: boolean) { - if (type.startsWith('video/')) { - try { - const thumbnail = await GenerateVideoThumbnail(path); - return { - webpublic: null, - thumbnail, - }; - } catch (err) { - logger.warn(`GenerateVideoThumbnail failed: ${err}`); - return { - webpublic: null, - thumbnail: null, - }; - } - } - - if (!['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml'].includes(type)) { - logger.debug('web image and thumbnail not created (not an required file)'); - return { - webpublic: null, - thumbnail: null, - }; - } - - let img: sharp.Sharp | null = null; - let satisfyWebpublic: boolean; - - try { - img = sharp(path); - const metadata = await img.metadata(); - const isAnimated = metadata.pages && metadata.pages > 1; - - // skip animated - if (isAnimated) { - return { - webpublic: null, - thumbnail: null, - }; - } - - satisfyWebpublic = !!( - type !== 'image/svg+xml' && type !== 'image/webp' && - !(metadata.exif || metadata.iptc || metadata.xmp || metadata.tifftagPhotoshop) && - metadata.width && metadata.width <= 2048 && - metadata.height && metadata.height <= 2048 - ); - } catch (err) { - logger.warn(`sharp failed: ${err}`); - return { - webpublic: null, - thumbnail: null, - }; - } - - // #region webpublic - let webpublic: IImage | null = null; - - if (generateWeb && !satisfyWebpublic) { - logger.info('creating web image'); - - try { - if (['image/jpeg', 'image/webp'].includes(type)) { - webpublic = await convertSharpToJpeg(img, 2048, 2048); - } else if (['image/png'].includes(type)) { - webpublic = await convertSharpToPng(img, 2048, 2048); - } else if (['image/svg+xml'].includes(type)) { - webpublic = await convertSharpToPng(img, 2048, 2048); - } else { - logger.debug('web image not created (not an required image)'); - } - } catch (err) { - logger.warn('web image not created (an error occured)', err as Error); - } - } else { - if (satisfyWebpublic) logger.info('web image not created (original satisfies webpublic)'); - else logger.info('web image not created (from remote)'); - } - // #endregion webpublic - - // #region thumbnail - let thumbnail: IImage | null = null; - - try { - if (['image/jpeg', 'image/webp', 'image/png', 'image/svg+xml'].includes(type)) { - thumbnail = await convertSharpToWebp(img, 498, 280); - } else { - logger.debug('thumbnail not created (not an required file)'); - } - } catch (err) { - logger.warn('thumbnail not created (an error occured)', err as Error); - } - // #endregion thumbnail - - return { - webpublic, - thumbnail, - }; -} - -/** - * Upload to ObjectStorage - */ -async function upload(key: string, stream: fs.ReadStream | Buffer, type: string, filename?: string) { - if (type === 'image/apng') type = 'image/png'; - if (!FILE_TYPE_BROWSERSAFE.includes(type)) type = 'application/octet-stream'; - - const meta = await fetchMeta(); - - const params = { - Bucket: meta.objectStorageBucket, - Key: key, - Body: stream, - ContentType: type, - CacheControl: 'max-age=31536000, immutable', - } as S3.PutObjectRequest; - - if (filename) params.ContentDisposition = contentDisposition('inline', filename); - if (meta.objectStorageSetPublicRead) params.ACL = 'public-read'; - - const s3 = getS3(meta); - - const upload = s3.upload(params, { - partSize: s3.endpoint.hostname === 'storage.googleapis.com' ? 500 * 1024 * 1024 : 8 * 1024 * 1024, - }); - - const result = await upload.promise(); - if (result) logger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`); -} - -async function deleteOldFile(user: IRemoteUser) { - const q = DriveFiles.createQueryBuilder('file') - .where('file.userId = :userId', { userId: user.id }) - .andWhere('file.isLink = FALSE'); - - if (user.avatarId) { - q.andWhere('file.id != :avatarId', { avatarId: user.avatarId }); - } - - if (user.bannerId) { - q.andWhere('file.id != :bannerId', { bannerId: user.bannerId }); - } - - q.orderBy('file.id', 'ASC'); - - const oldFile = await q.getOne(); - - if (oldFile) { - deleteFile(oldFile, true); - } -} - -type AddFileArgs = { - /** User who wish to add file */ - user: { id: User['id']; host: User['host']; driveCapacityOverrideMb: User['driveCapacityOverrideMb'] } | null; - /** File path */ - path: string; - /** Name */ - name?: string | null; - /** Comment */ - comment?: string | null; - /** Folder ID */ - folderId?: any; - /** If set to true, forcibly upload the file even if there is a file with the same hash. */ - force?: boolean; - /** Do not save file to local */ - isLink?: boolean; - /** URL of source (URLからアップロードされた場合(ローカル/リモート)の元URL) */ - url?: string | null; - /** URL of source (リモートインスタンスのURLからアップロードされた場合の元URL) */ - uri?: string | null; - /** Mark file as sensitive */ - sensitive?: boolean | null; - - requestIp?: string | null; - requestHeaders?: Record | null; -}; - -/** - * Add file to drive - * - */ -export async function addFile({ - user, - path, - name = null, - comment = null, - folderId = null, - force = false, - isLink = false, - url = null, - uri = null, - sensitive = null, - requestIp = null, - requestHeaders = null, -}: AddFileArgs): Promise { - let skipNsfwCheck = false; - const instance = await fetchMeta(); - if (user == null) skipNsfwCheck = true; - if (instance.sensitiveMediaDetection === 'none') skipNsfwCheck = true; - if (user && instance.sensitiveMediaDetection === 'local' && Users.isRemoteUser(user)) skipNsfwCheck = true; - if (user && instance.sensitiveMediaDetection === 'remote' && Users.isLocalUser(user)) skipNsfwCheck = true; - - const info = await getFileInfo(path, { - skipSensitiveDetection: skipNsfwCheck, - sensitiveThreshold: // 感度が高いほどしきい値は低くすることになる - instance.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 0.1 : - instance.sensitiveMediaDetectionSensitivity === 'high' ? 0.3 : - instance.sensitiveMediaDetectionSensitivity === 'low' ? 0.7 : - instance.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0.9 : - 0.5, - sensitiveThresholdForPorn: 0.75, - enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos, - }); - logger.info(`${JSON.stringify(info)}`); - - // 現状 false positive が多すぎて実用に耐えない - //if (info.porn && instance.disallowUploadWhenPredictedAsPorn) { - // throw new IdentifiableError('282f77bf-5816-4f72-9264-aa14d8261a21', 'Detected as porn.'); - //} - - // detect name - const detectedName = name || (info.type.ext ? `untitled.${info.type.ext}` : 'untitled'); - - if (user && !force) { - // Check if there is a file with the same hash - const much = await DriveFiles.findOneBy({ - md5: info.md5, - userId: user.id, - }); - - if (much) { - logger.info(`file with same hash is found: ${much.id}`); - return much; - } - } - - //#region Check drive usage - if (user && !isLink) { - const usage = await DriveFiles.calcDriveUsageOf(user); - const u = await Users.findOneBy({ id: user.id }); - - const instance = await fetchMeta(); - let driveCapacity = 1024 * 1024 * (Users.isLocalUser(user) ? instance.localDriveCapacityMb : instance.remoteDriveCapacityMb); - - if (Users.isLocalUser(user) && u?.driveCapacityOverrideMb != null) { - driveCapacity = 1024 * 1024 * u.driveCapacityOverrideMb; - logger.debug('drive capacity override applied'); - logger.debug(`overrideCap: ${driveCapacity}bytes, usage: ${usage}bytes, u+s: ${usage + info.size}bytes`); - } - - logger.debug(`drive usage is ${usage} (max: ${driveCapacity})`); - - // If usage limit exceeded - if (usage + info.size > driveCapacity) { - if (Users.isLocalUser(user)) { - throw new IdentifiableError('c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6', 'No free space.'); - } else { - // (アバターまたはバナーを含まず)最も古いファイルを削除する - deleteOldFile(await Users.findOneByOrFail({ id: user.id }) as IRemoteUser); - } - } - } - //#endregion - - const fetchFolder = async () => { - if (!folderId) { - return null; - } - - const driveFolder = await DriveFolders.findOneBy({ - id: folderId, - userId: user ? user.id : IsNull(), - }); - - if (driveFolder == null) throw new Error('folder-not-found'); - - return driveFolder; - }; - - const properties: { - width?: number; - height?: number; - orientation?: number; - } = {}; - - if (info.width) { - properties['width'] = info.width; - properties['height'] = info.height; - } - if (info.orientation != null) { - properties['orientation'] = info.orientation; - } - - const profile = user ? await UserProfiles.findOneBy({ userId: user.id }) : null; - - const folder = await fetchFolder(); - - let file = new DriveFile(); - file.id = genId(); - file.createdAt = new Date(); - file.userId = user ? user.id : null; - file.userHost = user ? user.host : null; - file.folderId = folder !== null ? folder.id : null; - file.comment = comment; - file.properties = properties; - file.blurhash = info.blurhash || null; - file.isLink = isLink; - file.requestIp = requestIp; - file.requestHeaders = requestHeaders; - file.maybeSensitive = info.sensitive; - file.maybePorn = info.porn; - file.isSensitive = user - ? Users.isLocalUser(user) && profile!.alwaysMarkNsfw ? true : - (sensitive !== null && sensitive !== undefined) - ? sensitive - : false - : false; - - if (info.sensitive && profile!.autoSensitive) file.isSensitive = true; - if (info.sensitive && instance.setSensitiveFlagAutomatically) file.isSensitive = true; - - if (url !== null) { - file.src = url; - - if (isLink) { - file.url = url; - // ローカルプロキシ用 - file.accessKey = uuid(); - file.thumbnailAccessKey = 'thumbnail-' + uuid(); - file.webpublicAccessKey = 'webpublic-' + uuid(); - } - } - - if (uri !== null) { - file.uri = uri; - } - - if (isLink) { - try { - file.size = 0; - file.md5 = info.md5; - file.name = detectedName; - file.type = info.type.mime; - file.storedInternal = false; - - file = await DriveFiles.insert(file).then(x => DriveFiles.findOneByOrFail(x.identifiers[0])); - } catch (err) { - // duplicate key error (when already registered) - if (isDuplicateKeyValueError(err)) { - logger.info(`already registered ${file.uri}`); - - file = await DriveFiles.findOneBy({ - uri: file.uri!, - userId: user ? user.id : IsNull(), - }) as DriveFile; - } else { - logger.error(err as Error); - throw err; - } - } - } else { - file = await (save(file, path, detectedName, info.type.mime, info.md5, info.size)); - } - - logger.succ(`drive file has been created ${file.id}`); - - if (user) { - DriveFiles.pack(file, { self: true }).then(packedFile => { - // Publish driveFileCreated event - publishMainStream(user.id, 'driveFileCreated', packedFile); - publishDriveStream(user.id, 'fileCreated', packedFile); - }); - } - - // 統計を更新 - driveChart.update(file, true); - perUserDriveChart.update(file, true); - if (file.userHost !== null) { - instanceChart.updateDrive(file, true); - } - - return file; -} diff --git a/packages/backend/src/services/drive/delete-file.ts b/packages/backend/src/services/drive/delete-file.ts deleted file mode 100644 index 4816a3a31..000000000 --- a/packages/backend/src/services/drive/delete-file.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { DriveFile } from '@/models/entities/drive-file.js'; -import { InternalStorage } from './internal-storage.js'; -import { DriveFiles, Instances } from '@/models/index.js'; -import { driveChart, perUserDriveChart, instanceChart } from '@/services/chart/index.js'; -import { createDeleteObjectStorageFileJob } from '@/queue/index.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { getS3 } from './s3.js'; -import { v4 as uuid } from 'uuid'; - -export async function deleteFile(file: DriveFile, isExpired = false) { - if (file.storedInternal) { - InternalStorage.del(file.accessKey!); - - if (file.thumbnailUrl) { - InternalStorage.del(file.thumbnailAccessKey!); - } - - if (file.webpublicUrl) { - InternalStorage.del(file.webpublicAccessKey!); - } - } else if (!file.isLink) { - createDeleteObjectStorageFileJob(file.accessKey!); - - if (file.thumbnailUrl) { - createDeleteObjectStorageFileJob(file.thumbnailAccessKey!); - } - - if (file.webpublicUrl) { - createDeleteObjectStorageFileJob(file.webpublicAccessKey!); - } - } - - postProcess(file, isExpired); -} - -export async function deleteFileSync(file: DriveFile, isExpired = false) { - if (file.storedInternal) { - InternalStorage.del(file.accessKey!); - - if (file.thumbnailUrl) { - InternalStorage.del(file.thumbnailAccessKey!); - } - - if (file.webpublicUrl) { - InternalStorage.del(file.webpublicAccessKey!); - } - } else if (!file.isLink) { - const promises = []; - - promises.push(deleteObjectStorageFile(file.accessKey!)); - - if (file.thumbnailUrl) { - promises.push(deleteObjectStorageFile(file.thumbnailAccessKey!)); - } - - if (file.webpublicUrl) { - promises.push(deleteObjectStorageFile(file.webpublicAccessKey!)); - } - - await Promise.all(promises); - } - - postProcess(file, isExpired); -} - -async function postProcess(file: DriveFile, isExpired = false) { - // リモートファイル期限切れ削除後は直リンクにする - if (isExpired && file.userHost !== null && file.uri != null) { - DriveFiles.update(file.id, { - isLink: true, - url: file.uri, - thumbnailUrl: null, - webpublicUrl: null, - storedInternal: false, - // ローカルプロキシ用 - accessKey: uuid(), - thumbnailAccessKey: 'thumbnail-' + uuid(), - webpublicAccessKey: 'webpublic-' + uuid(), - }); - } else { - DriveFiles.delete(file.id); - } - - // 統計を更新 - driveChart.update(file, false); - perUserDriveChart.update(file, false); - if (file.userHost !== null) { - instanceChart.updateDrive(file, false); - } -} - -export async function deleteObjectStorageFile(key: string) { - const meta = await fetchMeta(); - - const s3 = getS3(meta); - - await s3.deleteObject({ - Bucket: meta.objectStorageBucket!, - Key: key, - }).promise(); -} diff --git a/packages/backend/src/services/drive/generate-video-thumbnail.ts b/packages/backend/src/services/drive/generate-video-thumbnail.ts deleted file mode 100644 index 6e6666481..000000000 --- a/packages/backend/src/services/drive/generate-video-thumbnail.ts +++ /dev/null @@ -1,29 +0,0 @@ -import * as fs from 'node:fs'; -import { createTempDir } from '@/misc/create-temp.js'; -import { IImage, convertToJpeg } from './image-processor.js'; -import FFmpeg from 'fluent-ffmpeg'; - -export async function GenerateVideoThumbnail(source: string): Promise { - const [dir, cleanup] = await createTempDir(); - - try { - await new Promise((res, rej) => { - FFmpeg({ - source, - }) - .on('end', res) - .on('error', rej) - .screenshot({ - folder: dir, - filename: 'out.png', // must have .png extension - count: 1, - timestamps: ['5%'], - }); - }); - - // JPEGに変換 (Webpでもいいが、MastodonはWebpをサポートせず表示できなくなる) - return await convertToJpeg(`${dir}/out.png`, 498, 280); - } finally { - cleanup(); - } -} diff --git a/packages/backend/src/services/drive/image-processor.ts b/packages/backend/src/services/drive/image-processor.ts deleted file mode 100644 index 2c564ea59..000000000 --- a/packages/backend/src/services/drive/image-processor.ts +++ /dev/null @@ -1,87 +0,0 @@ -import sharp from 'sharp'; - -export type IImage = { - data: Buffer; - ext: string | null; - type: string; -}; - -/** - * Convert to JPEG - * with resize, remove metadata, resolve orientation, stop animation - */ -export async function convertToJpeg(path: string, width: number, height: number): Promise { - return convertSharpToJpeg(await sharp(path), width, height); -} - -export async function convertSharpToJpeg(sharp: sharp.Sharp, width: number, height: number): Promise { - const data = await sharp - .resize(width, height, { - fit: 'inside', - withoutEnlargement: true, - }) - .rotate() - .jpeg({ - quality: 85, - progressive: true, - }) - .toBuffer(); - - return { - data, - ext: 'jpg', - type: 'image/jpeg', - }; -} - -/** - * Convert to WebP - * with resize, remove metadata, resolve orientation, stop animation - */ -export async function convertToWebp(path: string, width: number, height: number, quality: number = 85): Promise { - return convertSharpToWebp(await sharp(path), width, height, quality); -} - -export async function convertSharpToWebp(sharp: sharp.Sharp, width: number, height: number, quality: number = 85): Promise { - const data = await sharp - .resize(width, height, { - fit: 'inside', - withoutEnlargement: true, - }) - .rotate() - .webp({ - quality, - }) - .toBuffer(); - - return { - data, - ext: 'webp', - type: 'image/webp', - }; -} - -/** - * Convert to PNG - * with resize, remove metadata, resolve orientation, stop animation - */ -export async function convertToPng(path: string, width: number, height: number): Promise { - return convertSharpToPng(await sharp(path), width, height); -} - -export async function convertSharpToPng(sharp: sharp.Sharp, width: number, height: number): Promise { - const data = await sharp - .resize(width, height, { - fit: 'inside', - withoutEnlargement: true, - }) - .rotate() - .png() - .toBuffer(); - - return { - data, - ext: 'png', - type: 'image/png', - }; -} diff --git a/packages/backend/src/services/drive/internal-storage.ts b/packages/backend/src/services/drive/internal-storage.ts deleted file mode 100644 index 8f76c81ca..000000000 --- a/packages/backend/src/services/drive/internal-storage.ts +++ /dev/null @@ -1,34 +0,0 @@ -import * as fs from 'node:fs'; -import * as Path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; -import config from '@/config/index.js'; - -const _filename = fileURLToPath(import.meta.url); -const _dirname = dirname(_filename); - -export class InternalStorage { - private static readonly path = Path.resolve(_dirname, '../../../../../files'); - - public static resolvePath = (key: string) => Path.resolve(InternalStorage.path, key); - - public static read(key: string) { - return fs.createReadStream(InternalStorage.resolvePath(key)); - } - - public static saveFromPath(key: string, srcPath: string) { - fs.mkdirSync(InternalStorage.path, { recursive: true }); - fs.copyFileSync(srcPath, InternalStorage.resolvePath(key)); - return `${config.url}/files/${key}`; - } - - public static saveFromBuffer(key: string, data: Buffer) { - fs.mkdirSync(InternalStorage.path, { recursive: true }); - fs.writeFileSync(InternalStorage.resolvePath(key), data); - return `${config.url}/files/${key}`; - } - - public static del(key: string) { - fs.unlink(InternalStorage.resolvePath(key), () => {}); - } -} diff --git a/packages/backend/src/services/drive/logger.ts b/packages/backend/src/services/drive/logger.ts deleted file mode 100644 index 917a8317e..000000000 --- a/packages/backend/src/services/drive/logger.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Logger from '../logger.js'; - -export const driveLogger = new Logger('drive', 'blue'); diff --git a/packages/backend/src/services/drive/s3.ts b/packages/backend/src/services/drive/s3.ts deleted file mode 100644 index 80e34be95..000000000 --- a/packages/backend/src/services/drive/s3.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { URL } from 'node:url'; -import S3 from 'aws-sdk/clients/s3.js'; -import { Meta } from '@/models/entities/meta.js'; -import { getAgentByUrl } from '@/misc/fetch.js'; - -export function getS3(meta: Meta) { - const u = meta.objectStorageEndpoint != null - ? `${meta.objectStorageUseSSL ? 'https://' : 'http://'}${meta.objectStorageEndpoint}` - : `${meta.objectStorageUseSSL ? 'https://' : 'http://'}example.net`; - - return new S3({ - endpoint: meta.objectStorageEndpoint || undefined, - accessKeyId: meta.objectStorageAccessKey!, - secretAccessKey: meta.objectStorageSecretKey!, - region: meta.objectStorageRegion || undefined, - sslEnabled: meta.objectStorageUseSSL, - s3ForcePathStyle: !meta.objectStorageEndpoint // AWS with endPoint omitted - ? false - : meta.objectStorageS3ForcePathStyle, - httpOptions: { - agent: getAgentByUrl(new URL(u), !meta.objectStorageUseProxy), - }, - }); -} diff --git a/packages/backend/src/services/drive/upload-from-url.ts b/packages/backend/src/services/drive/upload-from-url.ts deleted file mode 100644 index 3c5e1aa5c..000000000 --- a/packages/backend/src/services/drive/upload-from-url.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { URL } from 'node:url'; -import { User } from '@/models/entities/user.js'; -import { createTemp } from '@/misc/create-temp.js'; -import { downloadUrl } from '@/misc/download-url.js'; -import { DriveFolder } from '@/models/entities/drive-folder.js'; -import { DriveFile } from '@/models/entities/drive-file.js'; -import { DriveFiles } from '@/models/index.js'; -import { driveLogger } from './logger.js'; -import { addFile } from './add-file.js'; - -const logger = driveLogger.createSubLogger('downloader'); - -type Args = { - url: string; - user: { id: User['id']; host: User['host'] } | null; - folderId?: DriveFolder['id'] | null; - uri?: string | null; - sensitive?: boolean; - force?: boolean; - isLink?: boolean; - comment?: string | null; - requestIp?: string | null; - requestHeaders?: Record | null; -}; - -export async function uploadFromUrl({ - url, - user, - folderId = null, - uri = null, - sensitive = false, - force = false, - isLink = false, - comment = null, - requestIp = null, - requestHeaders = null, -}: Args): Promise { - let name = new URL(url).pathname.split('/').pop() || null; - if (name == null || !DriveFiles.validateFileName(name)) { - name = null; - } - - // If the comment is same as the name, skip comment - // (image.name is passed in when receiving attachment) - if (comment !== null && name === comment) { - comment = null; - } - - // Create temp file - const [path, cleanup] = await createTemp(); - - try { - // write content at URL to temp file - await downloadUrl(url, path); - - const driveFile = await addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive, requestIp, requestHeaders }); - logger.succ(`Got: ${driveFile.id}`); - return driveFile!; - } catch (e) { - logger.error(`Failed to create drive file: ${e}`, { - url: url, - e: e, - }); - throw e; - } finally { - cleanup(); - } -} diff --git a/packages/backend/src/services/fetch-instance-metadata.ts b/packages/backend/src/services/fetch-instance-metadata.ts deleted file mode 100644 index ee1245132..000000000 --- a/packages/backend/src/services/fetch-instance-metadata.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { DOMWindow, JSDOM } from 'jsdom'; -import fetch from 'node-fetch'; -import tinycolor from 'tinycolor2'; -import { getJson, getHtml, getAgentByUrl } from '@/misc/fetch.js'; -import { Instance } from '@/models/entities/instance.js'; -import { Instances } from '@/models/index.js'; -import { getFetchInstanceMetadataLock } from '@/misc/app-lock.js'; -import Logger from './logger.js'; -import { URL } from 'node:url'; - -const logger = new Logger('metadata', 'cyan'); - -export async function fetchInstanceMetadata(instance: Instance, force = false): Promise { - const unlock = await getFetchInstanceMetadataLock(instance.host); - - if (!force) { - const _instance = await Instances.findOneBy({ host: instance.host }); - const now = Date.now(); - if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) { - unlock(); - return; - } - } - - logger.info(`Fetching metadata of ${instance.host} ...`); - - try { - const [info, dom, manifest] = await Promise.all([ - fetchNodeinfo(instance).catch(() => null), - fetchDom(instance).catch(() => null), - fetchManifest(instance).catch(() => null), - ]); - - const [favicon, icon, themeColor, name, description] = await Promise.all([ - fetchFaviconUrl(instance, dom).catch(() => null), - fetchIconUrl(instance, dom, manifest).catch(() => null), - getThemeColor(info, dom, manifest).catch(() => null), - getSiteName(info, dom, manifest).catch(() => null), - getDescription(info, dom, manifest).catch(() => null), - ]); - - logger.succ(`Successfuly fetched metadata of ${instance.host}`); - - const updates = { - infoUpdatedAt: new Date(), - } as Record; - - if (info) { - updates.softwareName = info.software?.name.toLowerCase(); - updates.softwareVersion = info.software?.version; - updates.openRegistrations = info.openRegistrations; - updates.maintainerName = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.name || null) : null : null; - updates.maintainerEmail = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.email || null) : null : null; - } - - if (name) updates.name = name; - if (description) updates.description = description; - if (icon || favicon) updates.iconUrl = icon || favicon; - if (favicon) updates.faviconUrl = favicon; - if (themeColor) updates.themeColor = themeColor; - - await Instances.update(instance.id, updates); - - logger.succ(`Successfuly updated metadata of ${instance.host}`); - } catch (e) { - logger.error(`Failed to update metadata of ${instance.host}: ${e}`); - } finally { - unlock(); - } -} - -type NodeInfo = { - openRegistrations?: any; - software?: { - name?: any; - version?: any; - }; - metadata?: { - name?: any; - nodeName?: any; - nodeDescription?: any; - description?: any; - maintainer?: { - name?: any; - email?: any; - }; - }; -}; - -async function fetchNodeinfo(instance: Instance): Promise { - logger.info(`Fetching nodeinfo of ${instance.host} ...`); - - try { - const wellknown = await getJson('https://' + instance.host + '/.well-known/nodeinfo') - .catch(e => { - if (e.statusCode === 404) { - throw 'No nodeinfo provided'; - } else { - throw e.statusCode || e.message; - } - }) as Record; - - if (wellknown.links == null || !Array.isArray(wellknown.links)) { - throw 'No wellknown links'; - } - - const links = wellknown.links as any[]; - - const lnik1_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/1.0'); - const lnik2_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.0'); - const lnik2_1 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.1'); - const link = lnik2_1 || lnik2_0 || lnik1_0; - - if (link == null) { - throw 'No nodeinfo link provided'; - } - - const info = await getJson(link.href) - .catch(e => { - throw e.statusCode || e.message; - }); - - logger.succ(`Successfuly fetched nodeinfo of ${instance.host}`); - - return info as NodeInfo; - } catch (e) { - logger.error(`Failed to fetch nodeinfo of ${instance.host}: ${e}`); - - throw e; - } -} - -async function fetchDom(instance: Instance): Promise { - logger.info(`Fetching HTML of ${instance.host} ...`); - - const url = 'https://' + instance.host; - - const html = await getHtml(url); - - const { window } = new JSDOM(html); - const doc = window.document; - - return doc; -} - -async function fetchManifest(instance: Instance): Promise | null> { - const url = 'https://' + instance.host; - - const manifestUrl = url + '/manifest.json'; - - const manifest = await getJson(manifestUrl) as Record; - - return manifest; -} - -async function fetchFaviconUrl(instance: Instance, doc: DOMWindow['document'] | null): Promise { - const url = 'https://' + instance.host; - - if (doc) { - // https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043 - const href = Array.from(doc.getElementsByTagName('link')).reverse().find(link => link.relList.contains('icon'))?.href; - - if (href) { - return (new URL(href, url)).href; - } - } - - const faviconUrl = url + '/favicon.ico'; - - const favicon = await fetch(faviconUrl, { - // TODO - //timeout: 10000, - agent: getAgentByUrl, - }); - - if (favicon.ok) { - return faviconUrl; - } - - return null; -} - -async function fetchIconUrl(instance: Instance, doc: DOMWindow['document'] | null, manifest: Record | null): Promise { - if (manifest && manifest.icons && manifest.icons.length > 0 && manifest.icons[0].src) { - const url = 'https://' + instance.host; - return (new URL(manifest.icons[0].src, url)).href; - } - - if (doc) { - const url = 'https://' + instance.host; - - // https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043 - const links = Array.from(doc.getElementsByTagName('link')).reverse(); - // https://github.com/misskey-dev/misskey/pull/8220/files/0ec4eba22a914e31b86874f12448f88b3e58dd5a#r796487559 - const href = - [ - links.find(link => link.relList.contains('apple-touch-icon-precomposed'))?.href, - links.find(link => link.relList.contains('apple-touch-icon'))?.href, - links.find(link => link.relList.contains('icon'))?.href, - ] - .find(href => href); - - if (href) { - return (new URL(href, url)).href; - } - } - - return null; -} - -async function getThemeColor(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record | null): Promise { - const themeColor = info?.metadata?.themeColor || doc?.querySelector('meta[name="theme-color"]')?.getAttribute('content') || manifest?.theme_color; - - if (themeColor) { - const color = new tinycolor(themeColor); - if (color.isValid()) return color.toHexString(); - } - - return null; -} - -async function getSiteName(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record | null): Promise { - if (info && info.metadata) { - if (info.metadata.nodeName || info.metadata.name) { - return info.metadata.nodeName || info.metadata.name; - } - } - - if (doc) { - const og = doc.querySelector('meta[property="og:title"]')?.getAttribute('content'); - - if (og) { - return og; - } - } - - if (manifest) { - return manifest?.name || manifest?.short_name; - } - - return null; -} - -async function getDescription(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record | null): Promise { - if (info && info.metadata) { - if (info.metadata.nodeDescription || info.metadata.description) { - return info.metadata.nodeDescription || info.metadata.description; - } - } - - if (doc) { - const meta = doc.querySelector('meta[name="description"]')?.getAttribute('content'); - if (meta) { - return meta; - } - - const og = doc.querySelector('meta[property="og:description"]')?.getAttribute('content'); - if (og) { - return og; - } - } - - if (manifest) { - return manifest?.name || manifest?.short_name; - } - - return null; -} diff --git a/packages/backend/src/services/following/create.ts b/packages/backend/src/services/following/create.ts deleted file mode 100644 index 72c24676b..000000000 --- a/packages/backend/src/services/following/create.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { publishMainStream, publishUserEvent } from '@/services/stream.js'; -import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import renderFollow from '@/remote/activitypub/renderer/follow.js'; -import renderAccept from '@/remote/activitypub/renderer/accept.js'; -import renderReject from '@/remote/activitypub/renderer/reject.js'; -import { deliver } from '@/queue/index.js'; -import createFollowRequest from './requests/create.js'; -import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js'; -import Logger from '../logger.js'; -import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { User } from '@/models/entities/user.js'; -import { Followings, Users, FollowRequests, Blockings, Instances, UserProfiles } from '@/models/index.js'; -import { instanceChart, perUserFollowingChart } from '@/services/chart/index.js'; -import { genId } from '@/misc/gen-id.js'; -import { createNotification } from '../create-notification.js'; -import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; -import { Packed } from '@/misc/schema.js'; -import { getActiveWebhooks } from '@/misc/webhook-cache.js'; -import { webhookDeliver } from '@/queue/index.js'; - -const logger = new Logger('following/create'); - -export async function insertFollowingDoc(followee: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'] }, follower: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'] }) { - if (follower.id === followee.id) return; - - let alreadyFollowed = false; - - await Followings.insert({ - id: genId(), - createdAt: new Date(), - followerId: follower.id, - followeeId: followee.id, - - // 非正規化 - followerHost: follower.host, - followerInbox: Users.isRemoteUser(follower) ? follower.inbox : null, - followerSharedInbox: Users.isRemoteUser(follower) ? follower.sharedInbox : null, - followeeHost: followee.host, - followeeInbox: Users.isRemoteUser(followee) ? followee.inbox : null, - followeeSharedInbox: Users.isRemoteUser(followee) ? followee.sharedInbox : null, - }).catch(e => { - if (isDuplicateKeyValueError(e) && Users.isRemoteUser(follower) && Users.isLocalUser(followee)) { - logger.info(`Insert duplicated ignore. ${follower.id} => ${followee.id}`); - alreadyFollowed = true; - } else { - throw e; - } - }); - - const req = await FollowRequests.findOneBy({ - followeeId: followee.id, - followerId: follower.id, - }); - - if (req) { - await FollowRequests.delete({ - followeeId: followee.id, - followerId: follower.id, - }); - - // 通知を作成 - createNotification(follower.id, 'followRequestAccepted', { - notifierId: followee.id, - }); - } - - if (alreadyFollowed) return; - - //#region Increment counts - await Promise.all([ - Users.increment({ id: follower.id }, 'followingCount', 1), - Users.increment({ id: followee.id }, 'followersCount', 1), - ]); - //#endregion - - //#region Update instance stats - if (Users.isRemoteUser(follower) && Users.isLocalUser(followee)) { - registerOrFetchInstanceDoc(follower.host).then(i => { - Instances.increment({ id: i.id }, 'followingCount', 1); - instanceChart.updateFollowing(i.host, true); - }); - } else if (Users.isLocalUser(follower) && Users.isRemoteUser(followee)) { - registerOrFetchInstanceDoc(followee.host).then(i => { - Instances.increment({ id: i.id }, 'followersCount', 1); - instanceChart.updateFollowers(i.host, true); - }); - } - //#endregion - - perUserFollowingChart.update(follower, followee, true); - - // Publish follow event - if (Users.isLocalUser(follower)) { - Users.pack(followee.id, follower, { - detail: true, - }).then(async packed => { - publishUserEvent(follower.id, 'follow', packed as Packed<"UserDetailedNotMe">); - publishMainStream(follower.id, 'follow', packed as Packed<"UserDetailedNotMe">); - - const webhooks = (await getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('follow')); - for (const webhook of webhooks) { - webhookDeliver(webhook, 'follow', { - user: packed, - }); - } - }); - } - - // Publish followed event - if (Users.isLocalUser(followee)) { - Users.pack(follower.id, followee).then(async packed => { - publishMainStream(followee.id, 'followed', packed); - - const webhooks = (await getActiveWebhooks()).filter(x => x.userId === followee.id && x.on.includes('followed')); - for (const webhook of webhooks) { - webhookDeliver(webhook, 'followed', { - user: packed, - }); - } - }); - - // 通知を作成 - createNotification(followee.id, 'follow', { - notifierId: follower.id, - }); - } -} - -export default async function(_follower: { id: User['id'] }, _followee: { id: User['id'] }, requestId?: string) { - const [follower, followee] = await Promise.all([ - Users.findOneByOrFail({ id: _follower.id }), - Users.findOneByOrFail({ id: _followee.id }), - ]); - - // check blocking - const [blocking, blocked] = await Promise.all([ - Blockings.findOneBy({ - blockerId: follower.id, - blockeeId: followee.id, - }), - Blockings.findOneBy({ - blockerId: followee.id, - blockeeId: follower.id, - }), - ]); - - if (Users.isRemoteUser(follower) && Users.isLocalUser(followee) && blocked) { - // リモートフォローを受けてブロックしていた場合は、エラーにするのではなくRejectを送り返しておしまい。 - const content = renderActivity(renderReject(renderFollow(follower, followee, requestId), followee)); - deliver(followee , content, follower.inbox); - return; - } else if (Users.isRemoteUser(follower) && Users.isLocalUser(followee) && blocking) { - // リモートフォローを受けてブロックされているはずの場合だったら、ブロック解除しておく。 - await Blockings.delete(blocking.id); - } else { - // それ以外は単純に例外 - if (blocking != null) throw new IdentifiableError('710e8fb0-b8c3-4922-be49-d5d93d8e6a6e', 'blocking'); - if (blocked != null) throw new IdentifiableError('3338392a-f764-498d-8855-db939dcf8c48', 'blocked'); - } - - const followeeProfile = await UserProfiles.findOneByOrFail({ userId: followee.id }); - - // フォロー対象が鍵アカウントである or - // フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or - // フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである - // 上記のいずれかに当てはまる場合はすぐフォローせずにフォローリクエストを発行しておく - if (followee.isLocked || (followeeProfile.carefulBot && follower.isBot) || (Users.isLocalUser(follower) && Users.isRemoteUser(followee))) { - let autoAccept = false; - - // 鍵アカウントであっても、既にフォローされていた場合はスルー - const following = await Followings.findOneBy({ - followerId: follower.id, - followeeId: followee.id, - }); - if (following) { - autoAccept = true; - } - - // フォローしているユーザーは自動承認オプション - if (!autoAccept && (Users.isLocalUser(followee) && followeeProfile.autoAcceptFollowed)) { - const followed = await Followings.findOneBy({ - followerId: followee.id, - followeeId: follower.id, - }); - - if (followed) autoAccept = true; - } - - if (!autoAccept) { - await createFollowRequest(follower, followee, requestId); - return; - } - } - - await insertFollowingDoc(followee, follower); - - if (Users.isRemoteUser(follower) && Users.isLocalUser(followee)) { - const content = renderActivity(renderAccept(renderFollow(follower, followee, requestId), followee)); - deliver(followee, content, follower.inbox); - } -} diff --git a/packages/backend/src/services/following/delete.ts b/packages/backend/src/services/following/delete.ts deleted file mode 100644 index 91b5a3d61..000000000 --- a/packages/backend/src/services/following/delete.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { publishMainStream, publishUserEvent } from '@/services/stream.js'; -import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import renderFollow from '@/remote/activitypub/renderer/follow.js'; -import renderUndo from '@/remote/activitypub/renderer/undo.js'; -import renderReject from '@/remote/activitypub/renderer/reject.js'; -import { deliver, webhookDeliver } from '@/queue/index.js'; -import Logger from '../logger.js'; -import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js'; -import { User } from '@/models/entities/user.js'; -import { Followings, Users, Instances } from '@/models/index.js'; -import { instanceChart, perUserFollowingChart } from '@/services/chart/index.js'; -import { getActiveWebhooks } from '@/misc/webhook-cache.js'; - -const logger = new Logger('following/delete'); - -export default async function(follower: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }, followee: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }, silent = false) { - const following = await Followings.findOneBy({ - followerId: follower.id, - followeeId: followee.id, - }); - - if (following == null) { - logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした'); - return; - } - - await Followings.delete(following.id); - - decrementFollowing(follower, followee); - - // Publish unfollow event - if (!silent && Users.isLocalUser(follower)) { - Users.pack(followee.id, follower, { - detail: true, - }).then(async packed => { - publishUserEvent(follower.id, 'unfollow', packed); - publishMainStream(follower.id, 'unfollow', packed); - - const webhooks = (await getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); - for (const webhook of webhooks) { - webhookDeliver(webhook, 'unfollow', { - user: packed, - }); - } - }); - } - - if (Users.isLocalUser(follower) && Users.isRemoteUser(followee)) { - const content = renderActivity(renderUndo(renderFollow(follower, followee), follower)); - deliver(follower, content, followee.inbox); - } - - if (Users.isLocalUser(followee) && Users.isRemoteUser(follower)) { - // local user has null host - const content = renderActivity(renderReject(renderFollow(follower, followee), followee)); - deliver(followee, content, follower.inbox); - } -} - -export async function decrementFollowing(follower: { id: User['id']; host: User['host']; }, followee: { id: User['id']; host: User['host']; }) { - //#region Decrement following / followers counts - await Promise.all([ - Users.decrement({ id: follower.id }, 'followingCount', 1), - Users.decrement({ id: followee.id }, 'followersCount', 1), - ]); - //#endregion - - //#region Update instance stats - if (Users.isRemoteUser(follower) && Users.isLocalUser(followee)) { - registerOrFetchInstanceDoc(follower.host).then(i => { - Instances.decrement({ id: i.id }, 'followingCount', 1); - instanceChart.updateFollowing(i.host, false); - }); - } else if (Users.isLocalUser(follower) && Users.isRemoteUser(followee)) { - registerOrFetchInstanceDoc(followee.host).then(i => { - Instances.decrement({ id: i.id }, 'followersCount', 1); - instanceChart.updateFollowers(i.host, false); - }); - } - //#endregion - - perUserFollowingChart.update(follower, followee, false); -} diff --git a/packages/backend/src/services/following/reject.ts b/packages/backend/src/services/following/reject.ts deleted file mode 100644 index 691fca245..000000000 --- a/packages/backend/src/services/following/reject.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import renderFollow from '@/remote/activitypub/renderer/follow.js'; -import renderReject from '@/remote/activitypub/renderer/reject.js'; -import { deliver, webhookDeliver } from '@/queue/index.js'; -import { publishMainStream, publishUserEvent } from '@/services/stream.js'; -import { User, ILocalUser, IRemoteUser } from '@/models/entities/user.js'; -import { Users, FollowRequests, Followings } from '@/models/index.js'; -import { decrementFollowing } from './delete.js'; -import { getActiveWebhooks } from '@/misc/webhook-cache.js'; - -type Local = ILocalUser | { - id: ILocalUser['id']; - host: ILocalUser['host']; - uri: ILocalUser['uri'] -}; -type Remote = IRemoteUser | { - id: IRemoteUser['id']; - host: IRemoteUser['host']; - uri: IRemoteUser['uri']; - inbox: IRemoteUser['inbox']; -}; -type Both = Local | Remote; - -/** - * API following/request/reject - */ -export async function rejectFollowRequest(user: Local, follower: Both) { - if (Users.isRemoteUser(follower)) { - deliverReject(user, follower); - } - - await removeFollowRequest(user, follower); - - if (Users.isLocalUser(follower)) { - publishUnfollow(user, follower); - } -} - -/** - * API following/reject - */ -export async function rejectFollow(user: Local, follower: Both) { - if (Users.isRemoteUser(follower)) { - deliverReject(user, follower); - } - - await removeFollow(user, follower); - - if (Users.isLocalUser(follower)) { - publishUnfollow(user, follower); - } -} - -/** - * AP Reject/Follow - */ -export async function remoteReject(actor: Remote, follower: Local) { - await removeFollowRequest(actor, follower); - await removeFollow(actor, follower); - publishUnfollow(actor, follower); -} - -/** - * Remove follow request record - */ -async function removeFollowRequest(followee: Both, follower: Both) { - const request = await FollowRequests.findOneBy({ - followeeId: followee.id, - followerId: follower.id, - }); - - if (!request) return; - - await FollowRequests.delete(request.id); -} - -/** - * Remove follow record - */ -async function removeFollow(followee: Both, follower: Both) { - const following = await Followings.findOneBy({ - followeeId: followee.id, - followerId: follower.id, - }); - - if (!following) return; - - await Followings.delete(following.id); - decrementFollowing(follower, followee); -} - -/** - * Deliver Reject to remote - */ -async function deliverReject(followee: Local, follower: Remote) { - const request = await FollowRequests.findOneBy({ - followeeId: followee.id, - followerId: follower.id, - }); - - const content = renderActivity(renderReject(renderFollow(follower, followee, request?.requestId || undefined), followee)); - deliver(followee, content, follower.inbox); -} - -/** - * Publish unfollow to local - */ -async function publishUnfollow(followee: Both, follower: Local) { - const packedFollowee = await Users.pack(followee.id, follower, { - detail: true, - }); - - publishUserEvent(follower.id, 'unfollow', packedFollowee); - publishMainStream(follower.id, 'unfollow', packedFollowee); - - const webhooks = (await getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); - for (const webhook of webhooks) { - webhookDeliver(webhook, 'unfollow', { - user: packedFollowee, - }); - } -} diff --git a/packages/backend/src/services/following/requests/accept-all.ts b/packages/backend/src/services/following/requests/accept-all.ts deleted file mode 100644 index 5fbb549e0..000000000 --- a/packages/backend/src/services/following/requests/accept-all.ts +++ /dev/null @@ -1,18 +0,0 @@ -import accept from './accept.js'; -import { User } from '@/models/entities/user.js'; -import { FollowRequests, Users } from '@/models/index.js'; - -/** - * 指定したユーザー宛てのフォローリクエストをすべて承認 - * @param user ユーザー - */ -export default async function(user: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }) { - const requests = await FollowRequests.findBy({ - followeeId: user.id, - }); - - for (const request of requests) { - const follower = await Users.findOneByOrFail({ id: request.followerId }); - accept(user, follower); - } -} diff --git a/packages/backend/src/services/following/requests/accept.ts b/packages/backend/src/services/following/requests/accept.ts deleted file mode 100644 index 20829f70c..000000000 --- a/packages/backend/src/services/following/requests/accept.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import renderFollow from '@/remote/activitypub/renderer/follow.js'; -import renderAccept from '@/remote/activitypub/renderer/accept.js'; -import { deliver } from '@/queue/index.js'; -import { publishMainStream } from '@/services/stream.js'; -import { insertFollowingDoc } from '../create.js'; -import { User, ILocalUser, CacheableUser } from '@/models/entities/user.js'; -import { FollowRequests, Users } from '@/models/index.js'; -import { IdentifiableError } from '@/misc/identifiable-error.js'; - -export default async function(followee: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }, follower: CacheableUser) { - const request = await FollowRequests.findOneBy({ - followeeId: followee.id, - followerId: follower.id, - }); - - if (request == null) { - throw new IdentifiableError('8884c2dd-5795-4ac9-b27e-6a01d38190f9', 'No follow request.'); - } - - await insertFollowingDoc(followee, follower); - - if (Users.isRemoteUser(follower) && Users.isLocalUser(followee)) { - const content = renderActivity(renderAccept(renderFollow(follower, followee, request.requestId!), followee)); - deliver(followee, content, follower.inbox); - } - - Users.pack(followee.id, followee, { - detail: true, - }).then(packed => publishMainStream(followee.id, 'meUpdated', packed)); -} diff --git a/packages/backend/src/services/following/requests/cancel.ts b/packages/backend/src/services/following/requests/cancel.ts deleted file mode 100644 index 56531fa1f..000000000 --- a/packages/backend/src/services/following/requests/cancel.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import renderFollow from '@/remote/activitypub/renderer/follow.js'; -import renderUndo from '@/remote/activitypub/renderer/undo.js'; -import { deliver } from '@/queue/index.js'; -import { publishMainStream } from '@/services/stream.js'; -import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { User, ILocalUser } from '@/models/entities/user.js'; -import { Users, FollowRequests } from '@/models/index.js'; - -export default async function(followee: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox'] }, follower: { id: User['id']; host: User['host']; uri: User['host'] }) { - if (Users.isRemoteUser(followee)) { - const content = renderActivity(renderUndo(renderFollow(follower, followee), follower)); - - if (Users.isLocalUser(follower)) { // 本来このチェックは不要だけどTSに怒られるので - deliver(follower, content, followee.inbox); - } - } - - const request = await FollowRequests.findOneBy({ - followeeId: followee.id, - followerId: follower.id, - }); - - if (request == null) { - throw new IdentifiableError('17447091-ce07-46dd-b331-c1fd4f15b1e7', 'request not found'); - } - - await FollowRequests.delete({ - followeeId: followee.id, - followerId: follower.id, - }); - - Users.pack(followee.id, followee, { - detail: true, - }).then(packed => publishMainStream(followee.id, 'meUpdated', packed)); -} diff --git a/packages/backend/src/services/following/requests/create.ts b/packages/backend/src/services/following/requests/create.ts deleted file mode 100644 index bda2f8f92..000000000 --- a/packages/backend/src/services/following/requests/create.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { publishMainStream } from '@/services/stream.js'; -import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import renderFollow from '@/remote/activitypub/renderer/follow.js'; -import { deliver } from '@/queue/index.js'; -import { User } from '@/models/entities/user.js'; -import { Blockings, FollowRequests, Users } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; -import { createNotification } from '../../create-notification.js'; - -export default async function(follower: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }, followee: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }, requestId?: string) { - if (follower.id === followee.id) return; - - // check blocking - const [blocking, blocked] = await Promise.all([ - Blockings.findOneBy({ - blockerId: follower.id, - blockeeId: followee.id, - }), - Blockings.findOneBy({ - blockerId: followee.id, - blockeeId: follower.id, - }), - ]); - - if (blocking != null) throw new Error('blocking'); - if (blocked != null) throw new Error('blocked'); - - const followRequest = await FollowRequests.insert({ - id: genId(), - createdAt: new Date(), - followerId: follower.id, - followeeId: followee.id, - requestId, - - // 非正規化 - followerHost: follower.host, - followerInbox: Users.isRemoteUser(follower) ? follower.inbox : undefined, - followerSharedInbox: Users.isRemoteUser(follower) ? follower.sharedInbox : undefined, - followeeHost: followee.host, - followeeInbox: Users.isRemoteUser(followee) ? followee.inbox : undefined, - followeeSharedInbox: Users.isRemoteUser(followee) ? followee.sharedInbox : undefined, - }).then(x => FollowRequests.findOneByOrFail(x.identifiers[0])); - - // Publish receiveRequest event - if (Users.isLocalUser(followee)) { - Users.pack(follower.id, followee).then(packed => publishMainStream(followee.id, 'receiveFollowRequest', packed)); - - Users.pack(followee.id, followee, { - detail: true, - }).then(packed => publishMainStream(followee.id, 'meUpdated', packed)); - - // 通知を作成 - createNotification(followee.id, 'receiveFollowRequest', { - notifierId: follower.id, - followRequestId: followRequest.id, - }); - } - - if (Users.isLocalUser(follower) && Users.isRemoteUser(followee)) { - const content = renderActivity(renderFollow(follower, followee)); - deliver(follower, content, followee.inbox); - } -} diff --git a/packages/backend/src/services/i/pin.ts b/packages/backend/src/services/i/pin.ts deleted file mode 100644 index f35392a34..000000000 --- a/packages/backend/src/services/i/pin.ts +++ /dev/null @@ -1,92 +0,0 @@ -import config from '@/config/index.js'; -import renderAdd from '@/remote/activitypub/renderer/add.js'; -import renderRemove from '@/remote/activitypub/renderer/remove.js'; -import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { User } from '@/models/entities/user.js'; -import { Note } from '@/models/entities/note.js'; -import { Notes, UserNotePinings, Users } from '@/models/index.js'; -import { UserNotePining } from '@/models/entities/user-note-pining.js'; -import { genId } from '@/misc/gen-id.js'; -import { deliverToFollowers } from '@/remote/activitypub/deliver-manager.js'; -import { deliverToRelays } from '../relay.js'; - -/** - * 指定した投稿をピン留めします - * @param user - * @param noteId - */ -export async function addPinned(user: { id: User['id']; host: User['host']; }, noteId: Note['id']) { - // Fetch pinee - const note = await Notes.findOneBy({ - id: noteId, - userId: user.id, - }); - - if (note == null) { - throw new IdentifiableError('70c4e51f-5bea-449c-a030-53bee3cce202', 'No such note.'); - } - - const pinings = await UserNotePinings.findBy({ userId: user.id }); - - if (pinings.length >= 5) { - throw new IdentifiableError('15a018eb-58e5-4da1-93be-330fcc5e4e1a', 'You can not pin notes any more.'); - } - - if (pinings.some(pining => pining.noteId === note.id)) { - throw new IdentifiableError('23f0cf4e-59a3-4276-a91d-61a5891c1514', 'That note has already been pinned.'); - } - - await UserNotePinings.insert({ - id: genId(), - createdAt: new Date(), - userId: user.id, - noteId: note.id, - } as UserNotePining); - - // Deliver to remote followers - if (Users.isLocalUser(user)) { - deliverPinnedChange(user.id, note.id, true); - } -} - -/** - * 指定した投稿のピン留めを解除します - * @param user - * @param noteId - */ -export async function removePinned(user: { id: User['id']; host: User['host']; }, noteId: Note['id']) { - // Fetch unpinee - const note = await Notes.findOneBy({ - id: noteId, - userId: user.id, - }); - - if (note == null) { - throw new IdentifiableError('b302d4cf-c050-400a-bbb3-be208681f40c', 'No such note.'); - } - - UserNotePinings.delete({ - userId: user.id, - noteId: note.id, - }); - - // Deliver to remote followers - if (Users.isLocalUser(user)) { - deliverPinnedChange(user.id, noteId, false); - } -} - -export async function deliverPinnedChange(userId: User['id'], noteId: Note['id'], isAddition: boolean) { - const user = await Users.findOneBy({ id: userId }); - if (user == null) throw new Error('user not found'); - - if (!Users.isLocalUser(user)) return; - - const target = `${config.url}/users/${user.id}/collections/featured`; - const item = `${config.url}/notes/${noteId}`; - const content = renderActivity(isAddition ? renderAdd(user, target, item) : renderRemove(user, target, item)); - - deliverToFollowers(user, content); - deliverToRelays(user, content); -} diff --git a/packages/backend/src/services/i/update.ts b/packages/backend/src/services/i/update.ts deleted file mode 100644 index 27bd38bd3..000000000 --- a/packages/backend/src/services/i/update.ts +++ /dev/null @@ -1,19 +0,0 @@ -import renderUpdate from '@/remote/activitypub/renderer/update.js'; -import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import { Users } from '@/models/index.js'; -import { User } from '@/models/entities/user.js'; -import { renderPerson } from '@/remote/activitypub/renderer/person.js'; -import { deliverToFollowers } from '@/remote/activitypub/deliver-manager.js'; -import { deliverToRelays } from '../relay.js'; - -export async function publishToFollowers(userId: User['id']) { - const user = await Users.findOneBy({ id: userId }); - if (user == null) throw new Error('user not found'); - - // フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信 - if (Users.isLocalUser(user)) { - const content = renderActivity(renderUpdate(await renderPerson(user), user)); - deliverToFollowers(user, content); - deliverToRelays(user, content); - } -} diff --git a/packages/backend/src/services/insert-moderation-log.ts b/packages/backend/src/services/insert-moderation-log.ts deleted file mode 100644 index 0a7c472d8..000000000 --- a/packages/backend/src/services/insert-moderation-log.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ModerationLogs } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; -import { User } from '@/models/entities/user.js'; - -export async function insertModerationLog(moderator: { id: User['id'] }, type: string, info?: Record) { - await ModerationLogs.insert({ - id: genId(), - createdAt: new Date(), - userId: moderator.id, - type: type, - info: info || {}, - }); -} diff --git a/packages/backend/src/services/instance-actor.ts b/packages/backend/src/services/instance-actor.ts deleted file mode 100644 index bddd0355a..000000000 --- a/packages/backend/src/services/instance-actor.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { createSystemUser } from './create-system-user.js'; -import { ILocalUser } from '@/models/entities/user.js'; -import { Users } from '@/models/index.js'; -import { Cache } from '@/misc/cache.js'; -import { IsNull } from 'typeorm'; - -const ACTOR_USERNAME = 'instance.actor' as const; - -const cache = new Cache(Infinity); - -export async function getInstanceActor(): Promise { - const cached = cache.get(null); - if (cached) return cached; - - const user = await Users.findOneBy({ - host: IsNull(), - username: ACTOR_USERNAME, - }) as ILocalUser | undefined; - - if (user) { - cache.set(null, user); - return user; - } else { - const created = await createSystemUser(ACTOR_USERNAME) as ILocalUser; - cache.set(null, created); - return created; - } -} diff --git a/packages/backend/src/services/messages/create.ts b/packages/backend/src/services/messages/create.ts deleted file mode 100644 index e6b320492..000000000 --- a/packages/backend/src/services/messages/create.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { CacheableUser, User } from '@/models/entities/user.js'; -import { UserGroup } from '@/models/entities/user-group.js'; -import { DriveFile } from '@/models/entities/drive-file.js'; -import { MessagingMessages, UserGroupJoinings, Mutings, Users } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; -import { MessagingMessage } from '@/models/entities/messaging-message.js'; -import { publishMessagingStream, publishMessagingIndexStream, publishMainStream, publishGroupMessagingStream } from '@/services/stream.js'; -import { pushNotification } from '@/services/push-notification.js'; -import { Not } from 'typeorm'; -import { Note } from '@/models/entities/note.js'; -import renderNote from '@/remote/activitypub/renderer/note.js'; -import renderCreate from '@/remote/activitypub/renderer/create.js'; -import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import { deliver } from '@/queue/index.js'; - -export async function createMessage(user: { id: User['id']; host: User['host']; }, recipientUser: CacheableUser | undefined, recipientGroup: UserGroup | undefined, text: string | null | undefined, file: DriveFile | null, uri?: string) { - const message = { - id: genId(), - createdAt: new Date(), - fileId: file ? file.id : null, - recipientId: recipientUser ? recipientUser.id : null, - groupId: recipientGroup ? recipientGroup.id : null, - text: text ? text.trim() : null, - userId: user.id, - isRead: false, - reads: [] as any[], - uri, - } as MessagingMessage; - - await MessagingMessages.insert(message); - - const messageObj = await MessagingMessages.pack(message); - - if (recipientUser) { - if (Users.isLocalUser(user)) { - // 自分のストリーム - publishMessagingStream(message.userId, recipientUser.id, 'message', messageObj); - publishMessagingIndexStream(message.userId, 'message', messageObj); - publishMainStream(message.userId, 'messagingMessage', messageObj); - } - - if (Users.isLocalUser(recipientUser)) { - // 相手のストリーム - publishMessagingStream(recipientUser.id, message.userId, 'message', messageObj); - publishMessagingIndexStream(recipientUser.id, 'message', messageObj); - publishMainStream(recipientUser.id, 'messagingMessage', messageObj); - } - } else if (recipientGroup) { - // グループのストリーム - publishGroupMessagingStream(recipientGroup.id, 'message', messageObj); - - // メンバーのストリーム - const joinings = await UserGroupJoinings.findBy({ userGroupId: recipientGroup.id }); - for (const joining of joinings) { - publishMessagingIndexStream(joining.userId, 'message', messageObj); - publishMainStream(joining.userId, 'messagingMessage', messageObj); - } - } - - // 2秒経っても(今回作成した)メッセージが既読にならなかったら「未読のメッセージがありますよ」イベントを発行する - setTimeout(async () => { - const freshMessage = await MessagingMessages.findOneBy({ id: message.id }); - if (freshMessage == null) return; // メッセージが削除されている場合もある - - if (recipientUser && Users.isLocalUser(recipientUser)) { - if (freshMessage.isRead) return; // 既読 - - //#region ただしミュートされているなら発行しない - const mute = await Mutings.findBy({ - muterId: recipientUser.id, - }); - if (mute.map(m => m.muteeId).includes(user.id)) return; - //#endregion - - publishMainStream(recipientUser.id, 'unreadMessagingMessage', messageObj); - pushNotification(recipientUser.id, 'unreadMessagingMessage', messageObj); - } else if (recipientGroup) { - const joinings = await UserGroupJoinings.findBy({ userGroupId: recipientGroup.id, userId: Not(user.id) }); - for (const joining of joinings) { - if (freshMessage.reads.includes(joining.userId)) return; // 既読 - publishMainStream(joining.userId, 'unreadMessagingMessage', messageObj); - pushNotification(joining.userId, 'unreadMessagingMessage', messageObj); - } - } - }, 2000); - - if (recipientUser && Users.isLocalUser(user) && Users.isRemoteUser(recipientUser)) { - const note = { - id: message.id, - createdAt: message.createdAt, - fileIds: message.fileId ? [ message.fileId ] : [], - text: message.text, - userId: message.userId, - visibility: 'specified', - mentions: [ recipientUser ].map(u => u.id), - mentionedRemoteUsers: JSON.stringify([ recipientUser ].map(u => ({ - uri: u.uri, - username: u.username, - host: u.host, - }))), - } as Note; - - const activity = renderActivity(renderCreate(await renderNote(note, false, true), note)); - - deliver(user, activity, recipientUser.inbox); - } - return messageObj; -} diff --git a/packages/backend/src/services/messages/delete.ts b/packages/backend/src/services/messages/delete.ts deleted file mode 100644 index 1e7ce1981..000000000 --- a/packages/backend/src/services/messages/delete.ts +++ /dev/null @@ -1,30 +0,0 @@ -import config from '@/config/index.js'; -import { MessagingMessages, Users } from '@/models/index.js'; -import { MessagingMessage } from '@/models/entities/messaging-message.js'; -import { publishGroupMessagingStream, publishMessagingStream } from '@/services/stream.js'; -import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import renderDelete from '@/remote/activitypub/renderer/delete.js'; -import renderTombstone from '@/remote/activitypub/renderer/tombstone.js'; -import { deliver } from '@/queue/index.js'; - -export async function deleteMessage(message: MessagingMessage) { - await MessagingMessages.delete(message.id); - postDeleteMessage(message); -} - -async function postDeleteMessage(message: MessagingMessage) { - if (message.recipientId) { - const user = await Users.findOneByOrFail({ id: message.userId }); - const recipient = await Users.findOneByOrFail({ id: message.recipientId }); - - if (Users.isLocalUser(user)) publishMessagingStream(message.userId, message.recipientId, 'deleted', message.id); - if (Users.isLocalUser(recipient)) publishMessagingStream(message.recipientId, message.userId, 'deleted', message.id); - - if (Users.isLocalUser(user) && Users.isRemoteUser(recipient)) { - const activity = renderActivity(renderDelete(renderTombstone(`${config.url}/notes/${message.id}`), user)); - deliver(user, activity, recipient.inbox); - } - } else if (message.groupId) { - publishGroupMessagingStream(message.groupId, 'deleted', message.id); - } -} diff --git a/packages/backend/src/services/note/create.ts b/packages/backend/src/services/note/create.ts deleted file mode 100644 index e2bf9d5b5..000000000 --- a/packages/backend/src/services/note/create.ts +++ /dev/null @@ -1,692 +0,0 @@ -import * as mfm from 'mfm-js'; -import es from '../../db/elasticsearch.js'; -import { publishMainStream, publishNotesStream } from '@/services/stream.js'; -import DeliverManager from '@/remote/activitypub/deliver-manager.js'; -import renderNote from '@/remote/activitypub/renderer/note.js'; -import renderCreate from '@/remote/activitypub/renderer/create.js'; -import renderAnnounce from '@/remote/activitypub/renderer/announce.js'; -import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import { resolveUser } from '@/remote/resolve-user.js'; -import config from '@/config/index.js'; -import { updateHashtags } from '../update-hashtag.js'; -import { concat } from '@/prelude/array.js'; -import { insertNoteUnread } from '@/services/note/unread.js'; -import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js'; -import { extractMentions } from '@/misc/extract-mentions.js'; -import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; -import { extractHashtags } from '@/misc/extract-hashtags.js'; -import { Note, IMentionedRemoteUsers } from '@/models/entities/note.js'; -import { Mutings, Users, NoteWatchings, Notes, Instances, UserProfiles, Antennas, Followings, MutedNotes, Channels, ChannelFollowings, Blockings, NoteThreadMutings } from '@/models/index.js'; -import { DriveFile } from '@/models/entities/drive-file.js'; -import { App } from '@/models/entities/app.js'; -import { Not, In } from 'typeorm'; -import { User, ILocalUser, IRemoteUser } from '@/models/entities/user.js'; -import { genId } from '@/misc/gen-id.js'; -import { notesChart, perUserNotesChart, activeUsersChart, instanceChart } from '@/services/chart/index.js'; -import { Poll, IPoll } from '@/models/entities/poll.js'; -import { createNotification } from '../create-notification.js'; -import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; -import { checkHitAntenna } from '@/misc/check-hit-antenna.js'; -import { checkWordMute } from '@/misc/check-word-mute.js'; -import { addNoteToAntenna } from '../add-note-to-antenna.js'; -import { countSameRenotes } from '@/misc/count-same-renotes.js'; -import { deliverToRelays } from '../relay.js'; -import { Channel } from '@/models/entities/channel.js'; -import { normalizeForSearch } from '@/misc/normalize-for-search.js'; -import { getAntennas } from '@/misc/antenna-cache.js'; -import { endedPollNotificationQueue } from '@/queue/queues.js'; -import { webhookDeliver } from '@/queue/index.js'; -import { Cache } from '@/misc/cache.js'; -import { UserProfile } from '@/models/entities/user-profile.js'; -import { db } from '@/db/postgre.js'; -import { getActiveWebhooks } from '@/misc/webhook-cache.js'; - -const mutedWordsCache = new Cache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5); - -type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; - -class NotificationManager { - private notifier: { id: User['id']; }; - private note: Note; - private queue: { - target: ILocalUser['id']; - reason: NotificationType; - }[]; - - constructor(notifier: { id: User['id']; }, note: Note) { - this.notifier = notifier; - this.note = note; - this.queue = []; - } - - public push(notifiee: ILocalUser['id'], reason: NotificationType) { - // 自分自身へは通知しない - if (this.notifier.id === notifiee) return; - - const exist = this.queue.find(x => x.target === notifiee); - - if (exist) { - // 「メンションされているかつ返信されている」場合は、メンションとしての通知ではなく返信としての通知にする - if (reason !== 'mention') { - exist.reason = reason; - } - } else { - this.queue.push({ - reason: reason, - target: notifiee, - }); - } - } - - public async deliver() { - for (const x of this.queue) { - // ミュート情報を取得 - const mentioneeMutes = await Mutings.findBy({ - muterId: x.target, - }); - - const mentioneesMutedUserIds = mentioneeMutes.map(m => m.muteeId); - - // 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する - if (!mentioneesMutedUserIds.includes(this.notifier.id)) { - createNotification(x.target, x.reason, { - notifierId: this.notifier.id, - noteId: this.note.id, - }); - } - } - } -} - -type MinimumUser = { - id: User['id']; - host: User['host']; - username: User['username']; - uri: User['uri']; -}; - -type Option = { - createdAt?: Date | null; - name?: string | null; - text?: string | null; - reply?: Note | null; - renote?: Note | null; - files?: DriveFile[] | null; - poll?: IPoll | null; - localOnly?: boolean | null; - cw?: string | null; - visibility?: string; - visibleUsers?: MinimumUser[] | null; - channel?: Channel | null; - apMentions?: MinimumUser[] | null; - apHashtags?: string[] | null; - apEmojis?: string[] | null; - uri?: string | null; - url?: string | null; - app?: App | null; -}; - -export default async (user: { id: User['id']; username: User['username']; host: User['host']; isSilenced: User['isSilenced']; createdAt: User['createdAt']; }, data: Option, silent = false) => new Promise(async (res, rej) => { - // チャンネル外にリプライしたら対象のスコープに合わせる - // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) - if (data.reply && data.channel && data.reply.channelId !== data.channel.id) { - if (data.reply.channelId) { - data.channel = await Channels.findOneBy({ id: data.reply.channelId }); - } else { - data.channel = null; - } - } - - // チャンネル内にリプライしたら対象のスコープに合わせる - // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) - if (data.reply && (data.channel == null) && data.reply.channelId) { - data.channel = await Channels.findOneBy({ id: data.reply.channelId }); - } - - if (data.createdAt == null) data.createdAt = new Date(); - if (data.visibility == null) data.visibility = 'public'; - if (data.localOnly == null) data.localOnly = false; - if (data.channel != null) data.visibility = 'public'; - if (data.channel != null) data.visibleUsers = []; - if (data.channel != null) data.localOnly = true; - - // サイレンス - if (user.isSilenced && data.visibility === 'public' && data.channel == null) { - data.visibility = 'home'; - } - - // Renote対象が「ホームまたは全体」以外の公開範囲ならreject - if (data.renote && data.renote.visibility !== 'public' && data.renote.visibility !== 'home' && data.renote.userId !== user.id) { - return rej('Renote target is not public or home'); - } - - // Renote対象がpublicではないならhomeにする - if (data.renote && data.renote.visibility !== 'public' && data.visibility === 'public') { - data.visibility = 'home'; - } - - // Renote対象がfollowersならfollowersにする - if (data.renote && data.renote.visibility === 'followers') { - data.visibility = 'followers'; - } - - // 返信対象がpublicではないならhomeにする - if (data.reply && data.reply.visibility !== 'public' && data.visibility === 'public') { - data.visibility = 'home'; - } - - // ローカルのみをRenoteしたらローカルのみにする - if (data.renote && data.renote.localOnly && data.channel == null) { - data.localOnly = true; - } - - // ローカルのみにリプライしたらローカルのみにする - if (data.reply && data.reply.localOnly && data.channel == null) { - data.localOnly = true; - } - - if (data.text) { - data.text = data.text.trim(); - } else { - data.text = null; - } - - let tags = data.apHashtags; - let emojis = data.apEmojis; - let mentionedUsers = data.apMentions; - - // Parse MFM if needed - if (!tags || !emojis || !mentionedUsers) { - 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); - - mentionedUsers = data.apMentions || await extractMentionedUsers(user, combinedTokens); - } - - 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)) { - mentionedUsers.push(await Users.findOneByOrFail({ id: data.reply!.userId })); - } - - if (data.visibility === 'specified') { - if (data.visibleUsers == null) throw new Error('invalid param'); - - for (const u of data.visibleUsers) { - if (!mentionedUsers.some(x => x.id === u.id)) { - mentionedUsers.push(u); - } - } - - if (data.reply && !data.visibleUsers.some(x => x.id === data.reply!.userId)) { - data.visibleUsers.push(await Users.findOneByOrFail({ id: data.reply!.userId })); - } - } - - const note = await insertNote(user, data, tags, emojis, mentionedUsers); - - res(note); - - // 統計を更新 - notesChart.update(note, true); - perUserNotesChart.update(user, note, true); - - // Register host - if (Users.isRemoteUser(user)) { - registerOrFetchInstanceDoc(user.host).then(i => { - Instances.increment({ id: i.id }, 'notesCount', 1); - instanceChart.updateNote(i.host, note, true); - }); - } - - // ハッシュタグ更新 - if (data.visibility === 'public' || data.visibility === 'home') { - updateHashtags(user, tags); - } - - // Increment notes count (user) - incNotesCountOfUser(user); - - // Word mute - mutedWordsCache.fetch(null, () => UserProfiles.find({ - where: { - enableWordMute: true, - }, - select: ['userId', 'mutedWords'], - })).then(us => { - for (const u of us) { - checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => { - if (shouldMute) { - MutedNotes.insert({ - id: genId(), - userId: u.userId, - noteId: note.id, - reason: 'word', - }); - } - }); - } - }); - - // Antenna - for (const antenna of (await getAntennas())) { - checkHitAntenna(antenna, note, user).then(hit => { - if (hit) { - addNoteToAntenna(antenna, note, user); - } - }); - } - - // Channel - if (note.channelId) { - ChannelFollowings.findBy({ followeeId: note.channelId }).then(followings => { - for (const following of followings) { - insertNoteUnread(following.followerId, note, { - isSpecified: false, - isMentioned: false, - }); - } - }); - } - - if (data.reply) { - saveReply(data.reply, note); - } - - // この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき - if (data.renote && (await countSameRenotes(user.id, data.renote.id, note.id) === 0)) { - incRenoteCount(data.renote); - } - - if (data.poll && data.poll.expiresAt) { - const delay = data.poll.expiresAt.getTime() - Date.now(); - endedPollNotificationQueue.add({ - noteId: note.id, - }, { - delay, - removeOnComplete: true, - }); - } - - if (!silent) { - if (Users.isLocalUser(user)) activeUsersChart.write(user); - - // 未読通知を作成 - if (data.visibility === 'specified') { - if (data.visibleUsers == null) throw new Error('invalid param'); - - for (const u of data.visibleUsers) { - // ローカルユーザーのみ - if (!Users.isLocalUser(u)) continue; - - insertNoteUnread(u.id, note, { - isSpecified: true, - isMentioned: false, - }); - } - } else { - for (const u of mentionedUsers) { - // ローカルユーザーのみ - if (!Users.isLocalUser(u)) continue; - - insertNoteUnread(u.id, note, { - isSpecified: false, - isMentioned: true, - }); - } - } - - // Pack the note - const noteObj = await Notes.pack(note); - - publishNotesStream(noteObj); - - getActiveWebhooks().then(webhooks => { - webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note')); - for (const webhook of webhooks) { - webhookDeliver(webhook, 'note', { - note: noteObj, - }); - } - }); - - const nm = new NotificationManager(user, note); - const nmRelatedPromises = []; - - await createMentionedEvents(mentionedUsers, note, nm); - - // If has in reply to note - if (data.reply) { - // Fetch watchers - nmRelatedPromises.push(notifyToWatchersOfReplyee(data.reply, user, nm)); - - // 通知 - if (data.reply.userHost === null) { - const threadMuted = await NoteThreadMutings.findOneBy({ - userId: data.reply.userId, - threadId: data.reply.threadId || data.reply.id, - }); - - if (!threadMuted) { - nm.push(data.reply.userId, 'reply'); - publishMainStream(data.reply.userId, 'reply', noteObj); - - const webhooks = (await getActiveWebhooks()).filter(x => x.userId === data.reply!.userId && x.on.includes('reply')); - for (const webhook of webhooks) { - webhookDeliver(webhook, 'reply', { - note: noteObj, - }); - } - } - } - } - - // If it is renote - if (data.renote) { - const type = data.text ? 'quote' : 'renote'; - - // Notify - if (data.renote.userHost === null) { - nm.push(data.renote.userId, type); - } - - // Fetch watchers - nmRelatedPromises.push(notifyToWatchersOfRenotee(data.renote, user, nm, type)); - - // Publish event - if ((user.id !== data.renote.userId) && data.renote.userHost === null) { - publishMainStream(data.renote.userId, 'renote', noteObj); - - const webhooks = (await getActiveWebhooks()).filter(x => x.userId === data.renote!.userId && x.on.includes('renote')); - for (const webhook of webhooks) { - webhookDeliver(webhook, 'renote', { - note: noteObj, - }); - } - } - } - - Promise.all(nmRelatedPromises).then(() => { - nm.deliver(); - }); - - //#region AP deliver - if (Users.isLocalUser(user)) { - (async () => { - const noteActivity = await renderNoteOrRenoteActivity(data, note); - const dm = new DeliverManager(user, noteActivity); - - // メンションされたリモートユーザーに配送 - for (const u of mentionedUsers.filter(u => Users.isRemoteUser(u))) { - dm.addDirectRecipe(u as IRemoteUser); - } - - // 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送 - if (data.reply && data.reply.userHost !== null) { - const u = await Users.findOneBy({ id: data.reply.userId }); - if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(u); - } - - // 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送 - if (data.renote && data.renote.userHost !== null) { - const u = await Users.findOneBy({ id: data.renote.userId }); - if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(u); - } - - // フォロワーに配送 - if (['public', 'home', 'followers'].includes(note.visibility)) { - dm.addFollowersRecipe(); - } - - if (['public'].includes(note.visibility)) { - deliverToRelays(user, noteActivity); - } - - dm.execute(); - })(); - } - //#endregion - } - - if (data.channel) { - Channels.increment({ id: data.channel.id }, 'notesCount', 1); - Channels.update(data.channel.id, { - lastNotedAt: new Date(), - }); - - Notes.countBy({ - userId: user.id, - channelId: data.channel.id, - }).then(count => { - // この処理が行われるのはノート作成後なので、ノートが一つしかなかったら最初の投稿だと判断できる - // TODO: とはいえノートを削除して何回も投稿すればその分だけインクリメントされる雑さもあるのでどうにかしたい - if (count === 1) { - Channels.increment({ id: data.channel!.id }, 'usersCount', 1); - } - }); - } - - // Register to search database - index(note); -}); - -async function renderNoteOrRenoteActivity(data: Option, note: Note) { - if (data.localOnly) return null; - - const content = data.renote && data.text == null && data.poll == null && (data.files == null || data.files.length === 0) - ? renderAnnounce(data.renote.uri ? data.renote.uri : `${config.url}/notes/${data.renote.id}`, note) - : renderCreate(await renderNote(note, false), note); - - return renderActivity(content); -} - -function incRenoteCount(renote: Note) { - Notes.createQueryBuilder().update() - .set({ - renoteCount: () => '"renoteCount" + 1', - score: () => '"score" + 1', - }) - .where('id = :id', { id: renote.id }) - .execute(); -} - -async function insertNote(user: { id: User['id']; host: User['host']; }, data: Option, tags: string[], emojis: string[], mentionedUsers: MinimumUser[]) { - const insert = new Note({ - id: genId(data.createdAt!), - createdAt: data.createdAt!, - fileIds: data.files ? data.files.map(file => file.id) : [], - replyId: data.reply ? data.reply.id : null, - renoteId: data.renote ? data.renote.id : null, - channelId: data.channel ? data.channel.id : null, - threadId: data.reply - ? data.reply.threadId - ? data.reply.threadId - : data.reply.id - : null, - name: data.name, - text: data.text, - hasPoll: data.poll != null, - cw: data.cw == null ? null : data.cw, - tags: tags.map(tag => normalizeForSearch(tag)), - emojis, - userId: user.id, - localOnly: data.localOnly!, - visibility: data.visibility as any, - visibleUserIds: data.visibility === 'specified' - ? data.visibleUsers - ? data.visibleUsers.map(u => u.id) - : [] - : [], - - attachedFileTypes: data.files ? data.files.map(file => file.type) : [], - - // 以下非正規化データ - replyUserId: data.reply ? data.reply.userId : null, - replyUserHost: data.reply ? data.reply.userHost : null, - renoteUserId: data.renote ? data.renote.userId : null, - renoteUserHost: data.renote ? data.renote.userHost : null, - userHost: user.host, - }); - - if (data.uri != null) insert.uri = data.uri; - if (data.url != null) insert.url = data.url; - - // Append mentions data - if (mentionedUsers.length > 0) { - insert.mentions = mentionedUsers.map(u => u.id); - const profiles = await UserProfiles.findBy({ userId: In(insert.mentions) }); - insert.mentionedRemoteUsers = JSON.stringify(mentionedUsers.filter(u => Users.isRemoteUser(u)).map(u => { - const profile = profiles.find(p => p.userId === u.id); - const url = profile != null ? profile.url : null; - return { - uri: u.uri, - url: url == null ? undefined : url, - username: u.username, - host: u.host, - } as IMentionedRemoteUsers[0]; - })); - } - - // 投稿を作成 - try { - if (insert.hasPoll) { - // Start transaction - await db.transaction(async transactionalEntityManager => { - await transactionalEntityManager.insert(Note, insert); - - const poll = new Poll({ - noteId: insert.id, - choices: data.poll!.choices, - expiresAt: data.poll!.expiresAt, - multiple: data.poll!.multiple, - votes: new Array(data.poll!.choices.length).fill(0), - noteVisibility: insert.visibility, - userId: user.id, - userHost: user.host, - }); - - await transactionalEntityManager.insert(Poll, poll); - }); - } else { - await Notes.insert(insert); - } - - return insert; - } catch (e) { - // duplicate key error - if (isDuplicateKeyValueError(e)) { - const err = new Error('Duplicated note'); - err.name = 'duplicated'; - throw err; - } - - console.error(e); - - throw e; - } -} - -function index(note: Note) { - if (note.text == null || config.elasticsearch == null) return; - - es!.index({ - index: config.elasticsearch.index || 'misskey_note', - id: note.id.toString(), - body: { - text: normalizeForSearch(note.text), - userId: note.userId, - userHost: note.userHost, - }, - }); -} - -async function notifyToWatchersOfRenotee(renote: Note, user: { id: User['id']; }, nm: NotificationManager, type: NotificationType) { - const watchers = await NoteWatchings.findBy({ - noteId: renote.id, - userId: Not(user.id), - }); - - for (const watcher of watchers) { - nm.push(watcher.userId, type); - } -} - -async function notifyToWatchersOfReplyee(reply: Note, user: { id: User['id']; }, nm: NotificationManager) { - const watchers = await NoteWatchings.findBy({ - noteId: reply.id, - userId: Not(user.id), - }); - - for (const watcher of watchers) { - nm.push(watcher.userId, 'reply'); - } -} - -async function createMentionedEvents(mentionedUsers: MinimumUser[], note: Note, nm: NotificationManager) { - for (const u of mentionedUsers.filter(u => Users.isLocalUser(u))) { - const threadMuted = await NoteThreadMutings.findOneBy({ - userId: u.id, - threadId: note.threadId || note.id, - }); - - if (threadMuted) { - continue; - } - - const detailPackedNote = await Notes.pack(note, u, { - detail: true, - }); - - publishMainStream(u.id, 'mention', detailPackedNote); - - const webhooks = (await getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('mention')); - for (const webhook of webhooks) { - webhookDeliver(webhook, 'mention', { - note: detailPackedNote, - }); - } - - // Create notification - nm.push(u.id, 'mention'); - } -} - -function saveReply(reply: Note, note: Note) { - Notes.increment({ id: reply.id }, 'repliesCount', 1); -} - -function incNotesCountOfUser(user: { id: User['id']; }) { - Users.createQueryBuilder().update() - .set({ - updatedAt: new Date(), - notesCount: () => '"notesCount" + 1', - }) - .where('id = :id', { id: user.id }) - .execute(); -} - -async function extractMentionedUsers(user: { host: User['host']; }, tokens: mfm.MfmNode[]): Promise { - if (tokens == null) return []; - - const mentions = extractMentions(tokens); - - let mentionedUsers = (await Promise.all(mentions.map(m => - resolveUser(m.username, m.host || user.host).catch(() => null) - ))).filter(x => x != null) as User[]; - - // Drop duplicate users - mentionedUsers = mentionedUsers.filter((u, i, self) => - i === self.findIndex(u2 => u.id === u2.id) - ); - - return mentionedUsers; -} diff --git a/packages/backend/src/services/note/delete.ts b/packages/backend/src/services/note/delete.ts deleted file mode 100644 index 496320016..000000000 --- a/packages/backend/src/services/note/delete.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { Brackets, In } from 'typeorm'; -import { publishNoteStream } from '@/services/stream.js'; -import renderDelete from '@/remote/activitypub/renderer/delete.js'; -import renderAnnounce from '@/remote/activitypub/renderer/announce.js'; -import renderUndo from '@/remote/activitypub/renderer/undo.js'; -import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import renderTombstone from '@/remote/activitypub/renderer/tombstone.js'; -import config from '@/config/index.js'; -import { User, ILocalUser, IRemoteUser } from '@/models/entities/user.js'; -import { Note, IMentionedRemoteUsers } from '@/models/entities/note.js'; -import { Notes, Users, Instances } from '@/models/index.js'; -import { notesChart, perUserNotesChart, instanceChart } from '@/services/chart/index.js'; -import { deliverToFollowers, deliverToUser } from '@/remote/activitypub/deliver-manager.js'; -import { countSameRenotes } from '@/misc/count-same-renotes.js'; -import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js'; -import { deliverToRelays } from '../relay.js'; - -/** - * 投稿を削除します。 - * @param user 投稿者 - * @param note 投稿 - */ -export default async function(user: { id: User['id']; uri: User['uri']; host: User['host']; }, note: Note, quiet = false) { - const deletedAt = new Date(); - - // この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき - if (note.renoteId && (await countSameRenotes(user.id, note.renoteId, note.id)) === 0) { - Notes.decrement({ id: note.renoteId }, 'renoteCount', 1); - Notes.decrement({ id: note.renoteId }, 'score', 1); - } - - if (note.replyId) { - await Notes.decrement({ id: note.replyId }, 'repliesCount', 1); - } - - if (!quiet) { - publishNoteStream(note.id, 'deleted', { - deletedAt: deletedAt, - }); - - //#region ローカルの投稿なら削除アクティビティを配送 - if (Users.isLocalUser(user) && !note.localOnly) { - let renote: Note | null = null; - - // if deletd note is renote - if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) { - renote = await Notes.findOneBy({ - id: note.renoteId, - }); - } - - const content = renderActivity(renote - ? renderUndo(renderAnnounce(renote.uri || `${config.url}/notes/${renote.id}`, note), user) - : renderDelete(renderTombstone(`${config.url}/notes/${note.id}`), user)); - - deliverToConcerned(user, note, content); - } - - // also deliever delete activity to cascaded notes - const cascadingNotes = (await findCascadingNotes(note)).filter(note => !note.localOnly); // filter out local-only notes - for (const cascadingNote of cascadingNotes) { - if (!cascadingNote.user) continue; - if (!Users.isLocalUser(cascadingNote.user)) continue; - const content = renderActivity(renderDelete(renderTombstone(`${config.url}/notes/${cascadingNote.id}`), cascadingNote.user)); - deliverToConcerned(cascadingNote.user, cascadingNote, content); - } - //#endregion - - // 統計を更新 - notesChart.update(note, false); - perUserNotesChart.update(user, note, false); - - if (Users.isRemoteUser(user)) { - registerOrFetchInstanceDoc(user.host).then(i => { - Instances.decrement({ id: i.id }, 'notesCount', 1); - instanceChart.updateNote(i.host, note, false); - }); - } - } - - await Notes.delete({ - id: note.id, - userId: user.id, - }); -} - -async function findCascadingNotes(note: Note) { - const cascadingNotes: Note[] = []; - - const recursive = async (noteId: string) => { - const query = Notes.createQueryBuilder('note') - .where('note.replyId = :noteId', { noteId }) - .orWhere(new Brackets(q => { - q.where('note.renoteId = :noteId', { noteId }) - .andWhere('note.text IS NOT NULL'); - })) - .leftJoinAndSelect('note.user', 'user'); - const replies = await query.getMany(); - for (const reply of replies) { - cascadingNotes.push(reply); - await recursive(reply.id); - } - }; - await recursive(note.id); - - return cascadingNotes.filter(note => note.userHost === null); // filter out non-local users -} - -async function getMentionedRemoteUsers(note: Note) { - 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 Users.find({ - where, - }) as IRemoteUser[]; -} - -async function deliverToConcerned(user: { id: ILocalUser['id']; host: null; }, note: Note, content: any) { - deliverToFollowers(user, content); - deliverToRelays(user, content); - const remoteUsers = await getMentionedRemoteUsers(note); - for (const remoteUser of remoteUsers) { - deliverToUser(user, content, remoteUser); - } -} diff --git a/packages/backend/src/services/note/polls/update.ts b/packages/backend/src/services/note/polls/update.ts deleted file mode 100644 index 68cbb9835..000000000 --- a/packages/backend/src/services/note/polls/update.ts +++ /dev/null @@ -1,21 +0,0 @@ -import renderUpdate from '@/remote/activitypub/renderer/update.js'; -import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import renderNote from '@/remote/activitypub/renderer/note.js'; -import { Users, Notes } from '@/models/index.js'; -import { Note } from '@/models/entities/note.js'; -import { deliverToFollowers } from '@/remote/activitypub/deliver-manager.js'; -import { deliverToRelays } from '../../relay.js'; - -export async function deliverQuestionUpdate(noteId: Note['id']) { - const note = await Notes.findOneBy({ id: noteId }); - if (note == null) throw new Error('note not found'); - - const user = await Users.findOneBy({ id: note.userId }); - if (user == null) throw new Error('note not found'); - - if (Users.isLocalUser(user)) { - const content = renderActivity(renderUpdate(await renderNote(note, false), user)); - deliverToFollowers(user, content); - deliverToRelays(user, content); - } -} diff --git a/packages/backend/src/services/note/polls/vote.ts b/packages/backend/src/services/note/polls/vote.ts deleted file mode 100644 index 84d98769d..000000000 --- a/packages/backend/src/services/note/polls/vote.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { publishNoteStream } from '@/services/stream.js'; -import { CacheableUser, User } from '@/models/entities/user.js'; -import { Note } from '@/models/entities/note.js'; -import { PollVotes, NoteWatchings, Polls, Blockings } from '@/models/index.js'; -import { Not } from 'typeorm'; -import { genId } from '@/misc/gen-id.js'; -import { createNotification } from '../../create-notification.js'; - -export default async function(user: CacheableUser, note: Note, choice: number) { - const poll = await Polls.findOneBy({ noteId: note.id }); - - if (poll == null) throw new Error('poll not found'); - - // Check whether is valid choice - if (poll.choices[choice] == null) throw new Error('invalid choice param'); - - // Check blocking - if (note.userId !== user.id) { - const block = await Blockings.findOneBy({ - blockerId: note.userId, - blockeeId: user.id, - }); - if (block) { - throw new Error('blocked'); - } - } - - // if already voted - const exist = await PollVotes.findBy({ - noteId: note.id, - userId: user.id, - }); - - if (poll.multiple) { - if (exist.some(x => x.choice === choice)) { - throw new Error('already voted'); - } - } else if (exist.length !== 0) { - throw new Error('already voted'); - } - - // Create vote - await PollVotes.insert({ - id: genId(), - createdAt: new Date(), - noteId: note.id, - userId: user.id, - choice: choice, - }); - - // Increment votes count - const index = choice + 1; // In SQL, array index is 1 based - await Polls.query(`UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`); - - publishNoteStream(note.id, 'pollVoted', { - choice: choice, - userId: user.id, - }); - - // Notify - createNotification(note.userId, 'pollVote', { - notifierId: user.id, - noteId: note.id, - choice: choice, - }); - - // Fetch watchers - NoteWatchings.findBy({ - noteId: note.id, - userId: Not(user.id), - }) - .then(watchers => { - for (const watcher of watchers) { - createNotification(watcher.userId, 'pollVote', { - notifierId: user.id, - noteId: note.id, - choice: choice, - }); - } - }); -} diff --git a/packages/backend/src/services/note/reaction/create.ts b/packages/backend/src/services/note/reaction/create.ts deleted file mode 100644 index 83d302826..000000000 --- a/packages/backend/src/services/note/reaction/create.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { publishNoteStream } from '@/services/stream.js'; -import { renderLike } from '@/remote/activitypub/renderer/like.js'; -import DeliverManager from '@/remote/activitypub/deliver-manager.js'; -import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import { toDbReaction, decodeReaction } from '@/misc/reaction-lib.js'; -import { User, IRemoteUser } from '@/models/entities/user.js'; -import { Note } from '@/models/entities/note.js'; -import { NoteReactions, Users, NoteWatchings, Notes, Emojis, Blockings } from '@/models/index.js'; -import { IsNull, Not } from 'typeorm'; -import { perUserReactionsChart } from '@/services/chart/index.js'; -import { genId } from '@/misc/gen-id.js'; -import { createNotification } from '../../create-notification.js'; -import deleteReaction from './delete.js'; -import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; -import { NoteReaction } from '@/models/entities/note-reaction.js'; -import { IdentifiableError } from '@/misc/identifiable-error.js'; - -export default async (user: { id: User['id']; host: User['host']; }, note: Note, reaction?: string) => { - // Check blocking - if (note.userId !== user.id) { - const block = await Blockings.findOneBy({ - blockerId: note.userId, - blockeeId: user.id, - }); - if (block) { - throw new IdentifiableError('e70412a4-7197-4726-8e74-f3e0deb92aa7'); - } - } - - // check visibility - if (!await Notes.isVisibleForMe(note, user.id)) { - throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.'); - } - - // TODO: cache - reaction = await toDbReaction(reaction, user.host); - - const record: NoteReaction = { - id: genId(), - createdAt: new Date(), - noteId: note.id, - userId: user.id, - reaction, - }; - - // Create reaction - try { - await NoteReactions.insert(record); - } catch (e) { - if (isDuplicateKeyValueError(e)) { - const exists = await NoteReactions.findOneByOrFail({ - noteId: note.id, - userId: user.id, - }); - - if (exists.reaction !== reaction) { - // 別のリアクションがすでにされていたら置き換える - await deleteReaction(user, note); - await NoteReactions.insert(record); - } else { - // 同じリアクションがすでにされていたらエラー - throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298'); - } - } else { - throw e; - } - } - - // Increment reactions count - const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`; - await Notes.createQueryBuilder().update() - .set({ - reactions: () => sql, - score: () => '"score" + 1', - }) - .where('id = :id', { id: note.id }) - .execute(); - - perUserReactionsChart.update(user, note); - - // カスタム絵文字リアクションだったら絵文字情報も送る - const decodedReaction = decodeReaction(reaction); - - const emoji = await Emojis.findOne({ - where: { - name: decodedReaction.name, - host: decodedReaction.host ?? IsNull(), - }, - select: ['name', 'host', 'originalUrl', 'publicUrl'], - }); - - publishNoteStream(note.id, 'reacted', { - reaction: decodedReaction.reaction, - emoji: emoji != null ? { - name: emoji.host ? `${emoji.name}@${emoji.host}` : `${emoji.name}@.`, - url: emoji.publicUrl || emoji.originalUrl, // || emoji.originalUrl してるのは後方互換性のため - } : null, - userId: user.id, - }); - - // リアクションされたユーザーがローカルユーザーなら通知を作成 - if (note.userHost === null) { - createNotification(note.userId, 'reaction', { - notifierId: user.id, - noteId: note.id, - reaction: reaction, - }); - } - - // Fetch watchers - NoteWatchings.findBy({ - noteId: note.id, - userId: Not(user.id), - }).then(watchers => { - for (const watcher of watchers) { - createNotification(watcher.userId, 'reaction', { - notifierId: user.id, - noteId: note.id, - reaction: reaction, - }); - } - }); - - //#region 配信 - if (Users.isLocalUser(user) && !note.localOnly) { - const content = renderActivity(await renderLike(record, note)); - const dm = new DeliverManager(user, content); - if (note.userHost !== null) { - const reactee = await Users.findOneBy({ id: note.userId }); - dm.addDirectRecipe(reactee as IRemoteUser); - } - - if (['public', 'home', 'followers'].includes(note.visibility)) { - dm.addFollowersRecipe(); - } else if (note.visibility === 'specified') { - const visibleUsers = await Promise.all(note.visibleUserIds.map(id => Users.findOneBy({ id }))); - for (const u of visibleUsers.filter(u => u && Users.isRemoteUser(u))) { - dm.addDirectRecipe(u as IRemoteUser); - } - } - - dm.execute(); - } - //#endregion -}; diff --git a/packages/backend/src/services/note/reaction/delete.ts b/packages/backend/src/services/note/reaction/delete.ts deleted file mode 100644 index a7cbcb1c1..000000000 --- a/packages/backend/src/services/note/reaction/delete.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { publishNoteStream } from '@/services/stream.js'; -import { renderLike } from '@/remote/activitypub/renderer/like.js'; -import renderUndo from '@/remote/activitypub/renderer/undo.js'; -import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import DeliverManager from '@/remote/activitypub/deliver-manager.js'; -import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { User, IRemoteUser } from '@/models/entities/user.js'; -import { Note } from '@/models/entities/note.js'; -import { NoteReactions, Users, Notes } from '@/models/index.js'; -import { decodeReaction } from '@/misc/reaction-lib.js'; - -export default async (user: { id: User['id']; host: User['host']; }, note: Note) => { - // if already unreacted - const exist = await NoteReactions.findOneBy({ - noteId: note.id, - userId: user.id, - }); - - if (exist == null) { - throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted'); - } - - // Delete reaction - const result = await NoteReactions.delete(exist.id); - - if (result.affected !== 1) { - throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted'); - } - - // Decrement reactions count - const sql = `jsonb_set("reactions", '{${exist.reaction}}', (COALESCE("reactions"->>'${exist.reaction}', '0')::int - 1)::text::jsonb)`; - await Notes.createQueryBuilder().update() - .set({ - reactions: () => sql, - }) - .where('id = :id', { id: note.id }) - .execute(); - - Notes.decrement({ id: note.id }, 'score', 1); - - publishNoteStream(note.id, 'unreacted', { - reaction: decodeReaction(exist.reaction).reaction, - userId: user.id, - }); - - //#region 配信 - if (Users.isLocalUser(user) && !note.localOnly) { - const content = renderActivity(renderUndo(await renderLike(exist, note), user)); - const dm = new DeliverManager(user, content); - if (note.userHost !== null) { - const reactee = await Users.findOneBy({ id: note.userId }); - dm.addDirectRecipe(reactee as IRemoteUser); - } - dm.addFollowersRecipe(); - dm.execute(); - } - //#endregion -}; diff --git a/packages/backend/src/services/note/read.ts b/packages/backend/src/services/note/read.ts deleted file mode 100644 index 915a9e9ee..000000000 --- a/packages/backend/src/services/note/read.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { publishMainStream } from '@/services/stream.js'; -import { Note } from '@/models/entities/note.js'; -import { User } from '@/models/entities/user.js'; -import { NoteUnreads, AntennaNotes, Users, Followings, ChannelFollowings } from '@/models/index.js'; -import { Not, IsNull, In } from 'typeorm'; -import { Channel } from '@/models/entities/channel.js'; -import { checkHitAntenna } from '@/misc/check-hit-antenna.js'; -import { getAntennas } from '@/misc/antenna-cache.js'; -import { readNotificationByQuery } from '@/server/api/common/read-notification.js'; -import { Packed } from '@/misc/schema.js'; - -/** - * Mark notes as read - */ -export default async function( - userId: User['id'], - notes: (Note | Packed<'Note'>)[], - info?: { - following: Set; - followingChannels: Set; - } -) { - const following = info?.following ? info.following : new Set((await Followings.find({ - where: { - followerId: userId, - }, - select: ['followeeId'], - })).map(x => x.followeeId)); - const followingChannels = info?.followingChannels ? info.followingChannels : new Set((await ChannelFollowings.find({ - where: { - followerId: userId, - }, - select: ['followeeId'], - })).map(x => x.followeeId)); - - const myAntennas = (await getAntennas()).filter(a => a.userId === userId); - const readMentions: (Note | Packed<'Note'>)[] = []; - const readSpecifiedNotes: (Note | Packed<'Note'>)[] = []; - const readChannelNotes: (Note | Packed<'Note'>)[] = []; - const readAntennaNotes: (Note | Packed<'Note'>)[] = []; - - for (const note of notes) { - if (note.mentions && note.mentions.includes(userId)) { - readMentions.push(note); - } else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) { - readSpecifiedNotes.push(note); - } - - if (note.channelId && followingChannels.has(note.channelId)) { - readChannelNotes.push(note); - } - - if (note.user != null) { // たぶんnullになることは無いはずだけど一応 - for (const antenna of myAntennas) { - if (await checkHitAntenna(antenna, note, note.user, undefined, Array.from(following))) { - readAntennaNotes.push(note); - } - } - } - } - - if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0) || (readChannelNotes.length > 0)) { - // Remove the record - await NoteUnreads.delete({ - userId: userId, - noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id), ...readChannelNotes.map(n => n.id)]), - }); - - // TODO: ↓まとめてクエリしたい - - NoteUnreads.countBy({ - userId: userId, - isMentioned: true, - }).then(mentionsCount => { - if (mentionsCount === 0) { - // 全て既読になったイベントを発行 - publishMainStream(userId, 'readAllUnreadMentions'); - } - }); - - NoteUnreads.countBy({ - userId: userId, - isSpecified: true, - }).then(specifiedCount => { - if (specifiedCount === 0) { - // 全て既読になったイベントを発行 - publishMainStream(userId, 'readAllUnreadSpecifiedNotes'); - } - }); - - NoteUnreads.countBy({ - userId: userId, - noteChannelId: Not(IsNull()), - }).then(channelNoteCount => { - if (channelNoteCount === 0) { - // 全て既読になったイベントを発行 - publishMainStream(userId, 'readAllChannels'); - } - }); - - readNotificationByQuery(userId, { - noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]), - }); - } - - if (readAntennaNotes.length > 0) { - await AntennaNotes.update({ - antennaId: In(myAntennas.map(a => a.id)), - noteId: In(readAntennaNotes.map(n => n.id)), - }, { - read: true, - }); - - // TODO: まとめてクエリしたい - for (const antenna of myAntennas) { - const count = await AntennaNotes.countBy({ - antennaId: antenna.id, - read: false, - }); - - if (count === 0) { - publishMainStream(userId, 'readAntenna', antenna); - } - } - - Users.getHasUnreadAntenna(userId).then(unread => { - if (!unread) { - publishMainStream(userId, 'readAllAntennas'); - } - }); - } -} diff --git a/packages/backend/src/services/note/unread.ts b/packages/backend/src/services/note/unread.ts deleted file mode 100644 index d9ed711e0..000000000 --- a/packages/backend/src/services/note/unread.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Note } from '@/models/entities/note.js'; -import { publishMainStream } from '@/services/stream.js'; -import { User } from '@/models/entities/user.js'; -import { Mutings, NoteThreadMutings, NoteUnreads } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; - -export async function insertNoteUnread(userId: User['id'], note: Note, params: { - // NOTE: isSpecifiedがtrueならisMentionedは必ずfalse - isSpecified: boolean; - isMentioned: boolean; -}) { - //#region ミュートしているなら無視 - // TODO: 現在の仕様ではChannelにミュートは適用されないのでよしなにケアする - const mute = await Mutings.findBy({ - muterId: userId, - }); - if (mute.map(m => m.muteeId).includes(note.userId)) return; - //#endregion - - // スレッドミュート - const threadMute = await NoteThreadMutings.findOneBy({ - userId: userId, - threadId: note.threadId || note.id, - }); - if (threadMute) return; - - const unread = { - id: genId(), - noteId: note.id, - userId: userId, - isSpecified: params.isSpecified, - isMentioned: params.isMentioned, - noteChannelId: note.channelId, - noteUserId: note.userId, - }; - - await NoteUnreads.insert(unread); - - // 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する - setTimeout(async () => { - const exist = await NoteUnreads.findOneBy({ id: unread.id }); - - if (exist == null) return; - - if (params.isMentioned) { - publishMainStream(userId, 'unreadMention', note.id); - } - if (params.isSpecified) { - publishMainStream(userId, 'unreadSpecifiedNote', note.id); - } - if (note.channelId) { - publishMainStream(userId, 'unreadChannel', note.id); - } - }, 2000); -} diff --git a/packages/backend/src/services/note/unwatch.ts b/packages/backend/src/services/note/unwatch.ts deleted file mode 100644 index 3964b2ba5..000000000 --- a/packages/backend/src/services/note/unwatch.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { User } from '@/models/entities/user.js'; -import { NoteWatchings } from '@/models/index.js'; -import { Note } from '@/models/entities/note.js'; - -export default async (me: User['id'], note: Note) => { - await NoteWatchings.delete({ - noteId: note.id, - userId: me, - }); -}; diff --git a/packages/backend/src/services/note/watch.ts b/packages/backend/src/services/note/watch.ts deleted file mode 100644 index 2210c44a7..000000000 --- a/packages/backend/src/services/note/watch.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { User } from '@/models/entities/user.js'; -import { Note } from '@/models/entities/note.js'; -import { NoteWatchings } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; -import { NoteWatching } from '@/models/entities/note-watching.js'; - -export default async (me: User['id'], note: Note) => { - // 自分の投稿はwatchできない - if (me === note.userId) { - return; - } - - await NoteWatchings.insert({ - id: genId(), - createdAt: new Date(), - noteId: note.id, - userId: me, - noteUserId: note.userId, - } as NoteWatching); -}; diff --git a/packages/backend/src/services/push-notification.ts b/packages/backend/src/services/push-notification.ts deleted file mode 100644 index 393a23d05..000000000 --- a/packages/backend/src/services/push-notification.ts +++ /dev/null @@ -1,85 +0,0 @@ -import push from 'web-push'; -import config from '@/config/index.js'; -import { SwSubscriptions } from '@/models/index.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import { Packed } from '@/misc/schema.js'; -import { getNoteSummary } from '@/misc/get-note-summary.js'; - -// Defined also packages/sw/types.ts#L14-L21 -type pushNotificationsTypes = { - 'notification': Packed<'Notification'>; - 'unreadMessagingMessage': Packed<'MessagingMessage'>; - 'readNotifications': { notificationIds: string[] }; - 'readAllNotifications': undefined; - 'readAllMessagingMessages': undefined; - 'readAllMessagingMessagesOfARoom': { userId: string } | { groupId: string }; -}; - -// プッシュメッセージサーバーには文字数制限があるため、内容を削減します -function truncateNotification(notification: Packed<'Notification'>): any { - if (notification.note) { - return { - ...notification, - note: { - ...notification.note, - // textをgetNoteSummaryしたものに置き換える - text: getNoteSummary(notification.type === 'renote' ? notification.note.renote as Packed<'Note'> : notification.note), - - cw: undefined, - reply: undefined, - renote: undefined, - user: undefined as any, // 通知を受け取ったユーザーである場合が多いのでこれも捨てる - } - }; - } - - return notification; -} - -export async function pushNotification(userId: string, type: T, body: pushNotificationsTypes[T]) { - const meta = await fetchMeta(); - - if (!meta.enableServiceWorker || meta.swPublicKey == null || meta.swPrivateKey == null) return; - - // アプリケーションの連絡先と、サーバーサイドの鍵ペアの情報を登録 - push.setVapidDetails(config.url, - meta.swPublicKey, - meta.swPrivateKey); - - // Fetch - const subscriptions = await SwSubscriptions.findBy({ - userId: userId, - }); - - for (const subscription of subscriptions) { - const pushSubscription = { - endpoint: subscription.endpoint, - keys: { - auth: subscription.auth, - p256dh: subscription.publickey, - }, - }; - - push.sendNotification(pushSubscription, JSON.stringify({ - type, - body: type === 'notification' ? truncateNotification(body as Packed<'Notification'>) : body, - userId, - dateTime: (new Date()).getTime(), - }), { - proxy: config.proxy, - }).catch((err: any) => { - //swLogger.info(err.statusCode); - //swLogger.info(err.headers); - //swLogger.info(err.body); - - if (err.statusCode === 410) { - SwSubscriptions.delete({ - userId: userId, - endpoint: subscription.endpoint, - auth: subscription.auth, - publickey: subscription.publickey, - }); - } - }); - } -} diff --git a/packages/backend/src/services/register-or-fetch-instance-doc.ts b/packages/backend/src/services/register-or-fetch-instance-doc.ts deleted file mode 100644 index df7d125d0..000000000 --- a/packages/backend/src/services/register-or-fetch-instance-doc.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Instance } from '@/models/entities/instance.js'; -import { Instances } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; -import { toPuny } from '@/misc/convert-host.js'; -import { Cache } from '@/misc/cache.js'; - -const cache = new Cache(1000 * 60 * 60); - -export async function registerOrFetchInstanceDoc(host: string): Promise { - host = toPuny(host); - - const cached = cache.get(host); - if (cached) return cached; - - const index = await Instances.findOneBy({ host }); - - if (index == null) { - const i = await Instances.insert({ - id: genId(), - host, - caughtAt: new Date(), - lastCommunicatedAt: new Date(), - }).then(x => Instances.findOneByOrFail(x.identifiers[0])); - - cache.set(host, i); - return i; - } else { - cache.set(host, index); - return index; - } -} diff --git a/packages/backend/src/services/relay.ts b/packages/backend/src/services/relay.ts deleted file mode 100644 index 6bc430443..000000000 --- a/packages/backend/src/services/relay.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { IsNull } from 'typeorm'; -import { renderFollowRelay } from '@/remote/activitypub/renderer/follow-relay.js'; -import { renderActivity, attachLdSignature } from '@/remote/activitypub/renderer/index.js'; -import renderUndo from '@/remote/activitypub/renderer/undo.js'; -import { deliver } from '@/queue/index.js'; -import { ILocalUser, User } from '@/models/entities/user.js'; -import { Users, Relays } from '@/models/index.js'; -import { genId } from '@/misc/gen-id.js'; -import { Cache } from '@/misc/cache.js'; -import { Relay } from '@/models/entities/relay.js'; -import { createSystemUser } from './create-system-user.js'; - -const ACTOR_USERNAME = 'relay.actor' as const; - -const relaysCache = new Cache(1000 * 60 * 10); - -export async function getRelayActor(): Promise { - const user = await Users.findOneBy({ - host: IsNull(), - username: ACTOR_USERNAME, - }); - - if (user) return user as ILocalUser; - - const created = await createSystemUser(ACTOR_USERNAME); - return created as ILocalUser; -} - -export async function addRelay(inbox: string) { - const relay = await Relays.insert({ - id: genId(), - inbox, - status: 'requesting', - }).then(x => Relays.findOneByOrFail(x.identifiers[0])); - - const relayActor = await getRelayActor(); - const follow = await renderFollowRelay(relay, relayActor); - const activity = renderActivity(follow); - deliver(relayActor, activity, relay.inbox); - - return relay; -} - -export async function removeRelay(inbox: string) { - const relay = await Relays.findOneBy({ - inbox, - }); - - if (relay == null) { - throw 'relay not found'; - } - - const relayActor = await getRelayActor(); - const follow = renderFollowRelay(relay, relayActor); - const undo = renderUndo(follow, relayActor); - const activity = renderActivity(undo); - deliver(relayActor, activity, relay.inbox); - - await Relays.delete(relay.id); -} - -export async function listRelay() { - const relays = await Relays.find(); - return relays; -} - -export async function relayAccepted(id: string) { - const result = await Relays.update(id, { - status: 'accepted', - }); - - return JSON.stringify(result); -} - -export async function relayRejected(id: string) { - const result = await Relays.update(id, { - status: 'rejected', - }); - - return JSON.stringify(result); -} - -export async function deliverToRelays(user: { id: User['id']; host: null; }, activity: any) { - if (activity == null) return; - - const relays = await relaysCache.fetch(null, () => Relays.findBy({ - status: 'accepted', - })); - if (relays.length === 0) return; - - // TODO - //const copy = structuredClone(activity); - const copy = JSON.parse(JSON.stringify(activity)); - if (!copy.to) copy.to = ['https://www.w3.org/ns/activitystreams#Public']; - - const signed = await attachLdSignature(copy, user); - - for (const relay of relays) { - deliver(user, signed, relay.inbox); - } -} diff --git a/packages/backend/src/services/send-email-notification.ts b/packages/backend/src/services/send-email-notification.ts deleted file mode 100644 index 4a2f94b42..000000000 --- a/packages/backend/src/services/send-email-notification.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { UserProfiles } from '@/models/index.js'; -import { User } from '@/models/entities/user.js'; -import { sendEmail } from './send-email.js'; -import { I18n } from '@/misc/i18n.js'; -import * as Acct from '@/misc/acct.js'; -// TODO -//const locales = await import('../../../../locales/index.js'); - -// TODO: locale ファイルをクライアント用とサーバー用で分けたい - -async function follow(userId: User['id'], follower: User) { - /* - const userProfile = await UserProfiles.findOneByOrFail({ userId: userId }); - if (!userProfile.email || !userProfile.emailNotificationTypes.includes('follow')) return; - const locale = locales[userProfile.lang || 'ja-JP']; - const i18n = new I18n(locale); - // TODO: render user information html - sendEmail(userProfile.email, i18n.t('_email._follow.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); - */ -} - -async function receiveFollowRequest(userId: User['id'], follower: User) { - /* - const userProfile = await UserProfiles.findOneByOrFail({ userId: userId }); - if (!userProfile.email || !userProfile.emailNotificationTypes.includes('receiveFollowRequest')) return; - const locale = locales[userProfile.lang || 'ja-JP']; - const i18n = new I18n(locale); - // TODO: render user information html - sendEmail(userProfile.email, i18n.t('_email._receiveFollowRequest.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); - */ -} - -export const sendEmailNotification = { - follow, - receiveFollowRequest, -}; diff --git a/packages/backend/src/services/send-email.ts b/packages/backend/src/services/send-email.ts deleted file mode 100644 index b35d22548..000000000 --- a/packages/backend/src/services/send-email.ts +++ /dev/null @@ -1,122 +0,0 @@ -import * as nodemailer from 'nodemailer'; -import { fetchMeta } from '@/misc/fetch-meta.js'; -import Logger from './logger.js'; -import config from '@/config/index.js'; - -export const logger = new Logger('email'); - -export async function sendEmail(to: string, subject: string, html: string, text: string) { - const meta = await fetchMeta(true); - - const iconUrl = `${config.url}/static-assets/mi-white.png`; - const emailSettingUrl = `${config.url}/settings/email`; - - const enableAuth = meta.smtpUser != null && meta.smtpUser !== ''; - - const transporter = nodemailer.createTransport({ - host: meta.smtpHost, - port: meta.smtpPort, - secure: meta.smtpSecure, - ignoreTLS: !enableAuth, - proxy: config.proxySmtp, - auth: enableAuth ? { - user: meta.smtpUser, - pass: meta.smtpPass, - } : undefined, - } as any); - - try { - // TODO: htmlサニタイズ - const info = await transporter.sendMail({ - from: meta.email!, - to: to, - subject: subject, - text: text, - html: ` - - - - ${ subject } - - - -
-
- -
-
-

${ subject }

-
${ html }
-
- -
- - -`, - }); - - logger.info(`Message sent: ${info.messageId}`); - } catch (err) { - logger.error(err as Error); - throw err; - } -} diff --git a/packages/backend/src/services/stream.ts b/packages/backend/src/services/stream.ts deleted file mode 100644 index 9fa2b9713..000000000 --- a/packages/backend/src/services/stream.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { redisClient } from '../db/redis.js'; -import { User } from '@/models/entities/user.js'; -import { Note } from '@/models/entities/note.js'; -import { UserList } from '@/models/entities/user-list.js'; -import { UserGroup } from '@/models/entities/user-group.js'; -import config from '@/config/index.js'; -import { Antenna } from '@/models/entities/antenna.js'; -import { Channel } from '@/models/entities/channel.js'; -import { - StreamChannels, - AdminStreamTypes, - AntennaStreamTypes, - BroadcastTypes, - ChannelStreamTypes, - DriveStreamTypes, - GroupMessagingStreamTypes, - InternalStreamTypes, - MainStreamTypes, - MessagingIndexStreamTypes, - MessagingStreamTypes, - NoteStreamTypes, - UserListStreamTypes, - UserStreamTypes, -} from '@/server/api/stream/types.js'; -import { Packed } from '@/misc/schema.js'; - -class Publisher { - private publish = (channel: StreamChannels, type: string | null, value?: any): void => { - const message = type == null ? value : value == null ? - { type: type, body: null } : - { type: type, body: value }; - - redisClient.publish(config.host, JSON.stringify({ - channel: channel, - message: message, - })); - }; - - public publishInternalEvent = (type: K, value?: InternalStreamTypes[K]): void => { - this.publish('internal', type, typeof value === 'undefined' ? null : value); - }; - - public publishUserEvent = (userId: User['id'], type: K, value?: UserStreamTypes[K]): void => { - this.publish(`user:${userId}`, type, typeof value === 'undefined' ? null : value); - }; - - public publishBroadcastStream = (type: K, value?: BroadcastTypes[K]): void => { - this.publish('broadcast', type, typeof value === 'undefined' ? null : value); - }; - - public publishMainStream = (userId: User['id'], type: K, value?: MainStreamTypes[K]): void => { - this.publish(`mainStream:${userId}`, type, typeof value === 'undefined' ? null : value); - }; - - public publishDriveStream = (userId: User['id'], type: K, value?: DriveStreamTypes[K]): void => { - this.publish(`driveStream:${userId}`, type, typeof value === 'undefined' ? null : value); - }; - - public publishNoteStream = (noteId: Note['id'], type: K, value?: NoteStreamTypes[K]): void => { - this.publish(`noteStream:${noteId}`, type, { - id: noteId, - body: value, - }); - }; - - public publishChannelStream = (channelId: Channel['id'], type: K, value?: ChannelStreamTypes[K]): void => { - this.publish(`channelStream:${channelId}`, type, typeof value === 'undefined' ? null : value); - }; - - public publishUserListStream = (listId: UserList['id'], type: K, value?: UserListStreamTypes[K]): void => { - this.publish(`userListStream:${listId}`, type, typeof value === 'undefined' ? null : value); - }; - - public publishAntennaStream = (antennaId: Antenna['id'], type: K, value?: AntennaStreamTypes[K]): void => { - this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value); - }; - - public publishMessagingStream = (userId: User['id'], otherpartyId: User['id'], type: K, value?: MessagingStreamTypes[K]): void => { - this.publish(`messagingStream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value); - }; - - public publishGroupMessagingStream = (groupId: UserGroup['id'], type: K, value?: GroupMessagingStreamTypes[K]): void => { - this.publish(`messagingStream:${groupId}`, type, typeof value === 'undefined' ? null : value); - }; - - public publishMessagingIndexStream = (userId: User['id'], type: K, value?: MessagingIndexStreamTypes[K]): void => { - this.publish(`messagingIndexStream:${userId}`, type, typeof value === 'undefined' ? null : value); - }; - - public publishNotesStream = (note: Packed<'Note'>): void => { - this.publish('notesStream', null, note); - }; - - public publishAdminStream = (userId: User['id'], type: K, value?: AdminStreamTypes[K]): void => { - this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value); - }; -} - -const publisher = new Publisher(); - -export default publisher; - -export const publishInternalEvent = publisher.publishInternalEvent; -export const publishUserEvent = publisher.publishUserEvent; -export const publishBroadcastStream = publisher.publishBroadcastStream; -export const publishMainStream = publisher.publishMainStream; -export const publishDriveStream = publisher.publishDriveStream; -export const publishNoteStream = publisher.publishNoteStream; -export const publishNotesStream = publisher.publishNotesStream; -export const publishChannelStream = publisher.publishChannelStream; -export const publishUserListStream = publisher.publishUserListStream; -export const publishAntennaStream = publisher.publishAntennaStream; -export const publishMessagingStream = publisher.publishMessagingStream; -export const publishGroupMessagingStream = publisher.publishGroupMessagingStream; -export const publishMessagingIndexStream = publisher.publishMessagingIndexStream; -export const publishAdminStream = publisher.publishAdminStream; diff --git a/packages/backend/src/services/suspend-user.ts b/packages/backend/src/services/suspend-user.ts deleted file mode 100644 index e96b06a35..000000000 --- a/packages/backend/src/services/suspend-user.ts +++ /dev/null @@ -1,37 +0,0 @@ -import renderDelete from '@/remote/activitypub/renderer/delete.js'; -import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import { deliver } from '@/queue/index.js'; -import config from '@/config/index.js'; -import { User } from '@/models/entities/user.js'; -import { Users, Followings } from '@/models/index.js'; -import { Not, IsNull } from 'typeorm'; -import { publishInternalEvent } from '@/services/stream.js'; - -export async function doPostSuspend(user: { id: User['id']; host: User['host'] }) { - publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true }); - - if (Users.isLocalUser(user)) { - // 知り得る全SharedInboxにDelete配信 - const content = renderActivity(renderDelete(`${config.url}/users/${user.id}`, user)); - - const queue: string[] = []; - - const followings = await Followings.find({ - where: [ - { followerSharedInbox: Not(IsNull()) }, - { followeeSharedInbox: Not(IsNull()) }, - ], - select: ['followerSharedInbox', 'followeeSharedInbox'], - }); - - const inboxes = followings.map(x => x.followerSharedInbox || x.followeeSharedInbox); - - for (const inbox of inboxes) { - if (inbox != null && !queue.includes(inbox)) queue.push(inbox); - } - - for (const inbox of queue) { - deliver(user, content, inbox); - } - } -} diff --git a/packages/backend/src/services/unsuspend-user.ts b/packages/backend/src/services/unsuspend-user.ts deleted file mode 100644 index 44a0d01ca..000000000 --- a/packages/backend/src/services/unsuspend-user.ts +++ /dev/null @@ -1,38 +0,0 @@ -import renderDelete from '@/remote/activitypub/renderer/delete.js'; -import renderUndo from '@/remote/activitypub/renderer/undo.js'; -import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import { deliver } from '@/queue/index.js'; -import config from '@/config/index.js'; -import { User } from '@/models/entities/user.js'; -import { Users, Followings } from '@/models/index.js'; -import { Not, IsNull } from 'typeorm'; -import { publishInternalEvent } from '@/services/stream.js'; - -export async function doPostUnsuspend(user: User) { - publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false }); - - if (Users.isLocalUser(user)) { - // 知り得る全SharedInboxにUndo Delete配信 - const content = renderActivity(renderUndo(renderDelete(`${config.url}/users/${user.id}`, user), user)); - - const queue: string[] = []; - - const followings = await Followings.find({ - where: [ - { followerSharedInbox: Not(IsNull()) }, - { followeeSharedInbox: Not(IsNull()) }, - ], - select: ['followerSharedInbox', 'followeeSharedInbox'], - }); - - const inboxes = followings.map(x => x.followerSharedInbox || x.followeeSharedInbox); - - for (const inbox of inboxes) { - if (inbox != null && !queue.includes(inbox)) queue.push(inbox); - } - - for (const inbox of queue) { - deliver(user as any, content, inbox); - } - } -} diff --git a/packages/backend/src/services/update-hashtag.ts b/packages/backend/src/services/update-hashtag.ts deleted file mode 100644 index 23b210b7a..000000000 --- a/packages/backend/src/services/update-hashtag.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { User } from '@/models/entities/user.js'; -import { Hashtags, Users } from '@/models/index.js'; -import { hashtagChart } from '@/services/chart/index.js'; -import { genId } from '@/misc/gen-id.js'; -import { Hashtag } from '@/models/entities/hashtag.js'; -import { normalizeForSearch } from '@/misc/normalize-for-search.js'; - -export async function updateHashtags(user: { id: User['id']; host: User['host']; }, tags: string[]) { - for (const tag of tags) { - await updateHashtag(user, tag); - } -} - -export async function updateUsertags(user: User, tags: string[]) { - for (const tag of tags) { - await updateHashtag(user, tag, true, true); - } - - for (const tag of (user.tags || []).filter(x => !tags.includes(x))) { - await updateHashtag(user, tag, true, false); - } -} - -export async function updateHashtag(user: { id: User['id']; host: User['host']; }, tag: string, isUserAttached = false, inc = true) { - tag = normalizeForSearch(tag); - - const index = await Hashtags.findOneBy({ name: tag }); - - if (index == null && !inc) return; - - if (index != null) { - const q = Hashtags.createQueryBuilder('tag').update() - .where('name = :name', { name: tag }); - - const set = {} as any; - - if (isUserAttached) { - if (inc) { - // 自分が初めてこのタグを使ったなら - if (!index.attachedUserIds.some(id => id === user.id)) { - set.attachedUserIds = () => `array_append("attachedUserIds", '${user.id}')`; - set.attachedUsersCount = () => `"attachedUsersCount" + 1`; - } - // 自分が(ローカル内で)初めてこのタグを使ったなら - if (Users.isLocalUser(user) && !index.attachedLocalUserIds.some(id => id === user.id)) { - set.attachedLocalUserIds = () => `array_append("attachedLocalUserIds", '${user.id}')`; - set.attachedLocalUsersCount = () => `"attachedLocalUsersCount" + 1`; - } - // 自分が(リモートで)初めてこのタグを使ったなら - if (Users.isRemoteUser(user) && !index.attachedRemoteUserIds.some(id => id === user.id)) { - set.attachedRemoteUserIds = () => `array_append("attachedRemoteUserIds", '${user.id}')`; - set.attachedRemoteUsersCount = () => `"attachedRemoteUsersCount" + 1`; - } - } else { - set.attachedUserIds = () => `array_remove("attachedUserIds", '${user.id}')`; - set.attachedUsersCount = () => `"attachedUsersCount" - 1`; - if (Users.isLocalUser(user)) { - set.attachedLocalUserIds = () => `array_remove("attachedLocalUserIds", '${user.id}')`; - set.attachedLocalUsersCount = () => `"attachedLocalUsersCount" - 1`; - } else { - set.attachedRemoteUserIds = () => `array_remove("attachedRemoteUserIds", '${user.id}')`; - set.attachedRemoteUsersCount = () => `"attachedRemoteUsersCount" - 1`; - } - } - } else { - // 自分が初めてこのタグを使ったなら - if (!index.mentionedUserIds.some(id => id === user.id)) { - set.mentionedUserIds = () => `array_append("mentionedUserIds", '${user.id}')`; - set.mentionedUsersCount = () => `"mentionedUsersCount" + 1`; - } - // 自分が(ローカル内で)初めてこのタグを使ったなら - if (Users.isLocalUser(user) && !index.mentionedLocalUserIds.some(id => id === user.id)) { - set.mentionedLocalUserIds = () => `array_append("mentionedLocalUserIds", '${user.id}')`; - set.mentionedLocalUsersCount = () => `"mentionedLocalUsersCount" + 1`; - } - // 自分が(リモートで)初めてこのタグを使ったなら - if (Users.isRemoteUser(user) && !index.mentionedRemoteUserIds.some(id => id === user.id)) { - set.mentionedRemoteUserIds = () => `array_append("mentionedRemoteUserIds", '${user.id}')`; - set.mentionedRemoteUsersCount = () => `"mentionedRemoteUsersCount" + 1`; - } - } - - if (Object.keys(set).length > 0) { - q.set(set); - q.execute(); - } - } else { - if (isUserAttached) { - Hashtags.insert({ - id: genId(), - name: tag, - mentionedUserIds: [], - mentionedUsersCount: 0, - mentionedLocalUserIds: [], - mentionedLocalUsersCount: 0, - mentionedRemoteUserIds: [], - mentionedRemoteUsersCount: 0, - attachedUserIds: [user.id], - attachedUsersCount: 1, - attachedLocalUserIds: Users.isLocalUser(user) ? [user.id] : [], - attachedLocalUsersCount: Users.isLocalUser(user) ? 1 : 0, - attachedRemoteUserIds: Users.isRemoteUser(user) ? [user.id] : [], - attachedRemoteUsersCount: Users.isRemoteUser(user) ? 1 : 0, - } as Hashtag); - } else { - Hashtags.insert({ - id: genId(), - name: tag, - mentionedUserIds: [user.id], - mentionedUsersCount: 1, - mentionedLocalUserIds: Users.isLocalUser(user) ? [user.id] : [], - mentionedLocalUsersCount: Users.isLocalUser(user) ? 1 : 0, - mentionedRemoteUserIds: Users.isRemoteUser(user) ? [user.id] : [], - mentionedRemoteUsersCount: Users.isRemoteUser(user) ? 1 : 0, - attachedUserIds: [], - attachedUsersCount: 0, - attachedLocalUserIds: [], - attachedLocalUsersCount: 0, - attachedRemoteUserIds: [], - attachedRemoteUsersCount: 0, - } as Hashtag); - } - } - - if (!isUserAttached) { - hashtagChart.update(tag, user); - } -} diff --git a/packages/backend/src/services/user-cache.ts b/packages/backend/src/services/user-cache.ts deleted file mode 100644 index 407301f2f..000000000 --- a/packages/backend/src/services/user-cache.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { CacheableLocalUser, CacheableUser, ILocalUser, User } from '@/models/entities/user.js'; -import { Users } from '@/models/index.js'; -import { Cache } from '@/misc/cache.js'; -import { subsdcriber } from '@/db/redis.js'; - -export const userByIdCache = new Cache(Infinity); -export const localUserByNativeTokenCache = new Cache(Infinity); -export const localUserByIdCache = new Cache(Infinity); -export const uriPersonCache = new Cache(Infinity); - -subsdcriber.on('message', async (_, data) => { - const obj = JSON.parse(data); - - if (obj.channel === 'internal') { - const { type, body } = obj.message; - switch (type) { - case 'userChangeSuspendedState': - case 'userChangeSilencedState': - case 'userChangeModeratorState': - case 'remoteUserUpdated': { - const user = await Users.findOneByOrFail({ id: body.id }); - userByIdCache.set(user.id, user); - for (const [k, v] of uriPersonCache.cache.entries()) { - if (v.value?.id === user.id) { - uriPersonCache.set(k, user); - } - } - if (Users.isLocalUser(user)) { - localUserByNativeTokenCache.set(user.token, user); - localUserByIdCache.set(user.id, user); - } - break; - } - case 'userTokenRegenerated': { - const user = await Users.findOneByOrFail({ id: body.id }) as ILocalUser; - localUserByNativeTokenCache.delete(body.oldToken); - localUserByNativeTokenCache.set(body.newToken, user); - break; - } - default: - break; - } - } -}); diff --git a/packages/backend/src/services/user-list/push.ts b/packages/backend/src/services/user-list/push.ts deleted file mode 100644 index d073afcd3..000000000 --- a/packages/backend/src/services/user-list/push.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { publishUserListStream } from '@/services/stream.js'; -import { User } from '@/models/entities/user.js'; -import { UserList } from '@/models/entities/user-list.js'; -import { UserListJoinings, Users } from '@/models/index.js'; -import { UserListJoining } from '@/models/entities/user-list-joining.js'; -import { genId } from '@/misc/gen-id.js'; -import { fetchProxyAccount } from '@/misc/fetch-proxy-account.js'; -import createFollowing from '../following/create.js'; - -export async function pushUserToUserList(target: User, list: UserList) { - await UserListJoinings.insert({ - id: genId(), - createdAt: new Date(), - userId: target.id, - userListId: list.id, - } as UserListJoining); - - publishUserListStream(list.id, 'userAdded', await Users.pack(target)); - - // このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする - if (Users.isRemoteUser(target)) { - const proxy = await fetchProxyAccount(); - if (proxy) { - createFollowing(proxy, target); - } - } -} diff --git a/packages/backend/src/services/validate-email-for-account.ts b/packages/backend/src/services/validate-email-for-account.ts deleted file mode 100644 index b5fa99b93..000000000 --- a/packages/backend/src/services/validate-email-for-account.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { validate as validateEmail } from 'deep-email-validator'; -import { UserProfiles } from '@/models/index.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; - -export async function validateEmailForAccount(emailAddress: string): Promise<{ - available: boolean; - reason: null | 'used' | 'format' | 'disposable' | 'mx' | 'smtp'; -}> { - const meta = await fetchMeta(); - - const exist = await UserProfiles.countBy({ - emailVerified: true, - email: emailAddress, - }); - - const validated = meta.enableActiveEmailValidation ? await validateEmail({ - email: emailAddress, - validateRegex: true, - validateMx: true, - validateTypo: false, // TLDを見ているみたいだけどclubとか弾かれるので - validateDisposable: true, // 捨てアドかどうかチェック - validateSMTP: false, // 日本だと25ポートが殆どのプロバイダーで塞がれていてタイムアウトになるので - }) : { valid: true }; - - const available = exist === 0 && validated.valid; - - return { - available, - reason: available ? null : - exist !== 0 ? 'used' : - validated.reason === 'regex' ? 'format' : - validated.reason === 'disposable' ? 'disposable' : - validated.reason === 'mx' ? 'mx' : - validated.reason === 'smtp' ? 'smtp' : - null, - }; -} diff --git a/packages/backend/test/.eslintrc.cjs b/packages/backend/test/.eslintrc.cjs index d83dc37d2..41ecea0c3 100644 --- a/packages/backend/test/.eslintrc.cjs +++ b/packages/backend/test/.eslintrc.cjs @@ -6,6 +6,6 @@ module.exports = { extends: ['../.eslintrc.cjs'], env: { node: true, - mocha: true, + jest: true, }, }; diff --git a/packages/backend/test/api-visibility.ts b/packages/backend/test/_e2e/api-visibility.ts similarity index 74% rename from packages/backend/test/api-visibility.ts rename to packages/backend/test/_e2e/api-visibility.ts index b155549f9..9c2184084 100644 --- a/packages/backend/test/api-visibility.ts +++ b/packages/backend/test/_e2e/api-visibility.ts @@ -2,20 +2,20 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import * as childProcess from 'child_process'; -import { async, signup, request, post, startServer, shutdownServer } from './utils.js'; +import { signup, request, post, startServer, shutdownServer } from '../utils.js'; describe('API visibility', () => { let p: childProcess.ChildProcess; - before(async () => { + beforeAll(async () => { p = await startServer(); - }); + }, 1000 * 30); - after(async () => { + afterAll(async () => { await shutdownServer(p); }); - describe('Note visibility', async () => { + describe('Note visibility', () => { //#region vars /** ヒロイン */ let alice: any; @@ -65,7 +65,7 @@ describe('API visibility', () => { }, by); }; - before(async () => { + beforeAll(async () => { //#region prepare // signup alice = await signup({ username: 'alice' }); @@ -100,377 +100,378 @@ describe('API visibility', () => { //#region show post // public - it('[show] public-postを自分が見れる', async(async () => { + it('[show] public-postを自分が見れる', async () => { const res = await show(pub.id, alice); assert.strictEqual(res.body.text, 'x'); - })); + }); - it('[show] public-postをフォロワーが見れる', async(async () => { + it('[show] public-postをフォロワーが見れる', async () => { const res = await show(pub.id, follower); assert.strictEqual(res.body.text, 'x'); - })); + }); - it('[show] public-postを非フォロワーが見れる', async(async () => { + it('[show] public-postを非フォロワーが見れる', async () => { const res = await show(pub.id, other); assert.strictEqual(res.body.text, 'x'); - })); + }); - it('[show] public-postを未認証が見れる', async(async () => { + it('[show] public-postを未認証が見れる', async () => { const res = await show(pub.id, null); assert.strictEqual(res.body.text, 'x'); - })); + }); // home - it('[show] home-postを自分が見れる', async(async () => { + it('[show] home-postを自分が見れる', async () => { const res = await show(home.id, alice); assert.strictEqual(res.body.text, 'x'); - })); + }); - it('[show] home-postをフォロワーが見れる', async(async () => { + it('[show] home-postをフォロワーが見れる', async () => { const res = await show(home.id, follower); assert.strictEqual(res.body.text, 'x'); - })); + }); - it('[show] home-postを非フォロワーが見れる', async(async () => { + it('[show] home-postを非フォロワーが見れる', async () => { const res = await show(home.id, other); assert.strictEqual(res.body.text, 'x'); - })); + }); - it('[show] home-postを未認証が見れる', async(async () => { + it('[show] home-postを未認証が見れる', async () => { const res = await show(home.id, null); assert.strictEqual(res.body.text, 'x'); - })); + }); // followers - it('[show] followers-postを自分が見れる', async(async () => { + it('[show] followers-postを自分が見れる', async () => { const res = await show(fol.id, alice); assert.strictEqual(res.body.text, 'x'); - })); + }); - it('[show] followers-postをフォロワーが見れる', async(async () => { + it('[show] followers-postをフォロワーが見れる', async () => { const res = await show(fol.id, follower); assert.strictEqual(res.body.text, 'x'); - })); + }); - it('[show] followers-postを非フォロワーが見れない', async(async () => { + it('[show] followers-postを非フォロワーが見れない', async () => { const res = await show(fol.id, other); assert.strictEqual(res.body.isHidden, true); - })); + }); - it('[show] followers-postを未認証が見れない', async(async () => { + it('[show] followers-postを未認証が見れない', async () => { const res = await show(fol.id, null); assert.strictEqual(res.body.isHidden, true); - })); + }); // specified - it('[show] specified-postを自分が見れる', async(async () => { + it('[show] specified-postを自分が見れる', async () => { const res = await show(spe.id, alice); assert.strictEqual(res.body.text, 'x'); - })); + }); - it('[show] specified-postを指定ユーザーが見れる', async(async () => { + it('[show] specified-postを指定ユーザーが見れる', async () => { const res = await show(spe.id, target); assert.strictEqual(res.body.text, 'x'); - })); + }); - it('[show] specified-postをフォロワーが見れない', async(async () => { + it('[show] specified-postをフォロワーが見れない', async () => { const res = await show(spe.id, follower); assert.strictEqual(res.body.isHidden, true); - })); + }); - it('[show] specified-postを非フォロワーが見れない', async(async () => { + it('[show] specified-postを非フォロワーが見れない', async () => { const res = await show(spe.id, other); assert.strictEqual(res.body.isHidden, true); - })); + }); - it('[show] specified-postを未認証が見れない', async(async () => { + it('[show] specified-postを未認証が見れない', async () => { const res = await show(spe.id, null); assert.strictEqual(res.body.isHidden, true); - })); + }); //#endregion //#region show reply // public - it('[show] public-replyを自分が見れる', async(async () => { + it('[show] public-replyを自分が見れる', async () => { const res = await show(pubR.id, alice); assert.strictEqual(res.body.text, 'x'); - })); + }); - it('[show] public-replyをされた人が見れる', async(async () => { + it('[show] public-replyをされた人が見れる', async () => { const res = await show(pubR.id, target); assert.strictEqual(res.body.text, 'x'); - })); + }); - it('[show] public-replyをフォロワーが見れる', async(async () => { + it('[show] public-replyをフォロワーが見れる', async () => { const res = await show(pubR.id, follower); assert.strictEqual(res.body.text, 'x'); - })); + }); - it('[show] public-replyを非フォロワーが見れる', async(async () => { + it('[show] public-replyを非フォロワーが見れる', async () => { const res = await show(pubR.id, other); assert.strictEqual(res.body.text, 'x'); - })); + }); - it('[show] public-replyを未認証が見れる', async(async () => { + it('[show] public-replyを未認証が見れる', async () => { const res = await show(pubR.id, null); assert.strictEqual(res.body.text, 'x'); - })); + }); // home - it('[show] home-replyを自分が見れる', async(async () => { + it('[show] home-replyを自分が見れる', async () => { const res = await show(homeR.id, alice); assert.strictEqual(res.body.text, 'x'); - })); + }); - it('[show] home-replyをされた人が見れる', async(async () => { + it('[show] home-replyをされた人が見れる', async () => { const res = await show(homeR.id, target); assert.strictEqual(res.body.text, 'x'); - })); + }); - it('[show] home-replyをフォロワーが見れる', async(async () => { + it('[show] home-replyをフォロワーが見れる', async () => { const res = await show(homeR.id, follower); assert.strictEqual(res.body.text, 'x'); - })); + }); - it('[show] home-replyを非フォロワーが見れる', async(async () => { + it('[show] home-replyを非フォロワーが見れる', async () => { const res = await show(homeR.id, other); assert.strictEqual(res.body.text, 'x'); - })); + }); - it('[show] home-replyを未認証が見れる', async(async () => { + it('[show] home-replyを未認証が見れる', async () => { const res = await show(homeR.id, null); assert.strictEqual(res.body.text, 'x'); - })); + }); // followers - it('[show] followers-replyを自分が見れる', async(async () => { + it('[show] followers-replyを自分が見れる', async () => { const res = await show(folR.id, alice); assert.strictEqual(res.body.text, 'x'); - })); + }); - it('[show] followers-replyを非フォロワーでもリプライされていれば見れる', async(async () => { + it('[show] followers-replyを非フォロワーでもリプライされていれば見れる', async () => { const res = await show(folR.id, target); assert.strictEqual(res.body.text, 'x'); - })); + }); - it('[show] followers-replyをフォロワーが見れる', async(async () => { + it('[show] followers-replyをフォロワーが見れる', async () => { const res = await show(folR.id, follower); assert.strictEqual(res.body.text, 'x'); - })); + }); - it('[show] followers-replyを非フォロワーが見れない', async(async () => { + it('[show] followers-replyを非フォロワーが見れない', async () => { const res = await show(folR.id, other); assert.strictEqual(res.body.isHidden, true); - })); + }); - it('[show] followers-replyを未認証が見れない', async(async () => { + it('[show] followers-replyを未認証が見れない', async () => { const res = await show(folR.id, null); assert.strictEqual(res.body.isHidden, true); - })); + }); // specified - it('[show] specified-replyを自分が見れる', async(async () => { + it('[show] specified-replyを自分が見れる', async () => { const res = await show(speR.id, alice); assert.strictEqual(res.body.text, 'x'); - })); + }); - it('[show] specified-replyを指定ユーザーが見れる', async(async () => { + it('[show] specified-replyを指定ユーザーが見れる', async () => { const res = await show(speR.id, target); assert.strictEqual(res.body.text, 'x'); - })); + }); - it('[show] specified-replyをされた人が指定されてなくても見れる', async(async () => { + it('[show] specified-replyをされた人が指定されてなくても見れる', async () => { const res = await show(speR.id, target); assert.strictEqual(res.body.text, 'x'); - })); + }); - it('[show] specified-replyをフォロワーが見れない', async(async () => { + it('[show] specified-replyをフォロワーが見れない', async () => { const res = await show(speR.id, follower); assert.strictEqual(res.body.isHidden, true); - })); + }); - it('[show] specified-replyを非フォロワーが見れない', async(async () => { + it('[show] specified-replyを非フォロワーが見れない', async () => { const res = await show(speR.id, other); assert.strictEqual(res.body.isHidden, true); - })); + }); - it('[show] specified-replyを未認証が見れない', async(async () => { + it('[show] specified-replyを未認証が見れない', async () => { const res = await show(speR.id, null); assert.strictEqual(res.body.isHidden, true); - })); + }); //#endregion //#region show mention // public - it('[show] public-mentionを自分が見れる', async(async () => { + it('[show] public-mentionを自分が見れる', async () => { const res = await show(pubM.id, alice); assert.strictEqual(res.body.text, '@target x'); - })); + }); - it('[show] public-mentionをされた人が見れる', async(async () => { + it('[show] public-mentionをされた人が見れる', async () => { const res = await show(pubM.id, target); assert.strictEqual(res.body.text, '@target x'); - })); + }); - it('[show] public-mentionをフォロワーが見れる', async(async () => { + it('[show] public-mentionをフォロワーが見れる', async () => { const res = await show(pubM.id, follower); assert.strictEqual(res.body.text, '@target x'); - })); + }); - it('[show] public-mentionを非フォロワーが見れる', async(async () => { + it('[show] public-mentionを非フォロワーが見れる', async () => { const res = await show(pubM.id, other); assert.strictEqual(res.body.text, '@target x'); - })); + }); - it('[show] public-mentionを未認証が見れる', async(async () => { + it('[show] public-mentionを未認証が見れる', async () => { const res = await show(pubM.id, null); assert.strictEqual(res.body.text, '@target x'); - })); + }); // home - it('[show] home-mentionを自分が見れる', async(async () => { + it('[show] home-mentionを自分が見れる', async () => { const res = await show(homeM.id, alice); assert.strictEqual(res.body.text, '@target x'); - })); + }); - it('[show] home-mentionをされた人が見れる', async(async () => { + it('[show] home-mentionをされた人が見れる', async () => { const res = await show(homeM.id, target); assert.strictEqual(res.body.text, '@target x'); - })); + }); - it('[show] home-mentionをフォロワーが見れる', async(async () => { + it('[show] home-mentionをフォロワーが見れる', async () => { const res = await show(homeM.id, follower); assert.strictEqual(res.body.text, '@target x'); - })); + }); - it('[show] home-mentionを非フォロワーが見れる', async(async () => { + it('[show] home-mentionを非フォロワーが見れる', async () => { const res = await show(homeM.id, other); assert.strictEqual(res.body.text, '@target x'); - })); + }); - it('[show] home-mentionを未認証が見れる', async(async () => { + it('[show] home-mentionを未認証が見れる', async () => { const res = await show(homeM.id, null); assert.strictEqual(res.body.text, '@target x'); - })); + }); // followers - it('[show] followers-mentionを自分が見れる', async(async () => { + it('[show] followers-mentionを自分が見れる', async () => { const res = await show(folM.id, alice); assert.strictEqual(res.body.text, '@target x'); - })); + }); - it('[show] followers-mentionをメンションされていれば非フォロワーでも見れる', async(async () => { + it('[show] followers-mentionをメンションされていれば非フォロワーでも見れる', async () => { const res = await show(folM.id, target); assert.strictEqual(res.body.text, '@target x'); - })); + }); - it('[show] followers-mentionをフォロワーが見れる', async(async () => { + it('[show] followers-mentionをフォロワーが見れる', async () => { const res = await show(folM.id, follower); assert.strictEqual(res.body.text, '@target x'); - })); + }); - it('[show] followers-mentionを非フォロワーが見れない', async(async () => { + it('[show] followers-mentionを非フォロワーが見れない', async () => { const res = await show(folM.id, other); assert.strictEqual(res.body.isHidden, true); - })); + }); - it('[show] followers-mentionを未認証が見れない', async(async () => { + it('[show] followers-mentionを未認証が見れない', async () => { const res = await show(folM.id, null); assert.strictEqual(res.body.isHidden, true); - })); + }); // specified - it('[show] specified-mentionを自分が見れる', async(async () => { + it('[show] specified-mentionを自分が見れる', async () => { const res = await show(speM.id, alice); assert.strictEqual(res.body.text, '@target2 x'); - })); + }); - it('[show] specified-mentionを指定ユーザーが見れる', async(async () => { + it('[show] specified-mentionを指定ユーザーが見れる', async () => { const res = await show(speM.id, target); assert.strictEqual(res.body.text, '@target2 x'); - })); + }); - it('[show] specified-mentionをされた人が指定されてなかったら見れない', async(async () => { + it('[show] specified-mentionをされた人が指定されてなかったら見れない', async () => { const res = await show(speM.id, target2); assert.strictEqual(res.body.isHidden, true); - })); + }); - it('[show] specified-mentionをフォロワーが見れない', async(async () => { + it('[show] specified-mentionをフォロワーが見れない', async () => { const res = await show(speM.id, follower); assert.strictEqual(res.body.isHidden, true); - })); + }); - it('[show] specified-mentionを非フォロワーが見れない', async(async () => { + it('[show] specified-mentionを非フォロワーが見れない', async () => { const res = await show(speM.id, other); assert.strictEqual(res.body.isHidden, true); - })); + }); - it('[show] specified-mentionを未認証が見れない', async(async () => { + it('[show] specified-mentionを未認証が見れない', async () => { const res = await show(speM.id, null); assert.strictEqual(res.body.isHidden, true); - })); + }); //#endregion //#region HTL - it('[HTL] public-post が 自分が見れる', async(async () => { + it('[HTL] public-post が 自分が見れる', async () => { const res = await request('/notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id == pub.id); + const notes = res.body.filter((n: any) => n.id === pub.id); assert.strictEqual(notes[0].text, 'x'); - })); + }); - it('[HTL] public-post が 非フォロワーから見れない', async(async () => { + it('[HTL] public-post が 非フォロワーから見れない', async () => { const res = await request('/notes/timeline', { limit: 100 }, other); assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id == pub.id); + const notes = res.body.filter((n: any) => n.id === pub.id); assert.strictEqual(notes.length, 0); - })); + }); - it('[HTL] followers-post が フォロワーから見れる', async(async () => { + it('[HTL] followers-post が フォロワーから見れる', async () => { const res = await request('/notes/timeline', { limit: 100 }, follower); assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id == fol.id); + const notes = res.body.filter((n: any) => n.id === fol.id); assert.strictEqual(notes[0].text, 'x'); - })); + }); //#endregion //#region RTL - it('[replies] followers-reply が フォロワーから見れる', async(async () => { + it('[replies] followers-reply が フォロワーから見れる', async () => { const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, follower); assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id == folR.id); + const notes = res.body.filter((n: any) => n.id === folR.id); assert.strictEqual(notes[0].text, 'x'); - })); + }); - it('[replies] followers-reply が 非フォロワー (リプライ先ではない) から見れない', async(async () => { + it('[replies] followers-reply が 非フォロワー (リプライ先ではない) から見れない', async () => { const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, other); assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id == folR.id); + const notes = res.body.filter((n: any) => n.id === folR.id); assert.strictEqual(notes.length, 0); - })); + }); - it('[replies] followers-reply が 非フォロワー (リプライ先である) から見れる', async(async () => { + it('[replies] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => { const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, target); assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id == folR.id); + const notes = res.body.filter((n: any) => n.id === folR.id); assert.strictEqual(notes[0].text, 'x'); - })); + }); //#endregion //#region MTL - it('[mentions] followers-reply が 非フォロワー (リプライ先である) から見れる', async(async () => { + it('[mentions] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => { const res = await request('/notes/mentions', { limit: 100 }, target); assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id == folR.id); + const notes = res.body.filter((n: any) => n.id === folR.id); assert.strictEqual(notes[0].text, 'x'); - })); + }); - it('[mentions] followers-mention が 非フォロワー (メンション先である) から見れる', async(async () => { + it('[mentions] followers-mention が 非フォロワー (メンション先である) から見れる', async () => { const res = await request('/notes/mentions', { limit: 100 }, target); assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id == folM.id); + const notes = res.body.filter((n: any) => n.id === folM.id); assert.strictEqual(notes[0].text, '@target x'); - })); + }); //#endregion }); }); +*/ diff --git a/packages/backend/test/api.ts b/packages/backend/test/_e2e/api.ts similarity index 94% rename from packages/backend/test/api.ts rename to packages/backend/test/_e2e/api.ts index b1b2ecafc..3c0802203 100644 --- a/packages/backend/test/api.ts +++ b/packages/backend/test/_e2e/api.ts @@ -2,7 +2,7 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import * as childProcess from 'child_process'; -import { async, signup, request, post, react, uploadFile, startServer, shutdownServer } from './utils.js'; +import { async, signup, request, post, react, uploadFile, startServer, shutdownServer } from '../utils.js'; describe('API', () => { let p: childProcess.ChildProcess; @@ -10,14 +10,14 @@ describe('API', () => { let bob: any; let carol: any; - before(async () => { + beforeAll(async () => { p = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); - }); + }, 1000 * 30); - after(async () => { + afterAll(async () => { await shutdownServer(p); }); diff --git a/packages/backend/test/block.ts b/packages/backend/test/_e2e/block.ts similarity index 86% rename from packages/backend/test/block.ts rename to packages/backend/test/_e2e/block.ts index b3343813c..bb31983a3 100644 --- a/packages/backend/test/block.ts +++ b/packages/backend/test/_e2e/block.ts @@ -2,7 +2,7 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import * as childProcess from 'child_process'; -import { async, signup, request, post, startServer, shutdownServer } from './utils.js'; +import { signup, request, post, startServer, shutdownServer } from '../utils.js'; describe('Block', () => { let p: childProcess.ChildProcess; @@ -12,64 +12,64 @@ describe('Block', () => { let bob: any; let carol: any; - before(async () => { + beforeAll(async () => { p = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); - }); + }, 1000 * 30); - after(async () => { + afterAll(async () => { await shutdownServer(p); }); - it('Block作成', async(async () => { + it('Block作成', async () => { const res = await request('/blocking/create', { userId: bob.id, }, alice); assert.strictEqual(res.status, 200); - })); + }); - it('ブロックされているユーザーをフォローできない', async(async () => { + it('ブロックされているユーザーをフォローできない', async () => { const res = await request('/following/create', { userId: alice.id }, bob); assert.strictEqual(res.status, 400); assert.strictEqual(res.body.error.id, 'c4ab57cc-4e41-45e9-bfd9-584f61e35ce0'); - })); + }); - it('ブロックされているユーザーにリアクションできない', async(async () => { + it('ブロックされているユーザーにリアクションできない', async () => { const note = await post(alice, { text: 'hello' }); const res = await request('/notes/reactions/create', { noteId: note.id, reaction: '👍' }, bob); assert.strictEqual(res.status, 400); assert.strictEqual(res.body.error.id, '20ef5475-9f38-4e4c-bd33-de6d979498ec'); - })); + }); - it('ブロックされているユーザーに返信できない', async(async () => { + it('ブロックされているユーザーに返信できない', async () => { const note = await post(alice, { text: 'hello' }); const res = await request('/notes/create', { replyId: note.id, text: 'yo' }, bob); assert.strictEqual(res.status, 400); assert.strictEqual(res.body.error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3'); - })); + }); - it('ブロックされているユーザーのノートをRenoteできない', async(async () => { + it('ブロックされているユーザーのノートをRenoteできない', async () => { const note = await post(alice, { text: 'hello' }); const res = await request('/notes/create', { renoteId: note.id, text: 'yo' }, bob); assert.strictEqual(res.status, 400); assert.strictEqual(res.body.error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3'); - })); + }); // TODO: ユーザーリストに入れられないテスト // TODO: ユーザーリストから除外されるテスト - it('タイムライン(LTL)にブロックされているユーザーの投稿が含まれない', async(async () => { + it('タイムライン(LTL)にブロックされているユーザーの投稿が含まれない', async () => { const aliceNote = await post(alice); const bobNote = await post(bob); const carolNote = await post(carol); @@ -81,5 +81,5 @@ describe('Block', () => { assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), false); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); - })); + }); }); diff --git a/packages/backend/test/endpoints.ts b/packages/backend/test/_e2e/endpoints.ts similarity index 56% rename from packages/backend/test/endpoints.ts rename to packages/backend/test/_e2e/endpoints.ts index 2aedc25f2..05b74a65d 100644 --- a/packages/backend/test/endpoints.ts +++ b/packages/backend/test/_e2e/endpoints.ts @@ -1,3 +1,382 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import * as childProcess from 'child_process'; +import * as openapi from '@redocly/openapi-core'; +import { startServer, signup, post, request, simpleGet, port, shutdownServer, api } from '../utils.js'; + +describe('Endpoints', () => { + let p: childProcess.ChildProcess; + + let alice: any; + let bob: any; + + beforeAll(async () => { + p = await startServer(); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + }, 1000 * 30); + + afterAll(async () => { + await shutdownServer(p); + }); + + describe('signup', () => { + it('不正なユーザー名でアカウントが作成できない', async () => { + const res = await request('api/signup', { + username: 'test.', + password: 'test', + }); + assert.strictEqual(res.status, 400); + }); + + it('空のパスワードでアカウントが作成できない', async () => { + const res = await request('api/signup', { + username: 'test', + password: '', + }); + assert.strictEqual(res.status, 400); + }); + + it('正しくアカウントが作成できる', async () => { + const me = { + username: 'test1', + password: 'test1', + }; + + const res = await request('api/signup', me); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.username, me.username); + }); + + it('同じユーザー名のアカウントは作成できない', async () => { + const res = await request('api/signup', { + username: 'test1', + password: 'test1', + }); + + assert.strictEqual(res.status, 400); + }); + }); + + describe('signin', () => { + it('間違ったパスワードでサインインできない', async () => { + const res = await request('api/signin', { + username: 'test1', + password: 'bar', + }); + + assert.strictEqual(res.status, 403); + }); + + it('クエリをインジェクションできない', async () => { + const res = await request('api/signin', { + username: 'test1', + password: { + $gt: '', + }, + }); + + assert.strictEqual(res.status, 400); + }); + + it('正しい情報でサインインできる', async () => { + const res = await request('api/signin', { + username: 'test1', + password: 'test1', + }); + + assert.strictEqual(res.status, 200); + }); + }); + + describe('i/update', () => { + it('アカウント設定を更新できる', async () => { + const myName = '大室櫻子'; + const myLocation = '七森中'; + const myBirthday = '2000-09-07'; + + const res = await api('/i/update', { + name: myName, + location: myLocation, + birthday: myBirthday, + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.name, myName); + assert.strictEqual(res.body.location, myLocation); + assert.strictEqual(res.body.birthday, myBirthday); + }); + + it('名前を空白にできない', async () => { + const res = await api('/i/update', { + name: ' ', + }, alice); + assert.strictEqual(res.status, 400); + }); + + it('誕生日の設定を削除できる', async () => { + await api('/i/update', { + birthday: '2000-09-07', + }, alice); + + const res = await api('/i/update', { + birthday: null, + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.birthday, null); + }); + + it('不正な誕生日の形式で怒られる', async () => { + const res = await api('/i/update', { + birthday: '2000/09/07', + }, alice); + assert.strictEqual(res.status, 400); + }); + }); + + describe('users/show', () => { + it('ユーザーが取得できる', async () => { + const res = await api('/users/show', { + userId: alice.id, + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.id, alice.id); + }); + + it('ユーザーが存在しなかったら怒る', async () => { + const res = await api('/users/show', { + userId: '000000000000000000000000', + }); + assert.strictEqual(res.status, 400); + }); + + it('間違ったIDで怒られる', async () => { + const res = await api('/users/show', { + userId: 'kyoppie', + }); + assert.strictEqual(res.status, 400); + }); + }); + + describe('notes/show', () => { + it('投稿が取得できる', async () => { + const myPost = await post(alice, { + text: 'test', + }); + + const res = await api('/notes/show', { + noteId: myPost.id, + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.id, myPost.id); + assert.strictEqual(res.body.text, myPost.text); + }); + + it('投稿が存在しなかったら怒る', async () => { + const res = await api('/notes/show', { + noteId: '000000000000000000000000', + }); + assert.strictEqual(res.status, 400); + }); + + it('間違ったIDで怒られる', async () => { + const res = await api('/notes/show', { + noteId: 'kyoppie', + }); + assert.strictEqual(res.status, 400); + }); + }); + + describe('notes/reactions/create', () => { + it('リアクションできる', async () => { + const bobPost = await post(bob); + + const alice = await signup({ username: 'alice' }); + const res = await api('/notes/reactions/create', { + noteId: bobPost.id, + reaction: '🚀', + }, alice); + + assert.strictEqual(res.status, 204); + + const resNote = await api('/notes/show', { + noteId: bobPost.id, + }, alice); + + assert.strictEqual(resNote.status, 200); + assert.strictEqual(resNote.body.reactions['🚀'], [alice.id]); + }); + + it('自分の投稿にもリアクションできる', async () => { + const myPost = await post(alice); + + const res = await api('/notes/reactions/create', { + noteId: myPost.id, + reaction: '🚀', + }, alice); + + assert.strictEqual(res.status, 204); + }); + + it('二重にリアクションできない', async () => { + const bobPost = await post(bob); + + await api('/notes/reactions/create', { + noteId: bobPost.id, + reaction: '🥰', + }, alice); + + const res = await api('/notes/reactions/create', { + noteId: bobPost.id, + reaction: '🚀', + }, alice); + + assert.strictEqual(res.status, 400); + }); + + it('存在しない投稿にはリアクションできない', async () => { + const res = await api('/notes/reactions/create', { + noteId: '000000000000000000000000', + reaction: '🚀', + }, alice); + + assert.strictEqual(res.status, 400); + }); + + it('空のパラメータで怒られる', async () => { + const res = await api('/notes/reactions/create', {}, alice); + + assert.strictEqual(res.status, 400); + }); + + it('間違ったIDで怒られる', async () => { + const res = await api('/notes/reactions/create', { + noteId: 'kyoppie', + reaction: '🚀', + }, alice); + + assert.strictEqual(res.status, 400); + }); + }); + + describe('following/create', () => { + it('フォローできる', async () => { + const res = await api('/following/create', { + userId: alice.id, + }, bob); + + assert.strictEqual(res.status, 200); + }); + + it('既にフォローしている場合は怒る', async () => { + const res = await api('/following/create', { + userId: alice.id, + }, bob); + + assert.strictEqual(res.status, 400); + }); + + it('存在しないユーザーはフォローできない', async () => { + const res = await api('/following/create', { + userId: '000000000000000000000000', + }, alice); + + assert.strictEqual(res.status, 400); + }); + + it('自分自身はフォローできない', async () => { + const res = await api('/following/create', { + userId: alice.id, + }, alice); + + assert.strictEqual(res.status, 400); + }); + + it('空のパラメータで怒られる', async () => { + const res = await api('/following/create', {}, alice); + + assert.strictEqual(res.status, 400); + }); + + it('間違ったIDで怒られる', async () => { + const res = await api('/following/create', { + userId: 'foo', + }, alice); + + assert.strictEqual(res.status, 400); + }); + }); + + describe('following/delete', () => { + it('フォロー解除できる', async () => { + await api('/following/create', { + userId: alice.id, + }, bob); + + const res = await api('/following/delete', { + userId: alice.id, + }, bob); + + assert.strictEqual(res.status, 200); + }); + + it('フォローしていない場合は怒る', async () => { + const res = await api('/following/delete', { + userId: alice.id, + }, bob); + + assert.strictEqual(res.status, 400); + }); + + it('存在しないユーザーはフォロー解除できない', async () => { + const res = await api('/following/delete', { + userId: '000000000000000000000000', + }, alice); + + assert.strictEqual(res.status, 400); + }); + + it('自分自身はフォロー解除できない', async () => { + const res = await api('/following/delete', { + userId: alice.id, + }, alice); + + assert.strictEqual(res.status, 400); + }); + + it('空のパラメータで怒られる', async () => { + const res = await api('/following/delete', {}, alice); + + assert.strictEqual(res.status, 400); + }); + + it('間違ったIDで怒られる', async () => { + const res = await api('/following/delete', { + userId: 'kyoppie', + }, alice); + + assert.strictEqual(res.status, 400); + }); + }); + + /* + describe('/i', () => { + it('', async () => { + }); + }); + */ +}); + /* process.env.NODE_ENV = 'test'; @@ -22,371 +401,8 @@ describe('API: Endpoints', () => { await shutdownServer(p); }); - describe('signup', () => { - it('不正なユーザー名でアカウントが作成できない', async(async () => { - const res = await request('/signup', { - username: 'test.', - password: 'test' - }); - assert.strictEqual(res.status, 400); - })); - - it('空のパスワードでアカウントが作成できない', async(async () => { - const res = await request('/signup', { - username: 'test', - password: '' - }); - assert.strictEqual(res.status, 400); - })); - - it('正しくアカウントが作成できる', async(async () => { - const me = { - username: 'test1', - password: 'test1' - }; - - const res = await request('/signup', me); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.username, me.username); - })); - - it('同じユーザー名のアカウントは作成できない', async(async () => { - await signup({ - username: 'test2' - }); - - const res = await request('/signup', { - username: 'test2', - password: 'test2' - }); - - assert.strictEqual(res.status, 400); - })); - }); - - describe('signin', () => { - it('間違ったパスワードでサインインできない', async(async () => { - await signup({ - username: 'test3', - password: 'foo' - }); - - const res = await request('/signin', { - username: 'test3', - password: 'bar' - }); - - assert.strictEqual(res.status, 403); - })); - - it('クエリをインジェクションできない', async(async () => { - await signup({ - username: 'test4' - }); - - const res = await request('/signin', { - username: 'test4', - password: { - $gt: '' - } - }); - - assert.strictEqual(res.status, 400); - })); - - it('正しい情報でサインインできる', async(async () => { - await signup({ - username: 'test5', - password: 'foo' - }); - - const res = await request('/signin', { - username: 'test5', - password: 'foo' - }); - - assert.strictEqual(res.status, 200); - })); - }); - - describe('i/update', () => { - it('アカウント設定を更新できる', async(async () => { - const myName = '大室櫻子'; - const myLocation = '七森中'; - const myBirthday = '2000-09-07'; - - const res = await request('/i/update', { - name: myName, - location: myLocation, - birthday: myBirthday - }, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.name, myName); - assert.strictEqual(res.body.location, myLocation); - assert.strictEqual(res.body.birthday, myBirthday); - })); - - it('名前を空白にできない', async(async () => { - const res = await request('/i/update', { - name: ' ' - }, alice); - assert.strictEqual(res.status, 400); - })); - - it('誕生日の設定を削除できる', async(async () => { - await request('/i/update', { - birthday: '2000-09-07' - }, alice); - - const res = await request('/i/update', { - birthday: null - }, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.birthday, null); - })); - - it('不正な誕生日の形式で怒られる', async(async () => { - const res = await request('/i/update', { - birthday: '2000/09/07' - }, alice); - assert.strictEqual(res.status, 400); - })); - }); - - describe('users/show', () => { - it('ユーザーが取得できる', async(async () => { - const res = await request('/users/show', { - userId: alice.id - }, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.id, alice.id); - })); - - it('ユーザーが存在しなかったら怒る', async(async () => { - const res = await request('/users/show', { - userId: '000000000000000000000000' - }); - assert.strictEqual(res.status, 400); - })); - - it('間違ったIDで怒られる', async(async () => { - const res = await request('/users/show', { - userId: 'kyoppie' - }); - assert.strictEqual(res.status, 400); - })); - }); - - describe('notes/show', () => { - it('投稿が取得できる', async(async () => { - const myPost = await post(alice, { - text: 'test' - }); - - const res = await request('/notes/show', { - noteId: myPost.id - }, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.id, myPost.id); - assert.strictEqual(res.body.text, myPost.text); - })); - - it('投稿が存在しなかったら怒る', async(async () => { - const res = await request('/notes/show', { - noteId: '000000000000000000000000' - }); - assert.strictEqual(res.status, 400); - })); - - it('間違ったIDで怒られる', async(async () => { - const res = await request('/notes/show', { - noteId: 'kyoppie' - }); - assert.strictEqual(res.status, 400); - })); - }); - - describe('notes/reactions/create', () => { - it('リアクションできる', async(async () => { - const bobPost = await post(bob); - - const alice = await signup({ username: 'alice' }); - const res = await request('/notes/reactions/create', { - noteId: bobPost.id, - reaction: '🚀', - }, alice); - - assert.strictEqual(res.status, 204); - - const resNote = await request('/notes/show', { - noteId: bobPost.id, - }, alice); - - assert.strictEqual(resNote.status, 200); - assert.strictEqual(resNote.body.reactions['🚀'], [alice.id]); - })); - - it('自分の投稿にもリアクションできる', async(async () => { - const myPost = await post(alice); - - const res = await request('/notes/reactions/create', { - noteId: myPost.id, - reaction: '🚀', - }, alice); - - assert.strictEqual(res.status, 204); - })); - - it('二重にリアクションできない', async(async () => { - const bobPost = await post(bob); - - await react(alice, bobPost, 'like'); - - const res = await request('/notes/reactions/create', { - noteId: bobPost.id, - reaction: '🚀', - }, alice); - - assert.strictEqual(res.status, 400); - })); - - it('存在しない投稿にはリアクションできない', async(async () => { - const res = await request('/notes/reactions/create', { - noteId: '000000000000000000000000', - reaction: '🚀', - }, alice); - - assert.strictEqual(res.status, 400); - })); - - it('空のパラメータで怒られる', async(async () => { - const res = await request('/notes/reactions/create', {}, alice); - - assert.strictEqual(res.status, 400); - })); - - it('間違ったIDで怒られる', async(async () => { - const res = await request('/notes/reactions/create', { - noteId: 'kyoppie', - reaction: '🚀', - }, alice); - - assert.strictEqual(res.status, 400); - })); - }); - - describe('following/create', () => { - it('フォローできる', async(async () => { - const res = await request('/following/create', { - userId: alice.id - }, bob); - - assert.strictEqual(res.status, 200); - })); - - it('既にフォローしている場合は怒る', async(async () => { - const res = await request('/following/create', { - userId: alice.id - }, bob); - - assert.strictEqual(res.status, 400); - })); - - it('存在しないユーザーはフォローできない', async(async () => { - const res = await request('/following/create', { - userId: '000000000000000000000000' - }, alice); - - assert.strictEqual(res.status, 400); - })); - - it('自分自身はフォローできない', async(async () => { - const res = await request('/following/create', { - userId: alice.id - }, alice); - - assert.strictEqual(res.status, 400); - })); - - it('空のパラメータで怒られる', async(async () => { - const res = await request('/following/create', {}, alice); - - assert.strictEqual(res.status, 400); - })); - - it('間違ったIDで怒られる', async(async () => { - const res = await request('/following/create', { - userId: 'foo' - }, alice); - - assert.strictEqual(res.status, 400); - })); - }); - - describe('following/delete', () => { - it('フォロー解除できる', async(async () => { - await request('/following/create', { - userId: alice.id - }, bob); - - const res = await request('/following/delete', { - userId: alice.id - }, bob); - - assert.strictEqual(res.status, 200); - })); - - it('フォローしていない場合は怒る', async(async () => { - const res = await request('/following/delete', { - userId: alice.id - }, bob); - - assert.strictEqual(res.status, 400); - })); - - it('存在しないユーザーはフォロー解除できない', async(async () => { - const res = await request('/following/delete', { - userId: '000000000000000000000000' - }, alice); - - assert.strictEqual(res.status, 400); - })); - - it('自分自身はフォロー解除できない', async(async () => { - const res = await request('/following/delete', { - userId: alice.id - }, alice); - - assert.strictEqual(res.status, 400); - })); - - it('空のパラメータで怒られる', async(async () => { - const res = await request('/following/delete', {}, alice); - - assert.strictEqual(res.status, 400); - })); - - it('間違ったIDで怒られる', async(async () => { - const res = await request('/following/delete', { - userId: 'kyoppie' - }, alice); - - assert.strictEqual(res.status, 400); - })); - }); - describe('drive', () => { - it('ドライブ情報を取得できる', async(async () => { + it('ドライブ情報を取得できる', async () => { await uploadFile({ userId: alice.id, size: 256 @@ -399,7 +415,7 @@ describe('API: Endpoints', () => { userId: alice.id, size: 1024 }); - const res = await request('/drive', {}, alice); + const res = await api('/drive', {}, alice); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); expect(res.body).have.property('usage').eql(1792); @@ -407,7 +423,7 @@ describe('API: Endpoints', () => { }); describe('drive/files/create', () => { - it('ファイルを作成できる', async(async () => { + it('ファイルを作成できる', async () => { const res = await uploadFile(alice); assert.strictEqual(res.status, 200); @@ -415,7 +431,7 @@ describe('API: Endpoints', () => { assert.strictEqual(res.body.name, 'Lenna.png'); })); - it('ファイルに名前を付けられる', async(async () => { + it('ファイルに名前を付けられる', async () => { const res = await assert.request(server) .post('/drive/files/create') .field('i', alice.token) @@ -427,13 +443,13 @@ describe('API: Endpoints', () => { expect(res.body).have.property('name').eql('Belmond.png'); })); - it('ファイル無しで怒られる', async(async () => { - const res = await request('/drive/files/create', {}, alice); + it('ファイル無しで怒られる', async () => { + const res = await api('/drive/files/create', {}, alice); assert.strictEqual(res.status, 400); })); - it('SVGファイルを作成できる', async(async () => { + it('SVGファイルを作成できる', async () => { const res = await uploadFile(alice, __dirname + '/resources/image.svg'); assert.strictEqual(res.status, 200); @@ -444,11 +460,11 @@ describe('API: Endpoints', () => { }); describe('drive/files/update', () => { - it('名前を更新できる', async(async () => { + it('名前を更新できる', async () => { const file = await uploadFile(alice); const newName = 'いちごパスタ.png'; - const res = await request('/drive/files/update', { + const res = await api('/drive/files/update', { fileId: file.id, name: newName }, alice); @@ -458,10 +474,10 @@ describe('API: Endpoints', () => { assert.strictEqual(res.body.name, newName); })); - it('他人のファイルは更新できない', async(async () => { + it('他人のファイルは更新できない', async () => { const file = await uploadFile(bob); - const res = await request('/drive/files/update', { + const res = await api('/drive/files/update', { fileId: file.id, name: 'いちごパスタ.png' }, alice); @@ -469,13 +485,13 @@ describe('API: Endpoints', () => { assert.strictEqual(res.status, 400); })); - it('親フォルダを更新できる', async(async () => { + it('親フォルダを更新できる', async () => { const file = await uploadFile(alice); - const folder = (await request('/drive/folders/create', { + const folder = (await api('/drive/folders/create', { name: 'test' }, alice)).body; - const res = await request('/drive/files/update', { + const res = await api('/drive/files/update', { fileId: file.id, folderId: folder.id }, alice); @@ -485,19 +501,19 @@ describe('API: Endpoints', () => { assert.strictEqual(res.body.folderId, folder.id); })); - it('親フォルダを無しにできる', async(async () => { + it('親フォルダを無しにできる', async () => { const file = await uploadFile(alice); - const folder = (await request('/drive/folders/create', { + const folder = (await api('/drive/folders/create', { name: 'test' }, alice)).body; - await request('/drive/files/update', { + await api('/drive/files/update', { fileId: file.id, folderId: folder.id }, alice); - const res = await request('/drive/files/update', { + const res = await api('/drive/files/update', { fileId: file.id, folderId: null }, alice); @@ -507,13 +523,13 @@ describe('API: Endpoints', () => { assert.strictEqual(res.body.folderId, null); })); - it('他人のフォルダには入れられない', async(async () => { + it('他人のフォルダには入れられない', async () => { const file = await uploadFile(alice); - const folder = (await request('/drive/folders/create', { + const folder = (await api('/drive/folders/create', { name: 'test' }, bob)).body; - const res = await request('/drive/files/update', { + const res = await api('/drive/files/update', { fileId: file.id, folderId: folder.id }, alice); @@ -521,10 +537,10 @@ describe('API: Endpoints', () => { assert.strictEqual(res.status, 400); })); - it('存在しないフォルダで怒られる', async(async () => { + it('存在しないフォルダで怒られる', async () => { const file = await uploadFile(alice); - const res = await request('/drive/files/update', { + const res = await api('/drive/files/update', { fileId: file.id, folderId: '000000000000000000000000' }, alice); @@ -532,10 +548,10 @@ describe('API: Endpoints', () => { assert.strictEqual(res.status, 400); })); - it('不正なフォルダIDで怒られる', async(async () => { + it('不正なフォルダIDで怒られる', async () => { const file = await uploadFile(alice); - const res = await request('/drive/files/update', { + const res = await api('/drive/files/update', { fileId: file.id, folderId: 'foo' }, alice); @@ -543,8 +559,8 @@ describe('API: Endpoints', () => { assert.strictEqual(res.status, 400); })); - it('ファイルが存在しなかったら怒る', async(async () => { - const res = await request('/drive/files/update', { + it('ファイルが存在しなかったら怒る', async () => { + const res = await api('/drive/files/update', { fileId: '000000000000000000000000', name: 'いちごパスタ.png' }, alice); @@ -552,8 +568,8 @@ describe('API: Endpoints', () => { assert.strictEqual(res.status, 400); })); - it('間違ったIDで怒られる', async(async () => { - const res = await request('/drive/files/update', { + it('間違ったIDで怒られる', async () => { + const res = await api('/drive/files/update', { fileId: 'kyoppie', name: 'いちごパスタ.png' }, alice); @@ -563,8 +579,8 @@ describe('API: Endpoints', () => { }); describe('drive/folders/create', () => { - it('フォルダを作成できる', async(async () => { - const res = await request('/drive/folders/create', { + it('フォルダを作成できる', async () => { + const res = await api('/drive/folders/create', { name: 'test' }, alice); @@ -575,12 +591,12 @@ describe('API: Endpoints', () => { }); describe('drive/folders/update', () => { - it('名前を更新できる', async(async () => { - const folder = (await request('/drive/folders/create', { + it('名前を更新できる', async () => { + const folder = (await api('/drive/folders/create', { name: 'test' }, alice)).body; - const res = await request('/drive/folders/update', { + const res = await api('/drive/folders/update', { folderId: folder.id, name: 'new name' }, alice); @@ -590,12 +606,12 @@ describe('API: Endpoints', () => { assert.strictEqual(res.body.name, 'new name'); })); - it('他人のフォルダを更新できない', async(async () => { - const folder = (await request('/drive/folders/create', { + it('他人のフォルダを更新できない', async () => { + const folder = (await api('/drive/folders/create', { name: 'test' }, bob)).body; - const res = await request('/drive/folders/update', { + const res = await api('/drive/folders/update', { folderId: folder.id, name: 'new name' }, alice); @@ -603,15 +619,15 @@ describe('API: Endpoints', () => { assert.strictEqual(res.status, 400); })); - it('親フォルダを更新できる', async(async () => { - const folder = (await request('/drive/folders/create', { + it('親フォルダを更新できる', async () => { + const folder = (await api('/drive/folders/create', { name: 'test' }, alice)).body; - const parentFolder = (await request('/drive/folders/create', { + const parentFolder = (await api('/drive/folders/create', { name: 'parent' }, alice)).body; - const res = await request('/drive/folders/update', { + const res = await api('/drive/folders/update', { folderId: folder.id, parentId: parentFolder.id }, alice); @@ -621,19 +637,19 @@ describe('API: Endpoints', () => { assert.strictEqual(res.body.parentId, parentFolder.id); })); - it('親フォルダを無しに更新できる', async(async () => { - const folder = (await request('/drive/folders/create', { + it('親フォルダを無しに更新できる', async () => { + const folder = (await api('/drive/folders/create', { name: 'test' }, alice)).body; - const parentFolder = (await request('/drive/folders/create', { + const parentFolder = (await api('/drive/folders/create', { name: 'parent' }, alice)).body; - await request('/drive/folders/update', { + await api('/drive/folders/update', { folderId: folder.id, parentId: parentFolder.id }, alice); - const res = await request('/drive/folders/update', { + const res = await api('/drive/folders/update', { folderId: folder.id, parentId: null }, alice); @@ -643,15 +659,15 @@ describe('API: Endpoints', () => { assert.strictEqual(res.body.parentId, null); })); - it('他人のフォルダを親フォルダに設定できない', async(async () => { - const folder = (await request('/drive/folders/create', { + it('他人のフォルダを親フォルダに設定できない', async () => { + const folder = (await api('/drive/folders/create', { name: 'test' }, alice)).body; - const parentFolder = (await request('/drive/folders/create', { + const parentFolder = (await api('/drive/folders/create', { name: 'parent' }, bob)).body; - const res = await request('/drive/folders/update', { + const res = await api('/drive/folders/update', { folderId: folder.id, parentId: parentFolder.id }, alice); @@ -659,19 +675,19 @@ describe('API: Endpoints', () => { assert.strictEqual(res.status, 400); })); - it('フォルダが循環するような構造にできない', async(async () => { - const folder = (await request('/drive/folders/create', { + it('フォルダが循環するような構造にできない', async () => { + const folder = (await api('/drive/folders/create', { name: 'test' }, alice)).body; - const parentFolder = (await request('/drive/folders/create', { + const parentFolder = (await api('/drive/folders/create', { name: 'parent' }, alice)).body; - await request('/drive/folders/update', { + await api('/drive/folders/update', { folderId: parentFolder.id, parentId: folder.id }, alice); - const res = await request('/drive/folders/update', { + const res = await api('/drive/folders/update', { folderId: folder.id, parentId: parentFolder.id }, alice); @@ -679,26 +695,26 @@ describe('API: Endpoints', () => { assert.strictEqual(res.status, 400); })); - it('フォルダが循環するような構造にできない(再帰的)', async(async () => { - const folderA = (await request('/drive/folders/create', { + it('フォルダが循環するような構造にできない(再帰的)', async () => { + const folderA = (await api('/drive/folders/create', { name: 'test' }, alice)).body; - const folderB = (await request('/drive/folders/create', { + const folderB = (await api('/drive/folders/create', { name: 'test' }, alice)).body; - const folderC = (await request('/drive/folders/create', { + const folderC = (await api('/drive/folders/create', { name: 'test' }, alice)).body; - await request('/drive/folders/update', { + await api('/drive/folders/update', { folderId: folderB.id, parentId: folderA.id }, alice); - await request('/drive/folders/update', { + await api('/drive/folders/update', { folderId: folderC.id, parentId: folderB.id }, alice); - const res = await request('/drive/folders/update', { + const res = await api('/drive/folders/update', { folderId: folderA.id, parentId: folderC.id }, alice); @@ -706,12 +722,12 @@ describe('API: Endpoints', () => { assert.strictEqual(res.status, 400); })); - it('フォルダが循環するような構造にできない(自身)', async(async () => { - const folderA = (await request('/drive/folders/create', { + it('フォルダが循環するような構造にできない(自身)', async () => { + const folderA = (await api('/drive/folders/create', { name: 'test' }, alice)).body; - const res = await request('/drive/folders/update', { + const res = await api('/drive/folders/update', { folderId: folderA.id, parentId: folderA.id }, alice); @@ -719,12 +735,12 @@ describe('API: Endpoints', () => { assert.strictEqual(res.status, 400); })); - it('存在しない親フォルダを設定できない', async(async () => { - const folder = (await request('/drive/folders/create', { + it('存在しない親フォルダを設定できない', async () => { + const folder = (await api('/drive/folders/create', { name: 'test' }, alice)).body; - const res = await request('/drive/folders/update', { + const res = await api('/drive/folders/update', { folderId: folder.id, parentId: '000000000000000000000000' }, alice); @@ -732,12 +748,12 @@ describe('API: Endpoints', () => { assert.strictEqual(res.status, 400); })); - it('不正な親フォルダIDで怒られる', async(async () => { - const folder = (await request('/drive/folders/create', { + it('不正な親フォルダIDで怒られる', async () => { + const folder = (await api('/drive/folders/create', { name: 'test' }, alice)).body; - const res = await request('/drive/folders/update', { + const res = await api('/drive/folders/update', { folderId: folder.id, parentId: 'foo' }, alice); @@ -745,16 +761,16 @@ describe('API: Endpoints', () => { assert.strictEqual(res.status, 400); })); - it('存在しないフォルダを更新できない', async(async () => { - const res = await request('/drive/folders/update', { + it('存在しないフォルダを更新できない', async () => { + const res = await api('/drive/folders/update', { folderId: '000000000000000000000000' }, alice); assert.strictEqual(res.status, 400); })); - it('不正なフォルダIDで怒られる', async(async () => { - const res = await request('/drive/folders/update', { + it('不正なフォルダIDで怒られる', async () => { + const res = await api('/drive/folders/update', { folderId: 'foo' }, alice); @@ -763,8 +779,8 @@ describe('API: Endpoints', () => { }); describe('messaging/messages/create', () => { - it('メッセージを送信できる', async(async () => { - const res = await request('/messaging/messages/create', { + it('メッセージを送信できる', async () => { + const res = await api('/messaging/messages/create', { userId: bob.id, text: 'test' }, alice); @@ -774,8 +790,8 @@ describe('API: Endpoints', () => { assert.strictEqual(res.body.text, 'test'); })); - it('自分自身にはメッセージを送信できない', async(async () => { - const res = await request('/messaging/messages/create', { + it('自分自身にはメッセージを送信できない', async () => { + const res = await api('/messaging/messages/create', { userId: alice.id, text: 'Yo' }, alice); @@ -783,8 +799,8 @@ describe('API: Endpoints', () => { assert.strictEqual(res.status, 400); })); - it('存在しないユーザーにはメッセージを送信できない', async(async () => { - const res = await request('/messaging/messages/create', { + it('存在しないユーザーにはメッセージを送信できない', async () => { + const res = await api('/messaging/messages/create', { userId: '000000000000000000000000', text: 'test' }, alice); @@ -792,8 +808,8 @@ describe('API: Endpoints', () => { assert.strictEqual(res.status, 400); })); - it('不正なユーザーIDで怒られる', async(async () => { - const res = await request('/messaging/messages/create', { + it('不正なユーザーIDで怒られる', async () => { + const res = await api('/messaging/messages/create', { userId: 'foo', text: 'test' }, alice); @@ -801,16 +817,16 @@ describe('API: Endpoints', () => { assert.strictEqual(res.status, 400); })); - it('テキストが無くて怒られる', async(async () => { - const res = await request('/messaging/messages/create', { + it('テキストが無くて怒られる', async () => { + const res = await api('/messaging/messages/create', { userId: bob.id }, alice); assert.strictEqual(res.status, 400); })); - it('文字数オーバーで怒られる', async(async () => { - const res = await request('/messaging/messages/create', { + it('文字数オーバーで怒られる', async () => { + const res = await api('/messaging/messages/create', { userId: bob.id, text: '!'.repeat(1001) }, alice); @@ -820,7 +836,7 @@ describe('API: Endpoints', () => { }); describe('notes/replies', () => { - it('自分に閲覧権限のない投稿は含まれない', async(async () => { + it('自分に閲覧権限のない投稿は含まれない', async () => { const alicePost = await post(alice, { text: 'foo' }); @@ -832,7 +848,7 @@ describe('API: Endpoints', () => { visibleUserIds: [alice.id] }); - const res = await request('/notes/replies', { + const res = await api('/notes/replies', { noteId: alicePost.id }, carol); @@ -843,8 +859,8 @@ describe('API: Endpoints', () => { }); describe('notes/timeline', () => { - it('フォロワー限定投稿が含まれる', async(async () => { - await request('/following/create', { + it('フォロワー限定投稿が含まれる', async () => { + await api('/following/create', { userId: alice.id }, bob); @@ -853,7 +869,7 @@ describe('API: Endpoints', () => { visibility: 'followers' }); - const res = await request('/notes/timeline', {}, bob); + const res = await api('/notes/timeline', {}, bob); assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); diff --git a/packages/backend/test/fetch-resource.ts b/packages/backend/test/_e2e/fetch-resource.ts similarity index 77% rename from packages/backend/test/fetch-resource.ts rename to packages/backend/test/_e2e/fetch-resource.ts index ddb0e94b8..344022dec 100644 --- a/packages/backend/test/fetch-resource.ts +++ b/packages/backend/test/_e2e/fetch-resource.ts @@ -3,7 +3,7 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import * as childProcess from 'child_process'; import * as openapi from '@redocly/openapi-core'; -import { async, startServer, signup, post, request, simpleGet, port, shutdownServer } from './utils.js'; +import { startServer, signup, post, request, simpleGet, port, shutdownServer } from '../utils.js'; // Request Accept const ONLY_AP = 'application/activity+json'; @@ -22,51 +22,51 @@ describe('Fetch resource', () => { let alice: any; let alicesPost: any; - before(async () => { + beforeAll(async () => { p = await startServer(); alice = await signup({ username: 'alice' }); alicesPost = await post(alice, { text: 'test', }); - }); + }, 1000 * 30); - after(async () => { + afterAll(async () => { await shutdownServer(p); }); describe('Common', () => { - it('meta', async(async () => { + it('meta', async () => { const res = await request('/meta', { }); assert.strictEqual(res.status, 200); - })); + }); - it('GET root', async(async () => { + it('GET root', async () => { const res = await simpleGet('/'); assert.strictEqual(res.status, 200); assert.strictEqual(res.type, HTML); - })); + }); - it('GET docs', async(async () => { + it('GET docs', async () => { const res = await simpleGet('/docs/ja-JP/about'); assert.strictEqual(res.status, 200); assert.strictEqual(res.type, HTML); - })); + }); - it('GET api-doc', async(async () => { + it('GET api-doc', async () => { const res = await simpleGet('/api-doc'); assert.strictEqual(res.status, 200); assert.strictEqual(res.type, HTML); - })); + }); - it('GET api.json', async(async () => { + it('GET api.json', async () => { const res = await simpleGet('/api.json'); assert.strictEqual(res.status, 200); assert.strictEqual(res.type, JSON); - })); + }); - it('Validate api.json', async(async () => { + it('Validate api.json', async () => { const config = await openapi.loadConfig(); const result = await openapi.bundle({ config, @@ -78,128 +78,128 @@ describe('Fetch resource', () => { } assert.strictEqual(result.problems.length, 0); - })); + }); - it('GET favicon.ico', async(async () => { + it('GET favicon.ico', async () => { const res = await simpleGet('/favicon.ico'); assert.strictEqual(res.status, 200); assert.strictEqual(res.type, 'image/x-icon'); - })); + }); - it('GET apple-touch-icon.png', async(async () => { + it('GET apple-touch-icon.png', async () => { const res = await simpleGet('/apple-touch-icon.png'); assert.strictEqual(res.status, 200); assert.strictEqual(res.type, 'image/png'); - })); + }); - it('GET twemoji svg', async(async () => { + it('GET twemoji svg', async () => { const res = await simpleGet('/twemoji/2764.svg'); assert.strictEqual(res.status, 200); assert.strictEqual(res.type, 'image/svg+xml'); - })); + }); - it('GET twemoji svg with hyphen', async(async () => { + it('GET twemoji svg with hyphen', async () => { const res = await simpleGet('/twemoji/2764-fe0f-200d-1f525.svg'); assert.strictEqual(res.status, 200); assert.strictEqual(res.type, 'image/svg+xml'); - })); + }); }); describe('/@:username', () => { - it('Only AP => AP', async(async () => { + it('Only AP => AP', async () => { const res = await simpleGet(`/@${alice.username}`, ONLY_AP); assert.strictEqual(res.status, 200); assert.strictEqual(res.type, AP); - })); + }); - it('Prefer AP => AP', async(async () => { + it('Prefer AP => AP', async () => { const res = await simpleGet(`/@${alice.username}`, PREFER_AP); assert.strictEqual(res.status, 200); assert.strictEqual(res.type, AP); - })); + }); - it('Prefer HTML => HTML', async(async () => { + it('Prefer HTML => HTML', async () => { const res = await simpleGet(`/@${alice.username}`, PREFER_HTML); assert.strictEqual(res.status, 200); assert.strictEqual(res.type, HTML); - })); + }); - it('Unspecified => HTML', async(async () => { + it('Unspecified => HTML', async () => { const res = await simpleGet(`/@${alice.username}`, UNSPECIFIED); assert.strictEqual(res.status, 200); assert.strictEqual(res.type, HTML); - })); + }); }); describe('/users/:id', () => { - it('Only AP => AP', async(async () => { + it('Only AP => AP', async () => { const res = await simpleGet(`/users/${alice.id}`, ONLY_AP); assert.strictEqual(res.status, 200); assert.strictEqual(res.type, AP); - })); + }); - it('Prefer AP => AP', async(async () => { + it('Prefer AP => AP', async () => { const res = await simpleGet(`/users/${alice.id}`, PREFER_AP); assert.strictEqual(res.status, 200); assert.strictEqual(res.type, AP); - })); + }); - it('Prefer HTML => Redirect to /@:username', async(async () => { + it('Prefer HTML => Redirect to /@:username', async () => { const res = await simpleGet(`/users/${alice.id}`, PREFER_HTML); assert.strictEqual(res.status, 302); assert.strictEqual(res.location, `/@${alice.username}`); - })); + }); - it('Undecided => HTML', async(async () => { + it('Undecided => HTML', async () => { const res = await simpleGet(`/users/${alice.id}`, UNSPECIFIED); assert.strictEqual(res.status, 302); assert.strictEqual(res.location, `/@${alice.username}`); - })); + }); }); describe('/notes/:id', () => { - it('Only AP => AP', async(async () => { + it('Only AP => AP', async () => { const res = await simpleGet(`/notes/${alicesPost.id}`, ONLY_AP); assert.strictEqual(res.status, 200); assert.strictEqual(res.type, AP); - })); + }); - it('Prefer AP => AP', async(async () => { + it('Prefer AP => AP', async () => { const res = await simpleGet(`/notes/${alicesPost.id}`, PREFER_AP); assert.strictEqual(res.status, 200); assert.strictEqual(res.type, AP); - })); + }); - it('Prefer HTML => HTML', async(async () => { + it('Prefer HTML => HTML', async () => { const res = await simpleGet(`/notes/${alicesPost.id}`, PREFER_HTML); assert.strictEqual(res.status, 200); assert.strictEqual(res.type, HTML); - })); + }); - it('Unspecified => HTML', async(async () => { + it('Unspecified => HTML', async () => { const res = await simpleGet(`/notes/${alicesPost.id}`, UNSPECIFIED); assert.strictEqual(res.status, 200); assert.strictEqual(res.type, HTML); - })); + }); }); describe('Feeds', () => { - it('RSS', async(async () => { + it('RSS', async () => { const res = await simpleGet(`/@${alice.username}.rss`, UNSPECIFIED); assert.strictEqual(res.status, 200); assert.strictEqual(res.type, 'application/rss+xml; charset=utf-8'); - })); + }); - it('ATOM', async(async () => { + it('ATOM', async () => { const res = await simpleGet(`/@${alice.username}.atom`, UNSPECIFIED); assert.strictEqual(res.status, 200); assert.strictEqual(res.type, 'application/atom+xml; charset=utf-8'); - })); + }); - it('JSON', async(async () => { + it('JSON', async () => { const res = await simpleGet(`/@${alice.username}.json`, UNSPECIFIED); assert.strictEqual(res.status, 200); assert.strictEqual(res.type, 'application/json; charset=utf-8'); - })); + }); }); }); diff --git a/packages/backend/test/ff-visibility.ts b/packages/backend/test/_e2e/ff-visibility.ts similarity index 89% rename from packages/backend/test/ff-visibility.ts rename to packages/backend/test/_e2e/ff-visibility.ts index 4f6847be6..38be0eba2 100644 --- a/packages/backend/test/ff-visibility.ts +++ b/packages/backend/test/_e2e/ff-visibility.ts @@ -2,7 +2,7 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import * as childProcess from 'child_process'; -import { async, signup, request, post, react, connectStream, startServer, shutdownServer, simpleGet } from './utils.js'; +import { signup, request, post, react, connectStream, startServer, shutdownServer, simpleGet } from '../utils.js'; describe('FF visibility', () => { let p: childProcess.ChildProcess; @@ -11,18 +11,18 @@ describe('FF visibility', () => { let bob: any; let carol: any; - before(async () => { + beforeAll(async () => { p = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); - }); + }, 1000 * 30); - after(async () => { + afterAll(async () => { await shutdownServer(p); }); - it('ffVisibility が public なユーザーのフォロー/フォロワーを誰でも見れる', async(async () => { + it('ffVisibility が public なユーザーのフォロー/フォロワーを誰でも見れる', async () => { await request('/i/update', { ffVisibility: 'public', }, alice); @@ -38,9 +38,9 @@ describe('FF visibility', () => { assert.strictEqual(Array.isArray(followingRes.body), true); assert.strictEqual(followersRes.status, 200); assert.strictEqual(Array.isArray(followersRes.body), true); - })); + }); - it('ffVisibility が followers なユーザーのフォロー/フォロワーを自分で見れる', async(async () => { + it('ffVisibility が followers なユーザーのフォロー/フォロワーを自分で見れる', async () => { await request('/i/update', { ffVisibility: 'followers', }, alice); @@ -56,9 +56,9 @@ describe('FF visibility', () => { assert.strictEqual(Array.isArray(followingRes.body), true); assert.strictEqual(followersRes.status, 200); assert.strictEqual(Array.isArray(followersRes.body), true); - })); + }); - it('ffVisibility が followers なユーザーのフォロー/フォロワーを非フォロワーが見れない', async(async () => { + it('ffVisibility が followers なユーザーのフォロー/フォロワーを非フォロワーが見れない', async () => { await request('/i/update', { ffVisibility: 'followers', }, alice); @@ -72,9 +72,9 @@ describe('FF visibility', () => { assert.strictEqual(followingRes.status, 400); assert.strictEqual(followersRes.status, 400); - })); + }); - it('ffVisibility が followers なユーザーのフォロー/フォロワーをフォロワーが見れる', async(async () => { + it('ffVisibility が followers なユーザーのフォロー/フォロワーをフォロワーが見れる', async () => { await request('/i/update', { ffVisibility: 'followers', }, alice); @@ -94,9 +94,9 @@ describe('FF visibility', () => { assert.strictEqual(Array.isArray(followingRes.body), true); assert.strictEqual(followersRes.status, 200); assert.strictEqual(Array.isArray(followersRes.body), true); - })); + }); - it('ffVisibility が private なユーザーのフォロー/フォロワーを自分で見れる', async(async () => { + it('ffVisibility が private なユーザーのフォロー/フォロワーを自分で見れる', async () => { await request('/i/update', { ffVisibility: 'private', }, alice); @@ -112,9 +112,9 @@ describe('FF visibility', () => { assert.strictEqual(Array.isArray(followingRes.body), true); assert.strictEqual(followersRes.status, 200); assert.strictEqual(Array.isArray(followersRes.body), true); - })); + }); - it('ffVisibility が private なユーザーのフォロー/フォロワーを他人が見れない', async(async () => { + it('ffVisibility が private なユーザーのフォロー/フォロワーを他人が見れない', async () => { await request('/i/update', { ffVisibility: 'private', }, alice); @@ -128,10 +128,10 @@ describe('FF visibility', () => { assert.strictEqual(followingRes.status, 400); assert.strictEqual(followersRes.status, 400); - })); + }); describe('AP', () => { - it('ffVisibility が public 以外ならばAPからは取得できない', async(async () => { + it('ffVisibility が public 以外ならばAPからは取得できない', async () => { { await request('/i/update', { ffVisibility: 'public', @@ -162,6 +162,6 @@ describe('FF visibility', () => { assert.strictEqual(followingRes.status, 403); assert.strictEqual(followersRes.status, 403); } - })); + }); }); }); diff --git a/packages/backend/test/mute.ts b/packages/backend/test/_e2e/mute.ts similarity index 88% rename from packages/backend/test/mute.ts rename to packages/backend/test/_e2e/mute.ts index 465633973..231377367 100644 --- a/packages/backend/test/mute.ts +++ b/packages/backend/test/_e2e/mute.ts @@ -2,7 +2,7 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import * as childProcess from 'child_process'; -import { async, signup, request, post, react, startServer, shutdownServer, waitFire } from './utils.js'; +import { signup, request, post, react, startServer, shutdownServer, waitFire } from '../utils.js'; describe('Mute', () => { let p: childProcess.ChildProcess; @@ -12,26 +12,26 @@ describe('Mute', () => { let bob: any; let carol: any; - before(async () => { + beforeAll(async () => { p = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); - }); + }, 1000 * 30); - after(async () => { + afterAll(async () => { await shutdownServer(p); }); - it('ミュート作成', async(async () => { + it('ミュート作成', async () => { const res = await request('/mute/create', { userId: carol.id, }, alice); assert.strictEqual(res.status, 204); - })); + }); - it('「自分宛ての投稿」にミュートしているユーザーの投稿が含まれない', async(async () => { + it('「自分宛ての投稿」にミュートしているユーザーの投稿が含まれない', async () => { const bobNote = await post(bob, { text: '@alice hi' }); const carolNote = await post(carol, { text: '@alice hi' }); @@ -41,9 +41,9 @@ describe('Mute', () => { assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - })); + }); - it('ミュートしているユーザーからメンションされても、hasUnreadMentions が true にならない', async(async () => { + it('ミュートしているユーザーからメンションされても、hasUnreadMentions が true にならない', async () => { // 状態リセット await request('/i/read-all-unread-notes', {}, alice); @@ -53,7 +53,7 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(res.body.hasUnreadMentions, false); - })); + }); it('ミュートしているユーザーからメンションされても、ストリームに unreadMention イベントが流れてこない', async () => { // 状態リセット @@ -75,7 +75,7 @@ describe('Mute', () => { }); describe('Timeline', () => { - it('タイムラインにミュートしているユーザーの投稿が含まれない', async(async () => { + it('タイムラインにミュートしているユーザーの投稿が含まれない', async () => { const aliceNote = await post(alice); const bobNote = await post(bob); const carolNote = await post(carol); @@ -87,9 +87,9 @@ describe('Mute', () => { assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - })); + }); - it('タイムラインにミュートしているユーザーの投稿のRenoteが含まれない', async(async () => { + it('タイムラインにミュートしているユーザーの投稿のRenoteが含まれない', async () => { const aliceNote = await post(alice); const carolNote = await post(carol); const bobNote = await post(bob, { @@ -103,11 +103,11 @@ describe('Mute', () => { assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - })); + }); }); describe('Notification', () => { - it('通知にミュートしているユーザーの通知が含まれない(リアクション)', async(async () => { + it('通知にミュートしているユーザーの通知が含まれない(リアクション)', async () => { const aliceNote = await post(alice); await react(bob, aliceNote, 'like'); await react(carol, aliceNote, 'like'); @@ -118,6 +118,6 @@ describe('Mute', () => { assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); - })); + }); }); }); diff --git a/packages/backend/test/note.ts b/packages/backend/test/_e2e/note.ts similarity index 85% rename from packages/backend/test/note.ts rename to packages/backend/test/_e2e/note.ts index b495d8b7b..d75a5c828 100644 --- a/packages/backend/test/note.ts +++ b/packages/backend/test/_e2e/note.ts @@ -2,8 +2,8 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import * as childProcess from 'child_process'; -import { Note } from '../src/models/entities/note.js'; -import { async, signup, request, post, uploadUrl, startServer, shutdownServer, initTestDb, api } from './utils.js'; +import { Note } from '../../src/models/entities/note.js'; +import { async, signup, request, post, uploadUrl, startServer, shutdownServer, initTestDb, api } from '../utils.js'; describe('Note', () => { let p: childProcess.ChildProcess; @@ -12,19 +12,19 @@ describe('Note', () => { let alice: any; let bob: any; - before(async () => { + beforeAll(async () => { p = await startServer(); const connection = await initTestDb(true); Notes = connection.getRepository(Note); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); - }); + }, 1000 * 30); - after(async () => { + afterAll(async () => { await shutdownServer(p); }); - it('投稿できる', async(async () => { + it('投稿できる', async () => { const post = { text: 'test', }; @@ -34,9 +34,9 @@ describe('Note', () => { assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.createdNote.text, post.text); - })); + }); - it('ファイルを添付できる', async(async () => { + it('ファイルを添付できる', async () => { const file = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); const res = await request('/notes/create', { @@ -46,9 +46,9 @@ describe('Note', () => { assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.deepStrictEqual(res.body.createdNote.fileIds, [file.id]); - })); + }, 1000 * 10); - it('他人のファイルは無視', async(async () => { + it('他人のファイルは無視', async () => { const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); const res = await request('/notes/create', { @@ -59,9 +59,9 @@ describe('Note', () => { assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.deepStrictEqual(res.body.createdNote.fileIds, []); - })); + }, 1000 * 10); - it('存在しないファイルは無視', async(async () => { + it('存在しないファイルは無視', async () => { const res = await request('/notes/create', { text: 'test', fileIds: ['000000000000000000000000'], @@ -70,18 +70,18 @@ describe('Note', () => { assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.deepStrictEqual(res.body.createdNote.fileIds, []); - })); + }); - it('不正なファイルIDは無視', async(async () => { + it('不正なファイルIDは無視', async () => { const res = await request('/notes/create', { fileIds: ['kyoppie'], }, alice); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.deepStrictEqual(res.body.createdNote.fileIds, []); - })); + }); - it('返信できる', async(async () => { + it('返信できる', async () => { const bobPost = await post(bob, { text: 'foo', }); @@ -98,9 +98,9 @@ describe('Note', () => { assert.strictEqual(res.body.createdNote.text, alicePost.text); assert.strictEqual(res.body.createdNote.replyId, alicePost.replyId); assert.strictEqual(res.body.createdNote.reply.text, bobPost.text); - })); + }); - it('renoteできる', async(async () => { + it('renoteできる', async () => { const bobPost = await post(bob, { text: 'test', }); @@ -115,9 +115,9 @@ describe('Note', () => { assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.createdNote.renoteId, alicePost.renoteId); assert.strictEqual(res.body.createdNote.renote.text, bobPost.text); - })); + }); - it('引用renoteできる', async(async () => { + it('引用renoteできる', async () => { const bobPost = await post(bob, { text: 'test', }); @@ -134,59 +134,59 @@ describe('Note', () => { assert.strictEqual(res.body.createdNote.text, alicePost.text); assert.strictEqual(res.body.createdNote.renoteId, alicePost.renoteId); assert.strictEqual(res.body.createdNote.renote.text, bobPost.text); - })); + }); - it('文字数ぎりぎりで怒られない', async(async () => { + it('文字数ぎりぎりで怒られない', async () => { const post = { text: '!'.repeat(3000), }; const res = await request('/notes/create', post, alice); assert.strictEqual(res.status, 200); - })); + }); - it('文字数オーバーで怒られる', async(async () => { + it('文字数オーバーで怒られる', async () => { const post = { text: '!'.repeat(3001), }; const res = await request('/notes/create', post, alice); assert.strictEqual(res.status, 400); - })); + }); - it('存在しないリプライ先で怒られる', async(async () => { + it('存在しないリプライ先で怒られる', async () => { const post = { text: 'test', replyId: '000000000000000000000000', }; const res = await request('/notes/create', post, alice); assert.strictEqual(res.status, 400); - })); + }); - it('存在しないrenote対象で怒られる', async(async () => { + it('存在しないrenote対象で怒られる', async () => { const post = { renoteId: '000000000000000000000000', }; const res = await request('/notes/create', post, alice); assert.strictEqual(res.status, 400); - })); + }); - it('不正なリプライ先IDで怒られる', async(async () => { + it('不正なリプライ先IDで怒られる', async () => { const post = { text: 'test', replyId: 'foo', }; const res = await request('/notes/create', post, alice); assert.strictEqual(res.status, 400); - })); + }); - it('不正なrenote対象IDで怒られる', async(async () => { + it('不正なrenote対象IDで怒られる', async () => { const post = { renoteId: 'foo', }; const res = await request('/notes/create', post, alice); assert.strictEqual(res.status, 400); - })); + }); - it('存在しないユーザーにメンションできる', async(async () => { + it('存在しないユーザーにメンションできる', async () => { const post = { text: '@ghost yo', }; @@ -196,9 +196,9 @@ describe('Note', () => { assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.createdNote.text, post.text); - })); + }); - it('同じユーザーに複数メンションしても内部的にまとめられる', async(async () => { + it('同じユーザーに複数メンションしても内部的にまとめられる', async () => { const post = { text: '@bob @bob @bob yo', }; @@ -211,10 +211,10 @@ describe('Note', () => { const noteDoc = await Notes.findOneBy({ id: res.body.createdNote.id }); assert.deepStrictEqual(noteDoc.mentions, [bob.id]); - })); + }); describe('notes/create', () => { - it('投票を添付できる', async(async () => { + it('投票を添付できる', async () => { const res = await request('/notes/create', { text: 'test', poll: { @@ -225,34 +225,34 @@ describe('Note', () => { assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.createdNote.poll != null, true); - })); + }); - it('投票の選択肢が無くて怒られる', async(async () => { + it('投票の選択肢が無くて怒られる', async () => { const res = await request('/notes/create', { poll: {}, }, alice); assert.strictEqual(res.status, 400); - })); + }); - it('投票の選択肢が無くて怒られる (空の配列)', async(async () => { + it('投票の選択肢が無くて怒られる (空の配列)', async () => { const res = await request('/notes/create', { poll: { choices: [], }, }, alice); assert.strictEqual(res.status, 400); - })); + }); - it('投票の選択肢が1つで怒られる', async(async () => { + it('投票の選択肢が1つで怒られる', async () => { const res = await request('/notes/create', { poll: { choices: ['Strawberry Pasta'], }, }, alice); assert.strictEqual(res.status, 400); - })); + }); - it('投票できる', async(async () => { + it('投票できる', async () => { const { body } = await request('/notes/create', { text: 'test', poll: { @@ -266,9 +266,9 @@ describe('Note', () => { }, alice); assert.strictEqual(res.status, 204); - })); + }); - it('複数投票できない', async(async () => { + it('複数投票できない', async () => { const { body } = await request('/notes/create', { text: 'test', poll: { @@ -287,9 +287,9 @@ describe('Note', () => { }, alice); assert.strictEqual(res.status, 400); - })); + }); - it('許可されている場合は複数投票できる', async(async () => { + it('許可されている場合は複数投票できる', async () => { const { body } = await request('/notes/create', { text: 'test', poll: { @@ -314,9 +314,9 @@ describe('Note', () => { }, alice); assert.strictEqual(res.status, 204); - })); + }); - it('締め切られている場合は投票できない', async(async () => { + it('締め切られている場合は投票できない', async () => { const { body } = await request('/notes/create', { text: 'test', poll: { @@ -333,11 +333,11 @@ describe('Note', () => { }, alice); assert.strictEqual(res.status, 400); - })); + }); }); describe('notes/delete', () => { - it('delete a reply', async(async () => { + it('delete a reply', async () => { const mainNoteRes = await api('notes/create', { text: 'main post', }, alice); @@ -365,6 +365,6 @@ describe('Note', () => { assert.strictEqual(deleteTwoRes.status, 204); mainNote = await Notes.findOneBy({ id: mainNoteRes.body.createdNote.id }); assert.strictEqual(mainNote.repliesCount, 0); - })); + }); }); }); diff --git a/packages/backend/test/streaming.ts b/packages/backend/test/_e2e/streaming.ts similarity index 87% rename from packages/backend/test/streaming.ts rename to packages/backend/test/_e2e/streaming.ts index 621d07f9c..4dad322e9 100644 --- a/packages/backend/test/streaming.ts +++ b/packages/backend/test/_e2e/streaming.ts @@ -2,8 +2,8 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import * as childProcess from 'child_process'; -import { Following } from '../src/models/entities/following.js'; -import { connectStream, signup, api, post, startServer, shutdownServer, initTestDb, waitFire } from './utils.js'; +import { Following } from '../../src/models/entities/following.js'; +import { connectStream, signup, api, post, startServer, shutdownServer, initTestDb, waitFire } from '../utils.js'; describe('Streaming', () => { let p: childProcess.ChildProcess; @@ -37,7 +37,7 @@ describe('Streaming', () => { let kyokoNote: any; let list: any; - before(async () => { + beforeAll(async () => { p = await startServer(); const connection = await initTestDb(true); Followings = connection.getRepository(Following); @@ -71,9 +71,9 @@ describe('Streaming', () => { listId: list.id, userId: kyoko.id, }, chitose); - }); + }, 1000 * 30); - after(async () => { + afterAll(async () => { await shutdownServer(p); }); @@ -82,7 +82,7 @@ describe('Streaming', () => { const fired = await waitFire( kyoko, 'main', // kyoko:main () => post(ayano, { text: 'foo @kyoko bar' }), // ayano mention => kyoko - msg => msg.type === 'mention' && msg.body.userId === ayano.id // wait ayano + msg => msg.type === 'mention' && msg.body.userId === ayano.id, // wait ayano ); assert.strictEqual(fired, true); @@ -92,7 +92,7 @@ describe('Streaming', () => { const fired = await waitFire( kyoko, 'main', // kyoko:main () => post(ayano, { renoteId: kyokoNote.id }), // ayano renote - msg => msg.type === 'renote' && msg.body.renoteId === kyokoNote.id // wait renote + msg => msg.type === 'renote' && msg.body.renoteId === kyokoNote.id, // wait renote ); assert.strictEqual(fired, true); @@ -104,7 +104,7 @@ describe('Streaming', () => { const fired = await waitFire( ayano, 'homeTimeline', // ayano:Home () => api('notes/create', { text: 'foo' }, ayano), // ayano posts - msg => msg.type === 'note' && msg.body.text === 'foo' + msg => msg.type === 'note' && msg.body.text === 'foo', ); assert.strictEqual(fired, true); @@ -114,7 +114,7 @@ describe('Streaming', () => { const fired = await waitFire( ayano, 'homeTimeline', // ayano:home () => api('notes/create', { text: 'foo' }, kyoko), // kyoko posts - msg => msg.type === 'note' && msg.body.userId === kyoko.id // wait kyoko + msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko ); assert.strictEqual(fired, true); @@ -124,7 +124,7 @@ describe('Streaming', () => { const fired = await waitFire( kyoko, 'homeTimeline', // kyoko:home () => api('notes/create', { text: 'foo' }, ayano), // ayano posts - msg => msg.type === 'note' && msg.body.userId === ayano.id // wait ayano + msg => msg.type === 'note' && msg.body.userId === ayano.id, // wait ayano ); assert.strictEqual(fired, false); @@ -133,8 +133,8 @@ describe('Streaming', () => { it('フォローしているユーザーのダイレクト投稿が流れる', async () => { const fired = await waitFire( ayano, 'homeTimeline', // ayano:home - () => api('notes/create', { text: 'foo', visibility: 'specified', visibleUserIds: [ayano.id], }, kyoko), // kyoko dm => ayano - msg => msg.type === 'note' && msg.body.userId === kyoko.id // wait kyoko + () => api('notes/create', { text: 'foo', visibility: 'specified', visibleUserIds: [ayano.id] }, kyoko), // kyoko dm => ayano + msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko ); assert.strictEqual(fired, true); @@ -143,8 +143,8 @@ describe('Streaming', () => { it('フォローしているユーザーでも自分が指定されていないダイレクト投稿は流れない', async () => { const fired = await waitFire( ayano, 'homeTimeline', // ayano:home - () => api('notes/create', { text: 'foo', visibility: 'specified', visibleUserIds: [chitose.id], }, kyoko), // kyoko dm => chitose - msg => msg.type === 'note' && msg.body.userId === kyoko.id // wait kyoko + () => api('notes/create', { text: 'foo', visibility: 'specified', visibleUserIds: [chitose.id] }, kyoko), // kyoko dm => chitose + msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko ); assert.strictEqual(fired, false); @@ -156,7 +156,7 @@ describe('Streaming', () => { const fired = await waitFire( ayano, 'localTimeline', // ayano:Local () => api('notes/create', { text: 'foo' }, ayano), // ayano posts - msg => msg.type === 'note' && msg.body.text === 'foo' + msg => msg.type === 'note' && msg.body.text === 'foo', ); assert.strictEqual(fired, true); @@ -166,7 +166,7 @@ describe('Streaming', () => { const fired = await waitFire( ayano, 'localTimeline', // ayano:Local () => api('notes/create', { text: 'foo' }, chitose), // chitose posts - msg => msg.type === 'note' && msg.body.userId === chitose.id // wait chitose + msg => msg.type === 'note' && msg.body.userId === chitose.id, // wait chitose ); assert.strictEqual(fired, true); @@ -176,7 +176,7 @@ describe('Streaming', () => { const fired = await waitFire( ayano, 'localTimeline', // ayano:Local () => api('notes/create', { text: 'foo' }, chinatsu), // chinatsu posts - msg => msg.type === 'note' && msg.body.userId === chinatsu.id // wait chinatsu + msg => msg.type === 'note' && msg.body.userId === chinatsu.id, // wait chinatsu ); assert.strictEqual(fired, false); @@ -186,7 +186,7 @@ describe('Streaming', () => { const fired = await waitFire( ayano, 'localTimeline', // ayano:Local () => api('notes/create', { text: 'foo' }, akari), // akari posts - msg => msg.type === 'note' && msg.body.userId === akari.id // wait akari + msg => msg.type === 'note' && msg.body.userId === akari.id, // wait akari ); assert.strictEqual(fired, false); @@ -196,7 +196,7 @@ describe('Streaming', () => { const fired = await waitFire( ayano, 'localTimeline', // ayano:Local () => api('notes/create', { text: 'foo', visibility: 'home' }, kyoko), // kyoko home posts - msg => msg.type === 'note' && msg.body.userId === kyoko.id // wait kyoko + msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko ); assert.strictEqual(fired, false); @@ -206,7 +206,7 @@ describe('Streaming', () => { const fired = await waitFire( ayano, 'localTimeline', // ayano:Local () => api('notes/create', { text: 'foo', visibility: 'specified', visibleUserIds: [ayano.id] }, kyoko), // kyoko DM => ayano - msg => msg.type === 'note' && msg.body.userId === kyoko.id // wait kyoko + msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko ); assert.strictEqual(fired, false); @@ -216,7 +216,7 @@ describe('Streaming', () => { const fired = await waitFire( ayano, 'localTimeline', // ayano:Local () => api('notes/create', { text: 'foo', visibility: 'followers' }, chitose), - msg => msg.type === 'note' && msg.body.userId === chitose.id // wait chitose + msg => msg.type === 'note' && msg.body.userId === chitose.id, // wait chitose ); assert.strictEqual(fired, false); @@ -228,7 +228,7 @@ describe('Streaming', () => { const fired = await waitFire( ayano, 'hybridTimeline', // ayano:Hybrid () => api('notes/create', { text: 'foo' }, ayano), // ayano posts - msg => msg.type === 'note' && msg.body.text === 'foo' + msg => msg.type === 'note' && msg.body.text === 'foo', ); assert.strictEqual(fired, true); @@ -238,7 +238,7 @@ describe('Streaming', () => { const fired = await waitFire( ayano, 'hybridTimeline', // ayano:Hybrid () => api('notes/create', { text: 'foo' }, chitose), // chitose posts - msg => msg.type === 'note' && msg.body.userId === chitose.id // wait chitose + msg => msg.type === 'note' && msg.body.userId === chitose.id, // wait chitose ); assert.strictEqual(fired, true); @@ -248,7 +248,7 @@ describe('Streaming', () => { const fired = await waitFire( ayano, 'hybridTimeline', // ayano:Hybrid () => api('notes/create', { text: 'foo' }, akari), // akari posts - msg => msg.type === 'note' && msg.body.userId === akari.id // wait akari + msg => msg.type === 'note' && msg.body.userId === akari.id, // wait akari ); assert.strictEqual(fired, true); @@ -258,7 +258,7 @@ describe('Streaming', () => { const fired = await waitFire( ayano, 'hybridTimeline', // ayano:Hybrid () => api('notes/create', { text: 'foo' }, chinatsu), // chinatsu posts - msg => msg.type === 'note' && msg.body.userId === chinatsu.id // wait chinatsu + msg => msg.type === 'note' && msg.body.userId === chinatsu.id, // wait chinatsu ); assert.strictEqual(fired, false); @@ -268,7 +268,7 @@ describe('Streaming', () => { const fired = await waitFire( ayano, 'hybridTimeline', // ayano:Hybrid () => api('notes/create', { text: 'foo', visibility: 'specified', visibleUserIds: [ayano.id] }, kyoko), - msg => msg.type === 'note' && msg.body.userId === kyoko.id // wait kyoko + msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko ); assert.strictEqual(fired, true); @@ -278,7 +278,7 @@ describe('Streaming', () => { const fired = await waitFire( ayano, 'hybridTimeline', // ayano:Hybrid () => api('notes/create', { text: 'foo', visibility: 'home' }, kyoko), - msg => msg.type === 'note' && msg.body.userId === kyoko.id // wait kyoko + msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko ); assert.strictEqual(fired, true); @@ -288,17 +288,17 @@ describe('Streaming', () => { const fired = await waitFire( ayano, 'hybridTimeline', // ayano:Hybrid () => api('notes/create', { text: 'foo', visibility: 'home' }, chitose), - msg => msg.type === 'note' && msg.body.userId === chitose.id + msg => msg.type === 'note' && msg.body.userId === chitose.id, ); assert.strictEqual(fired, false); }); - it('フォローしていないローカルユーザーのフォロワー宛て投稿は流れない', () => async () => { + it('フォローしていないローカルユーザーのフォロワー宛て投稿は流れない', async () => { const fired = await waitFire( ayano, 'hybridTimeline', // ayano:Hybrid () => api('notes/create', { text: 'foo', visibility: 'followers' }, chitose), - msg => msg.type === 'note' && msg.body.userId === chitose.id + msg => msg.type === 'note' && msg.body.userId === chitose.id, ); assert.strictEqual(fired, false); @@ -306,31 +306,31 @@ describe('Streaming', () => { }); describe('Global Timeline', () => { - it('フォローしていないローカルユーザーの投稿が流れる', () => async () => { + it('フォローしていないローカルユーザーの投稿が流れる', async () => { const fired = await waitFire( ayano, 'globalTimeline', // ayano:Global () => api('notes/create', { text: 'foo' }, chitose), // chitose posts - msg => msg.type === 'note' && msg.body.userId === chitose.id // wait chitose + msg => msg.type === 'note' && msg.body.userId === chitose.id, // wait chitose ); assert.strictEqual(fired, true); }); - it('フォローしていないリモートユーザーの投稿が流れる', () => async () => { + it('フォローしていないリモートユーザーの投稿が流れる', async () => { const fired = await waitFire( ayano, 'globalTimeline', // ayano:Global () => api('notes/create', { text: 'foo' }, chinatsu), // chinatsu posts - msg => msg.type === 'note' && msg.body.userId === chinatsu.id // wait chinatsu + msg => msg.type === 'note' && msg.body.userId === chinatsu.id, // wait chinatsu ); assert.strictEqual(fired, true); }); - it('ホーム投稿は流れない', () => async () => { + it('ホーム投稿は流れない', async () => { const fired = await waitFire( ayano, 'globalTimeline', // ayano:Global () => api('notes/create', { text: 'foo', visibility: 'home' }, kyoko), // kyoko posts - msg => msg.type === 'note' && msg.body.userId === kyoko.id // wait kyoko + msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko ); assert.strictEqual(fired, false); @@ -338,47 +338,47 @@ describe('Streaming', () => { }); describe('UserList Timeline', () => { - it('リストに入れているユーザーの投稿が流れる', () => async () => { + it('リストに入れているユーザーの投稿が流れる', async () => { const fired = await waitFire( chitose, 'userList', () => api('notes/create', { text: 'foo' }, ayano), msg => msg.type === 'note' && msg.body.userId === ayano.id, - { listId: list.id, } + { listId: list.id }, ); assert.strictEqual(fired, true); }); - it('リストに入れていないユーザーの投稿は流れない', () => async () => { + it('リストに入れていないユーザーの投稿は流れない', async () => { const fired = await waitFire( chitose, 'userList', () => api('notes/create', { text: 'foo' }, chinatsu), msg => msg.type === 'note' && msg.body.userId === chinatsu.id, - { listId: list.id, } + { listId: list.id }, ); assert.strictEqual(fired, false); }); // #4471 - it('リストに入れているユーザーのダイレクト投稿が流れる', () => async () => { + it('リストに入れているユーザーのダイレクト投稿が流れる', async () => { const fired = await waitFire( chitose, 'userList', () => api('notes/create', { text: 'foo', visibility: 'specified', visibleUserIds: [chitose.id] }, ayano), msg => msg.type === 'note' && msg.body.userId === ayano.id, - { listId: list.id, } + { listId: list.id }, ); assert.strictEqual(fired, true); }); // #4335 - it('リストに入れているがフォローはしてないユーザーのフォロワー宛て投稿は流れない', () => async () => { + it('リストに入れているがフォローはしてないユーザーのフォロワー宛て投稿は流れない', async () => { const fired = await waitFire( chitose, 'userList', () => api('notes/create', { text: 'foo', visibility: 'followers' }, kyoko), msg => msg.type === 'note' && msg.body.userId === kyoko.id, - { listId: list.id, } + { listId: list.id }, ); assert.strictEqual(fired, false); @@ -388,7 +388,7 @@ describe('Streaming', () => { describe('Hashtag Timeline', () => { it('指定したハッシュタグの投稿が流れる', () => new Promise(async done => { const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { - if (type == 'note') { + if (type === 'note') { assert.deepStrictEqual(body.text, '#foo'); ws.close(); done(); @@ -410,7 +410,7 @@ describe('Streaming', () => { let fooBarCount = 0; const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { - if (type == 'note') { + if (type === 'note') { if (body.text === '#foo') fooCount++; if (body.text === '#bar') barCount++; if (body.text === '#foo #bar') fooBarCount++; @@ -449,7 +449,7 @@ describe('Streaming', () => { let piyoCount = 0; const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { - if (type == 'note') { + if (type === 'note') { if (body.text === '#foo') fooCount++; if (body.text === '#bar') barCount++; if (body.text === '#foo #bar') fooBarCount++; @@ -496,7 +496,7 @@ describe('Streaming', () => { let waaaCount = 0; const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { - if (type == 'note') { + if (type === 'note') { if (body.text === '#foo') fooCount++; if (body.text === '#bar') barCount++; if (body.text === '#foo #bar') fooBarCount++; diff --git a/packages/backend/test/thread-mute.ts b/packages/backend/test/_e2e/thread-mute.ts similarity index 91% rename from packages/backend/test/thread-mute.ts rename to packages/backend/test/_e2e/thread-mute.ts index cd3e51939..0ed9aa066 100644 --- a/packages/backend/test/thread-mute.ts +++ b/packages/backend/test/_e2e/thread-mute.ts @@ -2,7 +2,7 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import * as childProcess from 'child_process'; -import { async, signup, request, post, react, connectStream, startServer, shutdownServer } from './utils.js'; +import { signup, request, post, react, connectStream, startServer, shutdownServer } from '../utils.js'; describe('Note thread mute', () => { let p: childProcess.ChildProcess; @@ -11,18 +11,18 @@ describe('Note thread mute', () => { let bob: any; let carol: any; - before(async () => { + beforeAll(async () => { p = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); - }); + }, 1000 * 30); - after(async () => { + afterAll(async () => { await shutdownServer(p); }); - it('notes/mentions にミュートしているスレッドの投稿が含まれない', async(async () => { + it('notes/mentions にミュートしているスレッドの投稿が含まれない', async () => { const bobNote = await post(bob, { text: '@alice @carol root note' }); const aliceReply = await post(alice, { replyId: bobNote.id, text: '@bob @carol child note' }); @@ -38,9 +38,9 @@ describe('Note thread mute', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); assert.strictEqual(res.body.some((note: any) => note.id === carolReply.id), false); assert.strictEqual(res.body.some((note: any) => note.id === carolReplyWithoutMention.id), false); - })); + }); - it('ミュートしているスレッドからメンションされても、hasUnreadMentions が true にならない', async(async () => { + it('ミュートしているスレッドからメンションされても、hasUnreadMentions が true にならない', async () => { // 状態リセット await request('/i/read-all-unread-notes', {}, alice); @@ -54,7 +54,7 @@ describe('Note thread mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(res.body.hasUnreadMentions, false); - })); + }); it('ミュートしているスレッドからメンションされても、ストリームに unreadMention イベントが流れてこない', () => new Promise(async done => { // 状態リセット @@ -82,7 +82,7 @@ describe('Note thread mute', () => { }, 5000); })); - it('i/notifications にミュートしているスレッドの通知が含まれない', async(async () => { + it('i/notifications にミュートしているスレッドの通知が含まれない', async () => { const bobNote = await post(bob, { text: '@alice @carol root note' }); const aliceReply = await post(alice, { replyId: bobNote.id, text: '@bob @carol child note' }); @@ -99,5 +99,5 @@ describe('Note thread mute', () => { assert.strictEqual(res.body.some((notification: any) => notification.note.id === carolReplyWithoutMention.id), false); // NOTE: bobの投稿はスレッドミュート前に行われたため通知に含まれていてもよい - })); + }); }); diff --git a/packages/backend/test/user-notes.ts b/packages/backend/test/_e2e/user-notes.ts similarity index 86% rename from packages/backend/test/user-notes.ts rename to packages/backend/test/_e2e/user-notes.ts index 4447754d6..353875634 100644 --- a/packages/backend/test/user-notes.ts +++ b/packages/backend/test/_e2e/user-notes.ts @@ -2,7 +2,7 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import * as childProcess from 'child_process'; -import { async, signup, request, post, uploadUrl, startServer, shutdownServer } from './utils.js'; +import { signup, request, post, uploadUrl, startServer, shutdownServer } from '../utils.js'; describe('users/notes', () => { let p: childProcess.ChildProcess; @@ -12,7 +12,7 @@ describe('users/notes', () => { let pngNote: any; let jpgPngNote: any; - before(async () => { + beforeAll(async () => { p = await startServer(); alice = await signup({ username: 'alice' }); const jpg = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); @@ -26,13 +26,13 @@ describe('users/notes', () => { jpgPngNote = await post(alice, { fileIds: [jpg.id, png.id], }); - }); + }, 1000 * 30); - after(async() => { + afterAll(async() => { await shutdownServer(p); }); - it('ファイルタイプ指定 (jpg)', async(async () => { + it('ファイルタイプ指定 (jpg)', async () => { const res = await request('/users/notes', { userId: alice.id, fileType: ['image/jpeg'], @@ -43,9 +43,9 @@ describe('users/notes', () => { assert.strictEqual(res.body.length, 2); assert.strictEqual(res.body.some((note: any) => note.id === jpgNote.id), true); assert.strictEqual(res.body.some((note: any) => note.id === jpgPngNote.id), true); - })); + }); - it('ファイルタイプ指定 (jpg or png)', async(async () => { + it('ファイルタイプ指定 (jpg or png)', async () => { const res = await request('/users/notes', { userId: alice.id, fileType: ['image/jpeg', 'image/png'], @@ -57,5 +57,5 @@ describe('users/notes', () => { assert.strictEqual(res.body.some((note: any) => note.id === jpgNote.id), true); assert.strictEqual(res.body.some((note: any) => note.id === pngNote.id), true); assert.strictEqual(res.body.some((note: any) => note.id === jpgPngNote.id), true); - })); + }); }); diff --git a/packages/backend/test/prelude/maybe.ts b/packages/backend/test/prelude/maybe.ts index 0f4b00065..c1ff63ead 100644 --- a/packages/backend/test/prelude/maybe.ts +++ b/packages/backend/test/prelude/maybe.ts @@ -1,5 +1,5 @@ import * as assert from 'assert'; -import { just, nothing } from '../../src/prelude/maybe.js'; +import { just, nothing } from '../../src/misc/prelude/maybe.js'; describe('just', () => { it('has a value', () => { diff --git a/packages/backend/test/prelude/url.ts b/packages/backend/test/prelude/url.ts index df102c8df..574f2fffd 100644 --- a/packages/backend/test/prelude/url.ts +++ b/packages/backend/test/prelude/url.ts @@ -1,5 +1,5 @@ import * as assert from 'assert'; -import { query } from '../../src/prelude/url.js'; +import { query } from '../../src/misc/prelude/url.js'; describe('url', () => { it('query', () => { diff --git a/packages/backend/test/activitypub.ts b/packages/backend/test/tests/activitypub.ts similarity index 78% rename from packages/backend/test/activitypub.ts rename to packages/backend/test/tests/activitypub.ts index f4ae27e5e..6f549ca9c 100644 --- a/packages/backend/test/activitypub.ts +++ b/packages/backend/test/tests/activitypub.ts @@ -2,15 +2,8 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import rndstr from 'rndstr'; -import { initDb } from '../src/db/postgre.js'; -import { initTestDb } from './utils.js'; describe('ActivityPub', () => { - before(async () => { - //await initTestDb(); - await initDb(); - }); - describe('Parse minimum object', () => { const host = 'https://host1.test'; const preferredUsername = `${rndstr('A-Z', 4)}${rndstr('a-z', 4)}`; @@ -35,8 +28,8 @@ describe('ActivityPub', () => { }; it('Minimum Actor', async () => { - const { MockResolver } = await import('./misc/mock-resolver.js'); - const { createPerson } = await import('../src/remote/activitypub/models/person.js'); + const { MockResolver } = await import('../misc/mock-resolver.js'); + const { createPerson } = await import('../../src/remote/activitypub/models/person.js'); const resolver = new MockResolver(); resolver._register(actor.id, actor); @@ -49,8 +42,8 @@ describe('ActivityPub', () => { }); it('Minimum Note', async () => { - const { MockResolver } = await import('./misc/mock-resolver.js'); - const { createNote } = await import('../src/remote/activitypub/models/note.js'); + const { MockResolver } = await import('../misc/mock-resolver.js'); + const { createNote } = await import('../../src/remote/activitypub/models/note.js'); const resolver = new MockResolver(); resolver._register(actor.id, actor); @@ -82,8 +75,8 @@ describe('ActivityPub', () => { }; it('Actor', async () => { - const { MockResolver } = await import('./misc/mock-resolver.js'); - const { createPerson } = await import('../src/remote/activitypub/models/person.js'); + const { MockResolver } = await import('../misc/mock-resolver.js'); + const { createPerson } = await import('../../src/remote/activitypub/models/person.js'); const resolver = new MockResolver(); resolver._register(actor.id, actor); diff --git a/packages/backend/test/ap-request.ts b/packages/backend/test/tests/ap-request.ts similarity index 91% rename from packages/backend/test/ap-request.ts rename to packages/backend/test/tests/ap-request.ts index 744b2f2c9..299df9be3 100644 --- a/packages/backend/test/ap-request.ts +++ b/packages/backend/test/tests/ap-request.ts @@ -1,7 +1,7 @@ import * as assert from 'assert'; import httpSignature from '@peertube/http-signature'; -import { genRsaKeyPair } from '../src/misc/gen-key-pair.js'; -import { createSignedPost, createSignedGet } from '../src/remote/activitypub/ap-request.js'; +import { genRsaKeyPair } from '../../src/misc/gen-key-pair.js'; +import { createSignedPost, createSignedGet } from '../../src/remote/activitypub/ap-request.js'; export const buildParsedSignature = (signingString: string, signature: string, algorithm: string) => { return { diff --git a/packages/backend/test/extract-mentions.ts b/packages/backend/test/tests/extract-mentions.ts similarity index 91% rename from packages/backend/test/extract-mentions.ts rename to packages/backend/test/tests/extract-mentions.ts index 85afb098d..4f9cb6876 100644 --- a/packages/backend/test/extract-mentions.ts +++ b/packages/backend/test/tests/extract-mentions.ts @@ -1,7 +1,7 @@ import * as assert from 'assert'; import { parse } from 'mfm-js'; -import { extractMentions } from '../src/misc/extract-mentions.js'; +import { extractMentions } from '../../src/misc/extract-mentions.js'; describe('Extract mentions', () => { it('simple', () => { diff --git a/packages/backend/test/mfm.ts b/packages/backend/test/tests/mfm.ts similarity index 96% rename from packages/backend/test/mfm.ts rename to packages/backend/test/tests/mfm.ts index 5218942a5..5087e84a1 100644 --- a/packages/backend/test/mfm.ts +++ b/packages/backend/test/tests/mfm.ts @@ -1,8 +1,8 @@ import * as assert from 'assert'; import * as mfm from 'mfm-js'; -import { toHtml } from '../src/mfm/to-html.js'; -import { fromHtml } from '../src/mfm/from-html.js'; +import { toHtml } from '../../src/mfm/to-html.js'; +import { fromHtml } from '../../src/mfm/from-html.js'; describe('toHtml', () => { it('br', () => { diff --git a/packages/backend/test/reaction-lib.ts b/packages/backend/test/tests/reaction-lib.ts similarity index 100% rename from packages/backend/test/reaction-lib.ts rename to packages/backend/test/tests/reaction-lib.ts diff --git a/packages/backend/test/tsconfig.json b/packages/backend/test/tsconfig.json index bc7a9968b..5d91d0923 100644 --- a/packages/backend/test/tsconfig.json +++ b/packages/backend/test/tsconfig.json @@ -32,7 +32,8 @@ ], "lib": [ "esnext" - ] + ], + "types": ["jest"] }, "compileOnSave": false, "include": [ diff --git a/packages/backend/test/get-file-info.ts b/packages/backend/test/unit/FileInfoService.ts similarity index 51% rename from packages/backend/test/get-file-info.ts rename to packages/backend/test/unit/FileInfoService.ts index 09378fec8..b876deb54 100644 --- a/packages/backend/test/get-file-info.ts +++ b/packages/backend/test/unit/FileInfoService.ts @@ -1,16 +1,62 @@ +process.env.NODE_ENV = 'test'; + import * as assert from 'assert'; import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; -import { getFileInfo } from '../src/misc/get-file-info.js'; -import { async } from './utils.js'; +import { ModuleMocker } from 'jest-mock'; +import { Test } from '@nestjs/testing'; +import { GlobalModule } from '@/GlobalModule.js'; +import { FileInfoService } from '@/core/FileInfoService.js'; +import { DI } from '@/di-symbols.js'; +import { AiService } from '@/core/AiService.js'; +import type { TestingModule } from '@nestjs/testing'; +import type { jest } from '@jest/globals'; +import type { MockFunctionMetadata } from 'jest-mock'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); +const resources = `${_dirname}/../resources`; -describe('Get file info', () => { - it('Empty file', async (async () => { - const path = `${_dirname}/resources/emptyfile`; - const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any; +const moduleMocker = new ModuleMocker(global); + +describe('FileInfoService', () => { + let app: TestingModule; + let fileInfoService: FileInfoService; + + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [ + GlobalModule, + ], + providers: [ + AiService, + FileInfoService, + ], + }) + .useMocker((token) => { + //if (token === AiService) { + // return { }; + //} + if (typeof token === 'function') { + const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata; + const Mock = moduleMocker.generateFromMetadata(mockMetadata); + return new Mock(); + } + }) + .compile(); + + app.enableShutdownHooks(); + + fileInfoService = app.get(FileInfoService); + }); + + afterAll(async () => { + await app.close(); + }); + + it('Empty file', async () => { + const path = `${resources}/emptyfile`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; delete info.warnings; delete info.blurhash; delete info.sensitive; @@ -26,11 +72,11 @@ describe('Get file info', () => { height: undefined, orientation: undefined, }); - })); + }); - it('Generic JPEG', async (async () => { - const path = `${_dirname}/resources/Lenna.jpg`; - const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any; + it('Generic JPEG', async () => { + const path = `${resources}/Lenna.jpg`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; delete info.warnings; delete info.blurhash; delete info.sensitive; @@ -46,11 +92,11 @@ describe('Get file info', () => { height: 512, orientation: undefined, }); - })); + }); - it('Generic APNG', async (async () => { - const path = `${_dirname}/resources/anime.png`; - const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any; + it('Generic APNG', async () => { + const path = `${resources}/anime.png`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; delete info.warnings; delete info.blurhash; delete info.sensitive; @@ -66,11 +112,11 @@ describe('Get file info', () => { height: 256, orientation: undefined, }); - })); + }); - it('Generic AGIF', async (async () => { - const path = `${_dirname}/resources/anime.gif`; - const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any; + it('Generic AGIF', async () => { + const path = `${resources}/anime.gif`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; delete info.warnings; delete info.blurhash; delete info.sensitive; @@ -86,11 +132,11 @@ describe('Get file info', () => { height: 256, orientation: undefined, }); - })); + }); - it('PNG with alpha', async (async () => { - const path = `${_dirname}/resources/with-alpha.png`; - const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any; + it('PNG with alpha', async () => { + const path = `${resources}/with-alpha.png`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; delete info.warnings; delete info.blurhash; delete info.sensitive; @@ -106,11 +152,11 @@ describe('Get file info', () => { height: 256, orientation: undefined, }); - })); + }); - it('Generic SVG', async (async () => { - const path = `${_dirname}/resources/image.svg`; - const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any; + it('Generic SVG', async () => { + const path = `${resources}/image.svg`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; delete info.warnings; delete info.blurhash; delete info.sensitive; @@ -126,12 +172,12 @@ describe('Get file info', () => { height: 256, orientation: undefined, }); - })); + }); - it('SVG with XML definition', async (async () => { + it('SVG with XML definition', async () => { // https://github.com/misskey-dev/misskey/issues/4413 - const path = `${_dirname}/resources/with-xml-def.svg`; - const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any; + const path = `${resources}/with-xml-def.svg`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; delete info.warnings; delete info.blurhash; delete info.sensitive; @@ -147,11 +193,11 @@ describe('Get file info', () => { height: 256, orientation: undefined, }); - })); + }); - it('Dimension limit', async (async () => { - const path = `${_dirname}/resources/25000x25000.png`; - const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any; + it('Dimension limit', async () => { + const path = `${resources}/25000x25000.png`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; delete info.warnings; delete info.blurhash; delete info.sensitive; @@ -167,11 +213,11 @@ describe('Get file info', () => { height: 25000, orientation: undefined, }); - })); + }); - it('Rotate JPEG', async (async () => { - const path = `${_dirname}/resources/rotate.jpg`; - const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any; + it('Rotate JPEG', async () => { + const path = `${resources}/rotate.jpg`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; delete info.warnings; delete info.blurhash; delete info.sensitive; @@ -187,5 +233,5 @@ describe('Get file info', () => { height: 256, orientation: 8, }); - })); + }); }); diff --git a/packages/backend/test/unit/RelayService.ts b/packages/backend/test/unit/RelayService.ts new file mode 100644 index 000000000..bb555648e --- /dev/null +++ b/packages/backend/test/unit/RelayService.ts @@ -0,0 +1,96 @@ +process.env.NODE_ENV = 'test'; + +import { jest } from '@jest/globals'; +import { ModuleMocker } from 'jest-mock'; +import { Test } from '@nestjs/testing'; +import { GlobalModule } from '@/GlobalModule.js'; +import { RelayService } from '@/core/RelayService.js'; +import { ApRendererService } from '@/core/remote/activitypub/ApRendererService.js'; +import { CreateSystemUserService } from '@/core/CreateSystemUserService.js'; +import { QueueService } from '@/core/QueueService.js'; +import { IdService } from '@/core/IdService.js'; +import type { RelaysRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import type { TestingModule } from '@nestjs/testing'; +import type { MockFunctionMetadata } from 'jest-mock'; + +const moduleMocker = new ModuleMocker(global); + +describe('RelayService', () => { + let app: TestingModule; + let relayService: RelayService; + let queueService: jest.Mocked; + let relaysRepository: RelaysRepository; + + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [ + GlobalModule, + ], + providers: [ + IdService, + CreateSystemUserService, + ApRendererService, + RelayService, + ], + }) + .useMocker((token) => { + if (token === QueueService) { + return { deliver: jest.fn() }; + } + if (typeof token === 'function') { + const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata; + const Mock = moduleMocker.generateFromMetadata(mockMetadata); + return new Mock(); + } + }) + .compile(); + + app.enableShutdownHooks(); + + relayService = app.get(RelayService); + queueService = app.get(QueueService) as jest.Mocked; + relaysRepository = app.get(DI.relaysRepository); + }); + + afterAll(async () => { + await app.close(); + }); + + it('addRelay', async () => { + const result = await relayService.addRelay('https://example.com'); + + expect(result.inbox).toBe('https://example.com'); + expect(result.status).toBe('requesting'); + expect(queueService.deliver).toHaveBeenCalled(); + expect(queueService.deliver.mock.lastCall![1].type).toBe('Follow'); + expect(queueService.deliver.mock.lastCall![2]).toBe('https://example.com'); + //expect(queueService.deliver.mock.lastCall![0].username).toBe('relay.actor'); + }); + + it('listRelay', async () => { + const result = await relayService.listRelay(); + + expect(result.length).toBe(1); + expect(result[0].inbox).toBe('https://example.com'); + expect(result[0].status).toBe('requesting'); + }); + + it('removeRelay: succ', async () => { + await relayService.removeRelay('https://example.com'); + + expect(queueService.deliver).toHaveBeenCalled(); + expect(queueService.deliver.mock.lastCall![1].type).toBe('Undo'); + expect(queueService.deliver.mock.lastCall![1].object.type).toBe('Follow'); + expect(queueService.deliver.mock.lastCall![2]).toBe('https://example.com'); + //expect(queueService.deliver.mock.lastCall![0].username).toBe('relay.actor'); + + const list = await relayService.listRelay(); + expect(list.length).toBe(0); + }); + + it('removeRelay: fail', async () => { + await expect(relayService.removeRelay('https://x.example.com')) + .rejects.toThrow('relay not found'); + }); +}); diff --git a/packages/backend/test/chart.ts b/packages/backend/test/unit/chart.ts similarity index 84% rename from packages/backend/test/chart.ts rename to packages/backend/test/unit/chart.ts index ac0844679..036d0e19f 100644 --- a/packages/backend/test/chart.ts +++ b/packages/backend/test/unit/chart.ts @@ -1,14 +1,29 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; +import { jest } from '@jest/globals'; import * as lolex from '@sinonjs/fake-timers'; -import TestChart from '../src/services/chart/charts/test.js'; -import TestGroupedChart from '../src/services/chart/charts/test-grouped.js'; -import TestUniqueChart from '../src/services/chart/charts/test-unique.js'; -import TestIntersectionChart from '../src/services/chart/charts/test-intersection.js'; -import { initDb } from '../src/db/postgre.js'; +import { DataSource } from 'typeorm'; +import TestChart from '@/core/chart/charts/test.js'; +import TestGroupedChart from '@/core/chart/charts/test-grouped.js'; +import TestUniqueChart from '@/core/chart/charts/test-unique.js'; +import TestIntersectionChart from '@/core/chart/charts/test-intersection.js'; +import { entity as TestChartEntity } from '@/core/chart/charts/entities/test.js'; +import { entity as TestGroupedChartEntity } from '@/core/chart/charts/entities/test-grouped.js'; +import { entity as TestUniqueChartEntity } from '@/core/chart/charts/entities/test-unique.js'; +import { entity as TestIntersectionChartEntity } from '@/core/chart/charts/entities/test-intersection.js'; +import { loadConfig } from '@/config.js'; +import type { AppLockService } from '@/core/AppLockService'; +import Logger from '@/logger.js'; describe('Chart', () => { + const config = loadConfig(); + const appLockService = { + getChartInsertLock: jest.fn().mockImplementation(() => Promise.resolve(() => {})), + } as unknown as jest.Mocked; + + let db: DataSource | undefined; + let testChart: TestChart; let testGroupedChart: TestGroupedChart; let testUniqueChart: TestUniqueChart; @@ -16,12 +31,38 @@ describe('Chart', () => { let clock: lolex.InstalledClock; beforeEach(async () => { - await initDb(true); + if (db) db.destroy(); - testChart = new TestChart(); - testGroupedChart = new TestGroupedChart(); - testUniqueChart = new TestUniqueChart(); - testIntersectionChart = new TestIntersectionChart(); + db = new DataSource({ + type: 'postgres', + host: config.db.host, + port: config.db.port, + username: config.db.user, + password: config.db.pass, + database: config.db.db, + extra: { + statement_timeout: 1000 * 10, + ...config.db.extra, + }, + synchronize: true, + dropSchema: true, + maxQueryExecutionTime: 300, + entities: [ + TestChartEntity.hour, TestChartEntity.day, + TestGroupedChartEntity.hour, TestGroupedChartEntity.day, + TestUniqueChartEntity.hour, TestUniqueChartEntity.day, + TestIntersectionChartEntity.hour, TestIntersectionChartEntity.day, + ], + migrations: ['../../migration/*.js'], + }); + + await db.initialize(); + + const logger = new Logger('chart'); // TODO: モックにする + testChart = new TestChart(db, appLockService, logger); + testGroupedChart = new TestGroupedChart(db, appLockService, logger); + testUniqueChart = new TestUniqueChart(db, appLockService, logger); + testIntersectionChart = new TestIntersectionChart(db, appLockService, logger); clock = lolex.install({ now: new Date(Date.UTC(2000, 0, 1, 0, 0, 0)), @@ -33,6 +74,10 @@ describe('Chart', () => { clock.uninstall(); }); + afterAll(async () => { + if (db) await db.destroy(); + }); + it('Can updates', async () => { await testChart.increment(); await testChart.save(); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 245cf858d..c8fd41e1d 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -6,13 +6,13 @@ import * as childProcess from 'child_process'; import * as http from 'node:http'; import { SIGKILL } from 'constants'; import WebSocket from 'ws'; -import * as misskey from 'misskey-js'; import fetch from 'node-fetch'; import FormData from 'form-data'; import { DataSource } from 'typeorm'; +import got, { RequestError } from 'got'; import loadConfig from '../src/config/load.js'; -import { entities } from '../src/db/postgre.js'; -import got from 'got'; +import { entities } from '../src/postgre.js'; +import type * as misskey from 'misskey-js'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); @@ -20,56 +20,53 @@ const _dirname = dirname(_filename); const config = loadConfig(); export const port = config.port; -export const async = (fn: Function) => (done: Function) => { - fn().then(() => { - done(); - }, (err: Error) => { - done(err); - }); -}; - export const api = async (endpoint: string, params: any, me?: any) => { endpoint = endpoint.replace(/^\//, ''); const auth = me ? { - i: me.token + i: me.token, } : {}; - const res = await got(`http://localhost:${port}/api/${endpoint}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(Object.assign(auth, params)), - retry: { - limit: 0, - }, - hooks: { - beforeError: [ - error => { - const { response } = error; - if (response && response.body) console.warn(response.body); - return error; - } - ] - }, - }); + try { + const res = await got(`http://localhost:${port}/api/${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(Object.assign(auth, params)), + retry: { + limit: 0, + }, + }); - const status = res.statusCode; - const body = res.statusCode !== 204 ? await JSON.parse(res.body) : null; + const status = res.statusCode; + const body = res.statusCode !== 204 ? await JSON.parse(res.body) : null; - return { - status, - body - }; + return { + status, + body, + }; + } catch (err: unknown) { + if (err instanceof RequestError && err.response) { + const status = err.response.statusCode; + const body = await JSON.parse(err.response.body as string); + + return { + status, + body, + }; + } else { + throw err; + } + } }; -export const request = async (endpoint: string, params: any, me?: any): Promise<{ body: any, status: number }> => { +export const request = async (path: string, params: any, me?: any): Promise<{ body: any, status: number }> => { const auth = me ? { i: me.token, } : {}; - const res = await fetch(`http://localhost:${port}/api${endpoint}`, { + const res = await fetch(`http://localhost:${port}/${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -78,7 +75,7 @@ export const request = async (endpoint: string, params: any, me?: any): Promise< }); const status = res.status; - const body = res.status !== 204 ? await res.json().catch() : null; + const body = res.status === 200 ? await res.json().catch() : null; return { body, status, @@ -141,19 +138,21 @@ export const uploadFile = async (user: any, _path?: string): Promise => { export const uploadUrl = async (user: any, url: string) => { let file: any; + const marker = Math.random().toString(); const ws = await connectStream(user, 'main', (msg) => { - if (msg.type === 'driveFileCreated') { - file = msg.body; + if (msg.type === 'urlUploadFinished' && msg.body.marker === marker) { + file = msg.body.file; } }); await api('drive/files/upload-from-url', { url, + marker, force: true, }, user); - await sleep(5000); + await sleep(7000); ws.close(); return file; @@ -217,7 +216,7 @@ export const waitFire = async (user: any, channel: string, trgr: () => any, cond if (timer) clearTimeout(timer); rej(e); } - }) + }); }; export const simpleGet = async (path: string, accept = '*/*'): Promise<{ status?: number, type?: string, location?: string }> => { @@ -268,7 +267,7 @@ export async function initTestDb(justBorrow = false, initEntities?: any[]) { database: config.db.db, synchronize: true && !justBorrow, dropSchema: true && !justBorrow, - entities: initEntities || entities, + entities: initEntities ?? entities, }); await db.initialize(); @@ -299,7 +298,8 @@ export function startServer(timeout = 60 * 1000): Promise { const t = setTimeout(() => { p.kill(SIGKILL); diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json index dea4eb27d..0c9592574 100644 --- a/packages/backend/tsconfig.json +++ b/packages/backend/tsconfig.json @@ -10,7 +10,7 @@ "declaration": false, "sourceMap": false, "target": "es2021", - "module": "es2020", + "module": "es2022", "moduleResolution": "node", "allowSyntheticDefaultImports": true, "removeComments": false, diff --git a/packages/client/.eslintrc.js b/packages/client/.eslintrc.js index a5a4fd0f4..c3a78bd5f 100644 --- a/packages/client/.eslintrc.js +++ b/packages/client/.eslintrc.js @@ -21,6 +21,9 @@ module.exports = { 'allowSingleExtends': true, }, ], + '@typescript-eslint/prefer-nullish-coalescing': [ + 'error', + ], // window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため // e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため 'id-denylist': ['error', 'window', 'e'], @@ -35,7 +38,7 @@ module.exports = { 'vue/no-multi-spaces': ['error', { 'ignoreProperties': false, }], - 'vue/no-v-html': 'error', + 'vue/no-v-html': 'warn', 'vue/order-in-components': 'error', 'vue/html-indent': ['warn', 'tab', { 'attribute': 1, diff --git a/packages/client/package.json b/packages/client/package.json index b444592ae..63ba390ad 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -13,20 +13,20 @@ "@rollup/plugin-json": "4.1.0", "@rollup/pluginutils": "^4.2.1", "@syuilo/aiscript": "0.11.1", - "@vitejs/plugin-vue": "3.0.3", - "@vue/compiler-sfc": "3.2.37", + "@vitejs/plugin-vue": "3.1.0", + "@vue/compiler-sfc": "3.2.39", "autobind-decorator": "2.4.0", "autosize": "5.0.1", "blurhash": "1.1.5", - "broadcast-channel": "4.14.0", + "broadcast-channel": "4.17.0", "browser-image-resizer": "git+https://github.com/misskey-dev/browser-image-resizer#v2.2.1-misskey.2", "chart.js": "3.9.1", "chartjs-adapter-date-fns": "2.0.0", - "chartjs-plugin-gradient": "0.5.0", + "chartjs-plugin-gradient": "0.5.1", "chartjs-plugin-zoom": "1.2.1", - "compare-versions": "4.1.3", + "compare-versions": "5.0.1", "cropperjs": "2.0.0-beta", - "date-fns": "2.29.1", + "date-fns": "2.29.3", "escape-regexp": "0.0.1", "eventemitter3": "4.0.7", "idb-keyval": "6.2.0", @@ -36,39 +36,39 @@ "matter-js": "0.18.0", "mfm-js": "0.23.0", "misskey-js": "0.0.14", - "photoswipe": "5.3.0", - "prismjs": "1.28.0", + "photoswipe": "5.3.2", + "prismjs": "1.29.0", "punycode": "2.1.1", "querystring": "0.2.1", "rndstr": "1.0.0", "s-age": "1.1.2", - "sass": "1.54.4", + "sass": "1.54.9", "seedrandom": "3.0.5", "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", "syuilo-password-strength": "0.0.1", "textarea-caret": "3.1.0", - "three": "0.143.0", + "three": "0.144.0", "throttle-debounce": "5.0.0", "tinycolor2": "1.4.2", "tsc-alias": "1.7.0", "tsconfig-paths": "4.1.0", "twemoji-parser": "14.0.0", - "typescript": "4.7.4", - "uuid": "8.3.2", + "typescript": "4.8.3", + "uuid": "9.0.0", "vanilla-tilt": "1.7.2", - "vite": "3.0.7", - "vue": "3.2.37", + "vite": "3.1.3", + "vue": "3.2.39", "vue-prism-editor": "2.0.0-alpha.2", "vuedraggable": "4.0.1" }, "devDependencies": { "@types/escape-regexp": "0.0.1", - "@types/glob": "7.2.0", + "@types/glob": "8.0.0", "@types/gulp": "4.0.9", "@types/gulp-rename": "2.0.1", "@types/katex": "0.14.0", - "@types/matter-js": "0.18.1", + "@types/matter-js": "0.18.2", "@types/punycode": "2.1.0", "@types/seedrandom": "3.0.2", "@types/throttle-debounce": "5.0.0", @@ -76,14 +76,14 @@ "@types/uuid": "8.3.4", "@types/websocket": "1.0.5", "@types/ws": "8.5.3", - "@typescript-eslint/eslint-plugin": "5.33.0", - "@typescript-eslint/parser": "5.33.0", + "@typescript-eslint/eslint-plugin": "5.38.0", + "@typescript-eslint/parser": "5.38.0", "cross-env": "7.0.3", - "cypress": "10.4.0", - "eslint": "8.21.0", + "cypress": "10.8.0", + "eslint": "8.23.1", "eslint-plugin-import": "2.26.0", - "eslint-plugin-vue": "9.3.0", - "rollup": "2.77.3", + "eslint-plugin-vue": "9.5.1", + "rollup": "2.79.0", "start-server-and-test": "1.14.0" } } diff --git a/packages/client/src/account.ts b/packages/client/src/account.ts index 243aea68c..10257b841 100644 --- a/packages/client/src/account.ts +++ b/packages/client/src/account.ts @@ -146,7 +146,7 @@ export async function openAccountMenu(opts: { onChoose?: (account: misskey.entities.UserDetailed) => void; }, ev: MouseEvent) { function showSigninDialog() { - popup(defineAsyncComponent(() => import('@/components/signin-dialog.vue')), {}, { + popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { done: res => { addAccount(res.id, res.i); success(); @@ -155,7 +155,7 @@ export async function openAccountMenu(opts: { } function createAccount() { - popup(defineAsyncComponent(() => import('@/components/signup-dialog.vue')), {}, { + popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { done: res => { addAccount(res.id, res.i); switchAccountWithToken(res.i); diff --git a/packages/client/src/components/abuse-report.vue b/packages/client/src/components/MkAbuseReport.vue similarity index 96% rename from packages/client/src/components/abuse-report.vue rename to packages/client/src/components/MkAbuseReport.vue index 8c25df110..9a3464b64 100644 --- a/packages/client/src/components/abuse-report.vue +++ b/packages/client/src/components/MkAbuseReport.vue @@ -36,9 +36,9 @@ diff --git a/packages/client/src/components/user-card-mini.vue b/packages/client/src/components/MkUserCardMini.vue similarity index 97% rename from packages/client/src/components/user-card-mini.vue rename to packages/client/src/components/MkUserCardMini.vue index 732adf7f5..1a4c49498 100644 --- a/packages/client/src/components/user-card-mini.vue +++ b/packages/client/src/components/MkUserCardMini.vue @@ -11,7 +11,7 @@ diff --git a/packages/client/src/components/user-select-dialog.vue b/packages/client/src/components/MkUserSelectDialog.vue similarity index 98% rename from packages/client/src/components/user-select-dialog.vue rename to packages/client/src/components/MkUserSelectDialog.vue index 4d8e427a7..07caedfe3 100644 --- a/packages/client/src/components/user-select-dialog.vue +++ b/packages/client/src/components/MkUserSelectDialog.vue @@ -56,7 +56,7 @@ import { nextTick, onMounted } from 'vue'; import * as misskey from 'misskey-js'; import MkInput from '@/components/form/input.vue'; import FormSplit from '@/components/form/split.vue'; -import XModalWindow from '@/components/ui/modal-window.vue'; +import XModalWindow from '@/components/MkModalWindow.vue'; import * as os from '@/os'; import { defaultStore } from '@/store'; import { i18n } from '@/i18n'; diff --git a/packages/client/src/components/users-tooltip.vue b/packages/client/src/components/MkUsersTooltip.vue similarity index 95% rename from packages/client/src/components/users-tooltip.vue rename to packages/client/src/components/MkUsersTooltip.vue index 2df19bcd3..4ccc44b47 100644 --- a/packages/client/src/components/users-tooltip.vue +++ b/packages/client/src/components/MkUsersTooltip.vue @@ -12,7 +12,7 @@ diff --git a/packages/client/src/components/global/avatar.vue b/packages/client/src/components/global/MkAvatar.vue similarity index 98% rename from packages/client/src/components/global/avatar.vue rename to packages/client/src/components/global/MkAvatar.vue index 4868896c9..5f3e3c176 100644 --- a/packages/client/src/components/global/avatar.vue +++ b/packages/client/src/components/global/MkAvatar.vue @@ -15,7 +15,7 @@ import * as misskey from 'misskey-js'; import { getStaticImageUrl } from '@/scripts/get-static-image-url'; import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash'; import { acct, userPage } from '@/filters/user'; -import MkUserOnlineIndicator from '@/components/user-online-indicator.vue'; +import MkUserOnlineIndicator from '@/components/MkUserOnlineIndicator.vue'; import { defaultStore } from '@/store'; const props = withDefaults(defineProps<{ diff --git a/packages/client/src/components/global/ellipsis.vue b/packages/client/src/components/global/MkEllipsis.vue similarity index 100% rename from packages/client/src/components/global/ellipsis.vue rename to packages/client/src/components/global/MkEllipsis.vue diff --git a/packages/client/src/components/global/emoji.vue b/packages/client/src/components/global/MkEmoji.vue similarity index 100% rename from packages/client/src/components/global/emoji.vue rename to packages/client/src/components/global/MkEmoji.vue diff --git a/packages/client/src/components/global/error.vue b/packages/client/src/components/global/MkError.vue similarity index 93% rename from packages/client/src/components/global/error.vue rename to packages/client/src/components/global/MkError.vue index 4e2ba07d3..6e75a69ec 100644 --- a/packages/client/src/components/global/error.vue +++ b/packages/client/src/components/global/MkError.vue @@ -9,7 +9,7 @@ diff --git a/packages/client/src/components/global/loading.vue b/packages/client/src/components/global/MkLoading.vue similarity index 99% rename from packages/client/src/components/global/loading.vue rename to packages/client/src/components/global/MkLoading.vue index bcc6dfac0..bcf592523 100644 --- a/packages/client/src/components/global/loading.vue +++ b/packages/client/src/components/global/MkLoading.vue @@ -44,7 +44,7 @@ const props = withDefaults(defineProps<{ text-align: center; cursor: wait; - --size: 40px; + --size: 38px; &.colored { color: var(--accent); diff --git a/packages/client/src/components/global/misskey-flavored-markdown.vue b/packages/client/src/components/global/MkMisskeyFlavoredMarkdown.vue similarity index 100% rename from packages/client/src/components/global/misskey-flavored-markdown.vue rename to packages/client/src/components/global/MkMisskeyFlavoredMarkdown.vue diff --git a/packages/client/src/components/global/page-header.vue b/packages/client/src/components/global/MkPageHeader.vue similarity index 100% rename from packages/client/src/components/global/page-header.vue rename to packages/client/src/components/global/MkPageHeader.vue diff --git a/packages/client/src/components/global/spacer.vue b/packages/client/src/components/global/MkSpacer.vue similarity index 100% rename from packages/client/src/components/global/spacer.vue rename to packages/client/src/components/global/MkSpacer.vue diff --git a/packages/client/src/components/global/sticky-container.vue b/packages/client/src/components/global/MkStickyContainer.vue similarity index 100% rename from packages/client/src/components/global/sticky-container.vue rename to packages/client/src/components/global/MkStickyContainer.vue diff --git a/packages/client/src/components/global/time.vue b/packages/client/src/components/global/MkTime.vue similarity index 100% rename from packages/client/src/components/global/time.vue rename to packages/client/src/components/global/MkTime.vue diff --git a/packages/client/src/components/global/url.vue b/packages/client/src/components/global/MkUrl.vue similarity index 60% rename from packages/client/src/components/global/url.vue rename to packages/client/src/components/global/MkUrl.vue index dc79a2064..37c0212ea 100644 --- a/packages/client/src/components/global/url.vue +++ b/packages/client/src/components/global/MkUrl.vue @@ -18,54 +18,39 @@ - diff --git a/packages/client/src/components/global/i18n.ts b/packages/client/src/components/global/i18n.ts index abf0c9685..1fd293ba1 100644 --- a/packages/client/src/components/global/i18n.ts +++ b/packages/client/src/components/global/i18n.ts @@ -30,7 +30,7 @@ export default defineComponent({ } else { if (nextBracketOpen > 0) parsed.push(str.substr(0, nextBracketOpen)); parsed.push({ - arg: str.substring(nextBracketOpen + 1, nextBracketClose) + arg: str.substring(nextBracketOpen + 1, nextBracketClose), }); } @@ -38,5 +38,5 @@ export default defineComponent({ } return h(this.tag, parsed.map(x => typeof x === 'string' ? (this.textTag ? h(this.textTag, x) : x) : this.$slots[x.arg]())); - } + }, }); diff --git a/packages/client/src/components/index.ts b/packages/client/src/components/index.ts index aa8a591e5..863925700 100644 --- a/packages/client/src/components/index.ts +++ b/packages/client/src/components/index.ts @@ -1,22 +1,22 @@ import { App } from 'vue'; -import Mfm from './global/misskey-flavored-markdown.vue'; -import MkA from './global/a.vue'; -import MkAcct from './global/acct.vue'; -import MkAvatar from './global/avatar.vue'; -import MkEmoji from './global/emoji.vue'; -import MkUserName from './global/user-name.vue'; -import MkEllipsis from './global/ellipsis.vue'; -import MkTime from './global/time.vue'; -import MkUrl from './global/url.vue'; +import Mfm from './global/MkMisskeyFlavoredMarkdown.vue'; +import MkA from './global/MkA.vue'; +import MkAcct from './global/MkAcct.vue'; +import MkAvatar from './global/MkAvatar.vue'; +import MkEmoji from './global/MkEmoji.vue'; +import MkUserName from './global/MkUserName.vue'; +import MkEllipsis from './global/MkEllipsis.vue'; +import MkTime from './global/MkTime.vue'; +import MkUrl from './global/MkUrl.vue'; import I18n from './global/i18n'; -import RouterView from './global/router-view.vue'; -import MkLoading from './global/loading.vue'; -import MkError from './global/error.vue'; -import MkAd from './global/ad.vue'; -import MkPageHeader from './global/page-header.vue'; -import MkSpacer from './global/spacer.vue'; -import MkStickyContainer from './global/sticky-container.vue'; +import RouterView from './global/RouterView.vue'; +import MkLoading from './global/MkLoading.vue'; +import MkError from './global/MkError.vue'; +import MkAd from './global/MkAd.vue'; +import MkPageHeader from './global/MkPageHeader.vue'; +import MkSpacer from './global/MkSpacer.vue'; +import MkStickyContainer from './global/MkStickyContainer.vue'; export default function(app: App) { app.component('I18n', I18n); diff --git a/packages/client/src/components/mfm.ts b/packages/client/src/components/mfm.ts index 16ae45bfe..688857a49 100644 --- a/packages/client/src/components/mfm.ts +++ b/packages/client/src/components/mfm.ts @@ -1,15 +1,15 @@ import { VNode, defineComponent, h } from 'vue'; import * as mfm from 'mfm-js'; -import MkUrl from '@/components/global/url.vue'; -import MkLink from '@/components/link.vue'; -import MkMention from '@/components/mention.vue'; -import MkEmoji from '@/components/global/emoji.vue'; +import MkUrl from '@/components/global/MkUrl.vue'; +import MkLink from '@/components/MkLink.vue'; +import MkMention from '@/components/MkMention.vue'; +import MkEmoji from '@/components/global/MkEmoji.vue'; import { concat } from '@/scripts/array'; -import MkFormula from '@/components/formula.vue'; -import MkCode from '@/components/code.vue'; -import MkGoogle from '@/components/google.vue'; -import MkSparkle from '@/components/sparkle.vue'; -import MkA from '@/components/global/a.vue'; +import MkFormula from '@/components/MkFormula.vue'; +import MkCode from '@/components/MkCode.vue'; +import MkGoogle from '@/components/MkGoogle.vue'; +import MkSparkle from '@/components/MkSparkle.vue'; +import MkA from '@/components/global/MkA.vue'; import { host } from '@/config'; import { MFM_TAGS } from '@/scripts/mfm-tags'; diff --git a/packages/client/src/components/page/page.button.vue b/packages/client/src/components/page/page.button.vue index a87f6e9f0..e8878df8d 100644 --- a/packages/client/src/components/page/page.button.vue +++ b/packages/client/src/components/page/page.button.vue @@ -6,7 +6,7 @@ diff --git a/packages/client/src/components/ui/hr.vue b/packages/client/src/components/ui/hr.vue deleted file mode 100644 index 0cb5b4887..000000000 --- a/packages/client/src/components/ui/hr.vue +++ /dev/null @@ -1,17 +0,0 @@ - - - - - diff --git a/packages/client/src/components/url-preview-popup.vue b/packages/client/src/components/url-preview-popup.vue deleted file mode 100644 index 5f3717ab9..000000000 --- a/packages/client/src/components/url-preview-popup.vue +++ /dev/null @@ -1,60 +0,0 @@ - - - - - diff --git a/packages/client/src/directives/ripple.ts b/packages/client/src/directives/ripple.ts index f1d41ddb0..5329d021f 100644 --- a/packages/client/src/directives/ripple.ts +++ b/packages/client/src/directives/ripple.ts @@ -1,4 +1,4 @@ -import Ripple from '@/components/ripple.vue'; +import Ripple from '@/components/MkRipple.vue'; import { popup } from '@/os'; export default { diff --git a/packages/client/src/directives/tooltip.ts b/packages/client/src/directives/tooltip.ts index 8b68771ca..5d13497b5 100644 --- a/packages/client/src/directives/tooltip.ts +++ b/packages/client/src/directives/tooltip.ts @@ -46,7 +46,7 @@ export default { if (self.text == null) return; const showing = ref(true); - popup(defineAsyncComponent(() => import('@/components/ui/tooltip.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkTooltip.vue')), { showing, text: self.text, asMfm: binding.modifiers.mfm, diff --git a/packages/client/src/directives/user-preview.ts b/packages/client/src/directives/user-preview.ts index 9d18a6987..c46167662 100644 --- a/packages/client/src/directives/user-preview.ts +++ b/packages/client/src/directives/user-preview.ts @@ -24,7 +24,7 @@ export class UserPreview { const showing = ref(true); - popup(defineAsyncComponent(() => import('@/components/user-preview.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkUserPreview.vue')), { showing, q: this.user, source: this.el diff --git a/packages/client/src/init.ts b/packages/client/src/init.ts index 94e7f9f6b..737c044e6 100644 --- a/packages/client/src/init.ts +++ b/packages/client/src/init.ts @@ -14,7 +14,7 @@ if (localStorage.getItem('accounts') != null) { //#endregion import { computed, createApp, watch, markRaw, version as vueVersion, defineAsyncComponent } from 'vue'; -import compareVersions from 'compare-versions'; +import { compareVersions } from 'compare-versions'; import JSON5 from 'json5'; import widgets from '@/widgets'; @@ -241,7 +241,7 @@ import { getAccountFromId } from '@/scripts/get-account-from-id'; if (lastVersion != null && compareVersions(version, lastVersion) === 1) { // ログインしてる場合だけ if ($i) { - popup(defineAsyncComponent(() => import('@/components/updated.vue')), {}, {}, 'closed'); + popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, {}, 'closed'); } } } catch (err) { diff --git a/packages/client/src/os.ts b/packages/client/src/os.ts index 00dae867d..515fc4781 100644 --- a/packages/client/src/os.ts +++ b/packages/client/src/os.ts @@ -5,8 +5,8 @@ import { EventEmitter } from 'eventemitter3'; import insertTextAtCursor from 'insert-text-at-cursor'; import * as Misskey from 'misskey-js'; import { apiUrl, url } from '@/config'; -import MkPostFormDialog from '@/components/post-form-dialog.vue'; -import MkWaitingDialog from '@/components/waiting-dialog.vue'; +import MkPostFormDialog from '@/components/MkPostFormDialog.vue'; +import MkWaitingDialog from '@/components/MkWaitingDialog.vue'; import { MenuItem } from '@/types/menu'; import { $i } from '@/account'; @@ -187,19 +187,19 @@ export async function popup(component: Component, props: Record, ev } export function pageWindow(path: string) { - popup(defineAsyncComponent(() => import('@/components/page-window.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkPageWindow.vue')), { initialPath: path, }, {}, 'closed'); } export function modalPageWindow(path: string) { - popup(defineAsyncComponent(() => import('@/components/modal-page-window.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkModalPageWindow.vue')), { initialPath: path, }, {}, 'closed'); } export function toast(message: string) { - popup(defineAsyncComponent(() => import('@/components/toast.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkToast.vue')), { message, }, {}, 'closed'); } @@ -210,7 +210,7 @@ export function alert(props: { text?: string | null; }): Promise { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/dialog.vue')), props, { + popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), props, { done: result => { resolve(); }, @@ -224,7 +224,7 @@ export function confirm(props: { text?: string | null; }): Promise<{ canceled: boolean }> { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/dialog.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { ...props, showCancelButton: true, }, { @@ -245,7 +245,7 @@ export function inputText(props: { canceled: false; result: string; }> { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/dialog.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { title: props.title, text: props.text, input: { @@ -270,7 +270,7 @@ export function inputNumber(props: { canceled: false; result: number; }> { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/dialog.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { title: props.title, text: props.text, input: { @@ -295,7 +295,7 @@ export function inputDate(props: { canceled: false; result: Date; }> { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/dialog.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { title: props.title, text: props.text, input: { @@ -332,7 +332,7 @@ export function select(props: { canceled: false; result: C; }> { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/dialog.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { title: props.title, text: props.text, select: { @@ -354,7 +354,7 @@ export function success() { window.setTimeout(() => { showing.value = false; }, 1000); - popup(defineAsyncComponent(() => import('@/components/waiting-dialog.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { success: true, showing: showing, }, { @@ -366,7 +366,7 @@ export function success() { export function waiting() { return new Promise((resolve, reject) => { const showing = ref(true); - popup(defineAsyncComponent(() => import('@/components/waiting-dialog.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { success: false, showing: showing, }, { @@ -377,7 +377,7 @@ export function waiting() { export function form(title, form) { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/form-dialog.vue')), { title, form }, { + popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form }, { done: result => { resolve(result); }, @@ -387,7 +387,7 @@ export function form(title, form) { export async function selectUser() { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/user-select-dialog.vue')), {}, { + popup(defineAsyncComponent(() => import('@/components/MkUserSelectDialog.vue')), {}, { ok: user => { resolve(user); }, @@ -397,7 +397,7 @@ export async function selectUser() { export async function selectDriveFile(multiple: boolean) { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/drive-select-dialog.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), { type: 'file', multiple, }, { @@ -412,7 +412,7 @@ export async function selectDriveFile(multiple: boolean) { export async function selectDriveFolder(multiple: boolean) { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/drive-select-dialog.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), { type: 'folder', multiple, }, { @@ -427,7 +427,7 @@ export async function selectDriveFolder(multiple: boolean) { export async function pickEmoji(src: HTMLElement | null, opts) { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/emoji-picker-dialog.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { src, ...opts, }, { @@ -442,7 +442,7 @@ export async function cropImage(image: Misskey.entities.DriveFile, options: { aspectRatio: number; }): Promise { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/cropper-dialog.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), { file: image, aspectRatio: options.aspectRatio, }, { @@ -492,7 +492,7 @@ export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea: characterData: false, }); - openingEmojiPicker = await popup(defineAsyncComponent(() => import('@/components/emoji-picker-window.vue')), { + openingEmojiPicker = await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerWindow.vue')), { src, ...opts, }, { @@ -514,7 +514,7 @@ export function popupMenu(items: MenuItem[] | Ref, src?: HTMLElement }) { return new Promise((resolve, reject) => { let dispose; - popup(defineAsyncComponent(() => import('@/components/ui/popup-menu.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkPopupMenu.vue')), { items, src, width: options?.width, @@ -535,7 +535,7 @@ export function contextMenu(items: MenuItem[] | Ref, ev: MouseEvent) ev.preventDefault(); return new Promise((resolve, reject) => { let dispose; - popup(defineAsyncComponent(() => import('@/components/ui/context-menu.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkContextMenu.vue')), { items, ev, }, { diff --git a/packages/client/src/pages/_error_.vue b/packages/client/src/pages/_error_.vue index 6ac1f4297..a90a023cb 100644 --- a/packages/client/src/pages/_error_.vue +++ b/packages/client/src/pages/_error_.vue @@ -20,7 +20,7 @@ diff --git a/packages/client/src/pages/about.federation.vue b/packages/client/src/pages/about.federation.vue index 8d9390872..c501a7758 100644 --- a/packages/client/src/pages/about.federation.vue +++ b/packages/client/src/pages/about.federation.vue @@ -48,11 +48,11 @@ diff --git a/packages/client/src/pages/gallery/edit.vue b/packages/client/src/pages/gallery/edit.vue index 2e0e5063e..8f716d9eb 100644 --- a/packages/client/src/pages/gallery/edit.vue +++ b/packages/client/src/pages/gallery/edit.vue @@ -32,7 +32,7 @@