diff --git a/.config/example.yml b/.config/example.yml index 75be5157d..2924114fb 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -133,3 +133,10 @@ redis: #allowedPrivateNetworks: [ # '127.0.0.1/32' #] + +# images used on error screens. You can use absolute or relative URLs. +# If you use relative URLs, be aware that the URL may be used on different pages/paths, so the path component should be absolute. +#images: +# info: /twemoji/1f440.svg +# notFound: /twemoji/2049.svg +# error: /twemoji/1f480.svg diff --git a/.dockerignore b/.dockerignore index fd28d7d47..87b4197df 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,6 @@ .autogen -.vscode .config +.woodpecker Dockerfile build/ built/ diff --git a/.editorconfig b/.editorconfig index edccf3a9d..d30203e97 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,9 +2,10 @@ root = true [*] indent_style = tab -indent_size = 2 +indent_size = 4 charset = utf-8 insert_final_newline = true [*.yml] indent_style = space +indent_size = 2 diff --git a/.gitattributes b/.gitattributes index a175917f3..88c9bd2d5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,7 +1 @@ *.svg -diff -text -*.psd -diff -text -*.ai -diff -text -*.mqo -diff -text -*.glb -diff -text -*.blend -diff -text -*.afdesign -diff -text diff --git a/.gitignore b/.gitignore index f8e8f4c9b..a43c8dd5f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # Visual Studio Code /.vscode -!/.vscode/extensions.json +/.vsls.json # Intelij-IDEA /.idea diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index 795562e70..000000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "recommendations": [ - "editorconfig.editorconfig", - "dbaeumer.vscode-eslint", - "Vue.volar", - "Vue.vscode-typescript-vue-plugin" - ] -} diff --git a/.vsls.json b/.vsls.json deleted file mode 100644 index 3fff86244..000000000 --- a/.vsls.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "http://json.schemastore.org/vsls", - "gitignore": "exclude" -} diff --git a/.woodpecker/lint-sw.yml b/.woodpecker/lint-sw.yml new file mode 100644 index 000000000..4a20add2e --- /dev/null +++ b/.woodpecker/lint-sw.yml @@ -0,0 +1,22 @@ +clone: + git: + image: woodpeckerci/plugin-git + settings: + depth: 1 # CI does not need commit history + recursive: true + +pipeline: + install: + when: + event: + - pull_request + image: node:18.6.0 + commands: + - yarn install + lint: + when: + event: + - pull_request + image: node:18.6.0 + commands: + - yarn workspace sw run lint diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 09a3a91e5..943a6bec6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -316,8 +316,8 @@ This does not apply when using the Composition API since reactivation is manual. If you import json in TypeScript, the json file will be spit out together with the TypeScript file into the dist directory when compiling with tsc. This behavior may cause unintentional rewriting of files, so when importing json files, be sure to check whether the files are allowed to be rewritten or not. If you do not want the file to be rewritten, you should make sure that the file can be rewritten by importing the json file. If you do not want the file to be rewritten, use functions such as `fs.readFileSync` to read the file instead of importing it. ### Component style definitions do not have a `margin` -Setting the `margin` of a component may be confusing. -Instead, it should always be the user of a component that sets a `margin`. +~~Setting the `margin` of a component may be confusing. Instead, it should always be the user of a component that sets a `margin`.~~ +This was a philosophy used previously. Hoever it now seems a better idea to add a default margin to the top level element of a component which can be easily overwritten on the usage of that component with a `style` attribute. ### Do not use the word "follow" in HTML class names This has caused things to be blocked by an ad blocker in the past. diff --git a/assets/about/drive.png b/assets/about/drive.png deleted file mode 100644 index 16037aae3..000000000 Binary files a/assets/about/drive.png and /dev/null differ diff --git a/assets/about/post.png b/assets/about/post.png deleted file mode 100644 index 3c55f66c5..000000000 Binary files a/assets/about/post.png and /dev/null differ diff --git a/assets/about/reaction.png b/assets/about/reaction.png deleted file mode 100644 index e4e7e06bc..000000000 Binary files a/assets/about/reaction.png and /dev/null differ diff --git a/assets/about/ui.png b/assets/about/ui.png deleted file mode 100644 index 0601837f4..000000000 Binary files a/assets/about/ui.png and /dev/null differ diff --git a/assets/ai-orig.png b/assets/ai-orig.png deleted file mode 100644 index 2837456a9..000000000 Binary files a/assets/ai-orig.png and /dev/null differ diff --git a/assets/ai.png b/assets/ai.png deleted file mode 100644 index c1eab6608..000000000 Binary files a/assets/ai.png and /dev/null differ diff --git a/assets/banner.afdesign b/assets/banner.afdesign deleted file mode 100644 index 08b5c1b4a..000000000 Binary files a/assets/banner.afdesign and /dev/null differ diff --git a/assets/mi-white.afdesign b/assets/mi-white.afdesign deleted file mode 100644 index b0a309d1c..000000000 Binary files a/assets/mi-white.afdesign and /dev/null differ diff --git a/assets/mi.afdesign b/assets/mi.afdesign deleted file mode 100644 index e7f6331c4..000000000 Binary files a/assets/mi.afdesign and /dev/null differ diff --git a/assets/ss/explore.jpg b/assets/ss/explore.jpg deleted file mode 100644 index bf81d794c..000000000 Binary files a/assets/ss/explore.jpg and /dev/null differ diff --git a/assets/ss/user.jpg b/assets/ss/user.jpg deleted file mode 100644 index 3ec595c19..000000000 Binary files a/assets/ss/user.jpg and /dev/null differ diff --git a/assets/title.png b/assets/title.png deleted file mode 100644 index b94e66c20..000000000 Binary files a/assets/title.png and /dev/null differ diff --git a/docs/oauth.md b/docs/oauth.md new file mode 100644 index 000000000..9b9f3814a --- /dev/null +++ b/docs/oauth.md @@ -0,0 +1,42 @@ +# 3rd party access +Foundkey supports: +- OAuth 2.0 Authorization Code grant per [RFC 6749](https://www.rfc-editor.org/rfc/rfc6749). +- OAuth Bearer Token Usage per [RFC 6750](https://www.rfc-editor.org/rfc/rfc6750). +- Proof Key for Code Exchange (PKCE) per [RFC 7636](https://www.rfc-editor.org/rfc/rfc7636). +- OAuth 2.0 Authorization Server Metadata per [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414.html). + +# Discovery +Because the implementation may change in the future, it is recommended that you use OAuth 2.0 Authorization Server Metadata a.k.a. OpenID Connect Discovery. +In short, this means that to discover the URLs for the grant endpoints you should request `/.well-known/oauth-authorization-server`, which is a JSON object. +From there, `authorization_endpoint` and `token_endpoint` will probably be most interesting to you. +The definitions of all data fields are to be found in [RFC 8414, section 2](https://www.rfc-editor.org/rfc/rfc8414#section-2). + +# App registration +Before using the OAuth grant you need to register your application. +Currently you will need to use the pre-existing Misskey API to register, though Dynamic Client Registration may be implemented at a later point. +(You'd be able to tell from the Authorization Server Metadata, see above.) + +The data you will need to know before registering is the following: +- a name for your app, +- a short description to be shown to users, +- which API permissions you need, and +- the callback URL you want to use. + +There can only be 1 callback URL per registration. + +Note that you can specify permissions a 2nd time in the OAuth flow. +If you do not provide permissions again in the grant flow, the default is to use all permissions you gave when registering the app. +If you do provide permissions in the grant flow, permissions that were not registered will never be granted. +A list of available permissions can be viewed on any Foundkey instance by going to the API documentation at `/api-doc`. + +To register your app you need to `POST` to `/api/app/create`. +The body of the request must be a JSON object with the following keys: +- `name` (string): a name for your app, +- `description` (string): a short description to be shown to users, +- `permission` (array of permission names) which API permissions you need, and +- `callbackUrl` (string): the callback URL you want to use. + +If successful (HTTP response code 200) you will receive back a JSON object containing among other things: +- `id` (string): the client ID +- `secret` (string): the client secret +With these credentials you should be able to use the Authorization Code grant to obtain authorization. diff --git a/gulpfile.js b/gulpfile.js index 12ddcaddb..898869b06 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -36,7 +36,7 @@ gulp.task('copy:client:locales', cb => { }); gulp.task('build:backend:script', () => { - return gulp.src(['./packages/backend/src/server/web/boot.js', './packages/backend/src/server/web/bios.js', './packages/backend/src/server/web/cli.js']) + return gulp.src(['./packages/backend/src/server/web/boot.js']) .pipe(replace('LANGS', JSON.stringify(Object.keys(locales)))) .pipe(terser({ toplevel: true @@ -45,7 +45,7 @@ gulp.task('build:backend:script', () => { }); gulp.task('build:backend:style', () => { - return gulp.src(['./packages/backend/src/server/web/style.css', './packages/backend/src/server/web/bios.css', './packages/backend/src/server/web/cli.css']) + return gulp.src(['./packages/backend/src/server/web/style.css']) .pipe(cssnano({ zindex: false })) diff --git a/locales/ar-SA.yml b/locales/ar-SA.yml index 8493f72ba..74869f65a 100644 --- a/locales/ar-SA.yml +++ b/locales/ar-SA.yml @@ -293,9 +293,6 @@ dayX: "{day}" monthX: "{month}" yearX: "{year}" pages: "الصفحات" -integration: "التكامل" -connectService: "اتصل" -disconnectService: "اقطع الاتصال" enableLocalTimeline: "تفعيل الخيط المحلي" enableGlobalTimeline: "تفعيل الخيط الزمني الشامل" disablingTimelinesInfo: "سيتمكن المديرون والمشرفون من الوصول إلى كل الخيوط الزمنية\ @@ -402,7 +399,6 @@ normalPassword: "الكلمة السرية جيدة" strongPassword: "الكلمة السرية قوية" passwordMatched: "التطابق صحيح!" passwordNotMatched: "غير متطابقتان" -signinWith: "الولوج عبر {x}" signinFailed: "فشل الولوج، خطأ في اسم المستخدم أو كلمة المرور." tapSecurityKey: "أنقر مفتاح الأمان" or: "أو" diff --git a/locales/bn-BD.yml b/locales/bn-BD.yml index 9cee3b901..cdaac8b2d 100644 --- a/locales/bn-BD.yml +++ b/locales/bn-BD.yml @@ -308,9 +308,6 @@ dayX: "{day}" monthX: "{month}" yearX: "{year}" pages: "পৃষ্ঠা" -integration: "ইন্টিগ্রেশন" -connectService: "সংযুক্ত করুন" -disconnectService: "সংযোগ বিচ্ছিন্ন করুন" enableLocalTimeline: "স্থানীয় টাইমলাইন চালু করুন" enableGlobalTimeline: "গ্লোবাল টাইমলাইন চালু করুন" disablingTimelinesInfo: "আপনি এই টাইমলাইনগুলি বন্ধ করলেও প্রশাসক এবং মডারেটররা এই\ @@ -417,7 +414,6 @@ normalPassword: "সাধারণ পাসওয়ার্ড" strongPassword: "শক্তিশালী পাসওয়ার্ড" passwordMatched: "মিলেছে" passwordNotMatched: "মিলেনি" -signinWith: "{x} এর সাহায্যে সাইন ইন করুন" signinFailed: "লগ ইন করা যায়নি। আপনার ব্যবহারকারীর নাম এবং পাসওয়ার্ড চেক করুন." tapSecurityKey: "সিকিউরিটি কী স্পর্শ করুন" or: "অথবা" diff --git a/locales/cs-CZ.yml b/locales/cs-CZ.yml index 69e01db57..53c7f4aa4 100644 --- a/locales/cs-CZ.yml +++ b/locales/cs-CZ.yml @@ -277,9 +277,6 @@ dayX: "{day}" monthX: "{month}" yearX: "{year}" pages: "Stránky" -integration: "Integrace" -connectService: "Připojit" -disconnectService: "Odpojit" enableLocalTimeline: "Povolit lokální čas" enableGlobalTimeline: "Povolit globální čas" enableRegistration: "Povolit registraci novým uživatelům" @@ -353,7 +350,6 @@ normalPassword: "Dobré heslo" strongPassword: "Silné heslo" 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." or: "Nebo" language: "Jazyk" diff --git a/locales/de-DE.yml b/locales/de-DE.yml index aa97782f0..ff6e082ab 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -321,9 +321,6 @@ dayX: "{day}" monthX: "{month}" yearX: "{year}" pages: "Seiten" -integration: "Integration" -connectService: "Verbinden" -disconnectService: "Trennen" enableLocalTimeline: "Lokale Chronik aktivieren" enableGlobalTimeline: "Globale Chronik aktivieren" disablingTimelinesInfo: "Administratoren und Moderatoren haben immer Zugriff auf alle\ @@ -433,7 +430,6 @@ normalPassword: "Durchschnittliches Passwort" strongPassword: "Starkes Passwort" passwordMatched: "Stimmt überein" passwordNotMatched: "Stimmt nicht überein" -signinWith: "Mit {x} anmelden" signinFailed: "Anmeldung fehlgeschlagen. Überprüfe Benutzername und Passswort." tapSecurityKey: "Tippe deinen Sicherheitsschlüssel an" or: "Oder" @@ -1373,18 +1369,6 @@ recommended: "Empfehlung" check: "Check" maxCustomEmojiPicker: Maximale Anzahl vorgeschlagener benutzerdefinierter Emoji maxUnicodeEmojiPicker: Maximale Anzahl vorgeschlagener Unicode-Emoji -_services: - _discord: - connected: 'Discord: @{username}#{discriminator} wurde mit Foundkey-Account @{mkUsername} - verknüpft!' - disconnected: Discord-Verknüpfung wurde entfernt. - _twitter: - connected: Twitter-Account @{twitterUserName} wurde mit Foundkey-Account @{userName} - verknüpft! - disconnected: Twitter-Verknüpfung wurde entfernt. - _github: - connected: GitHub-Account @{login} wurde mit Foundkey-Account @{userName} verknüpft! - disconnected: GitHub-Verknüpfung wurde entfernt. documentation: Dokumentation signinHistoryExpires: Frühere Login-Versuche werden aus Datenschutzgründen nach 60 Tagen automatisch gelöscht. diff --git a/locales/en-US.yml b/locales/en-US.yml index 4dfd7f055..004271869 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -96,6 +96,8 @@ unfollow: "Unfollow" followRequestPending: "Follow request pending" renote: "Renote" unrenote: "Take back renote" +unrenoteAll: "Take back all renotes" +unrenoteAllConfirm: "Are you sure that you want to take back all renotes of this note?" quote: "Quote" pinnedNote: "Pinned note" you: "You" @@ -187,7 +189,7 @@ clearCachedFiles: "Clear cache" clearCachedFilesConfirm: "Are you sure that you want to delete all cached remote files?" blockedInstances: "Blocked Instances" blockedInstancesDescription: "List the hostnames of the instances that you want to\ - \ block. Listed instances will no longer be able to communicate with this instance. Non-ASCII domain names must be encoded in punycode. You can use an asterisk (*) as a placeholder for zero or more character(s)." + \ block. Listed instances will no longer be able to communicate with this instance. Non-ASCII domain names must be encoded in punycode. Subdomains of the listed instances will also be blocked." muteAndBlock: "Mutes and Blocks" mutedUsers: "Muted users" blockedUsers: "Blocked users" @@ -311,9 +313,6 @@ dayX: "{day}" monthX: "{month}" yearX: "{year}" pages: "Pages" -integration: "Integration" -connectService: "Connect" -disconnectService: "Disconnect" enableLocalTimeline: "Enable local timeline" enableGlobalTimeline: "Enable global timeline" disablingTimelinesInfo: "Adminstrators and Moderators will always have access to all\ @@ -420,7 +419,6 @@ normalPassword: "Average password" strongPassword: "Strong password" passwordMatched: "Matches" passwordNotMatched: "Does not match" -signinWith: "Sign in with {x}" signinFailed: "Unable to sign in. The entered username or password is incorrect." tapSecurityKey: "Tap your security key" or: "Or" @@ -829,6 +827,10 @@ setTag: "Set tag" addTag: "Add tag" removeTag: "Remove tag" externalCssSnippets: "Some CSS snippets for your inspiration (not managed by FoundKey)" +oauthErrorGoBack: "An error happened while trying to authenticate a 3rd party app.\ + \ Please go back and try again." +appAuthorization: "App authorization" +noPermissionsRequested: "(No permissions requested.)" _emailUnavailable: used: "This email address is already being used" format: "The format of this email address is invalid" @@ -1079,38 +1081,37 @@ _2fa: \ authentication via hardware security keys that support FIDO2 to further secure\ \ your account." _permissions: - "read:account": "View your account information" - "write:account": "Edit your account information" - "read:blocks": "View your list of blocked users" - "write:blocks": "Edit your list of blocked users" - "read:drive": "Access your Drive files and folders" - "write:drive": "Edit or delete your Drive files and folders" - "read:favorites": "View your list of favorites" - "write:favorites": "Edit your list of favorites" - "read:following": "View information on who you follow" - "write:following": "Follow or unfollow other accounts" - "read:messaging": "View your chats" - "write:messaging": "Compose or delete chat messages" - "read:mutes": "View your list of muted users" - "write:mutes": "Edit your list of muted users" - "write:notes": "Compose or delete notes" - "read:notifications": "View your notifications" - "write:notifications": "Manage your notifications" - "read:reactions": "View your reactions" - "write:reactions": "Edit your reactions" - "write:votes": "Vote on a poll" - "read:pages": "View your pages" - "write:pages": "Edit or delete your pages" - "read:page-likes": "View your likes on pages" - "write:page-likes": "Edit your likes on pages" - "read:user-groups": "View your user groups" - "write:user-groups": "Edit or delete your user groups" - "read:channels": "View your channels" - "write:channels": "Edit your channels" - "read:gallery": "View your gallery" - "write:gallery": "Edit your gallery" - "read:gallery-likes": "View your list of liked gallery posts" - "write:gallery-likes": "Edit your list of liked gallery posts" + "read:account": "Read account information" + "write:account": "Edit account information" + "read:blocks": "Read which users are blocked" + "write:blocks": "Block and unblock users" + "read:drive": "List files and folders in the drive" + "write:drive": "Create, change and delete files in the drive" + "read:favorites": "List favourited notes" + "write:favorites": "Favorite and unfavorite notes" + "read:following": "List followed and following users" + "write:following": "Follow and unfollow other users" + "read:messaging": "View chat messages and history" + "write:messaging": "Create and delete chat messages" + "read:mutes": "List users which are muted or whose renotes are muted" + "write:mutes": "Mute and unmute users or their renotes" + "write:notes": "Create and delete notes" + "read:notifications": "Read notifications" + "write:notifications": "Mark notifications as read and create custom notifications" + "write:reactions": "Create and delete reactions" + "write:votes": "Vote in polls" + "read:pages": "List and read pages" + "write:pages": "Create, change and delete pages" + "read:page-likes": "List and read page likes" + "write:page-likes": "Like and unlike pages" + "read:user-groups": "List and view joined, owned and invited to groups" + "write:user-groups": "Create, modify, delete, transfer, join and leave groups. Invite and ban others from groups. Accept and reject group invitations." + "read:channels": "List and read followed and joined channels" + "write:channels": "Create, modify, follow and unfollow channels" + "read:gallery": "List and read gallery posts" + "write:gallery": "Create, modify and delete gallery posts" + "read:gallery-likes": "List and read gallery post likes" + "write:gallery-likes": "Like and unlike gallery posts" _auth: shareAccess: "Would you like to authorize \"{name}\" to access this account?" shareAccessAsk: "Are you sure you want to authorize this application to access your\ @@ -1342,16 +1343,6 @@ _deck: list: "List" mentions: "Mentions" direct: "Direct notes" -_services: - _discord: - connected: "Discord: @{username}#{discriminator} connected to FoundKey: @{mkUsername}!" - disconnected: "Discord linkage has been removed." - _twitter: - connected: "Twitter: @{twitterUserName} connected to FoundKey: @{userName}!" - disconnected: "Twitter linkage has been removed." - _github: - connected: "GitHub: @{login} connected to FoundKey: @{userName}!" - disconnected: "GitHub linkage has been removed." _translationService: _deepl: authKey: "DeepL Auth Key" diff --git a/locales/es-ES.yml b/locales/es-ES.yml index 969639f9b..8e022fa27 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -311,9 +311,6 @@ dayX: "Día {day}" monthX: "Mes {month}" yearX: "Año {year}" pages: "Páginas" -integration: "Integración" -connectService: "Conectar" -disconnectService: "Desconectar" enableLocalTimeline: "Habilitar linea de tiempo local" enableGlobalTimeline: "Habilitar linea de tiempo global" disablingTimelinesInfo: "Aunque se desactiven estas lineas de tiempo, por conveniencia\ @@ -420,7 +417,6 @@ normalPassword: "Buena contraseña" strongPassword: "Muy buena contraseña" passwordMatched: "Correcto" passwordNotMatched: "Las contraseñas no son las mismas" -signinWith: "Inicie sesión con {x}" signinFailed: "Autenticación fallida. Asegúrate de haber usado el nombre de usuario\ \ y contraseña correctos." tapSecurityKey: "Toque la clave de seguridad" diff --git a/locales/fr-FR.yml b/locales/fr-FR.yml index 5150905cb..1cbcb20a4 100644 --- a/locales/fr-FR.yml +++ b/locales/fr-FR.yml @@ -311,9 +311,6 @@ dayX: "{day}" monthX: "{month}" yearX: "{year}" pages: "Pages" -integration: "Intégrations" -connectService: "Connexion" -disconnectService: "Déconnexion" enableLocalTimeline: "Activer le fil local" enableGlobalTimeline: "Activer le fil global" disablingTimelinesInfo: "Même si vous désactivez ces fils, les administrateur·rice·s\ @@ -423,7 +420,6 @@ normalPassword: "Mot de passe acceptable" strongPassword: "Mot de passe fort" passwordMatched: "Les mots de passe correspondent" passwordNotMatched: "Les mots de passe ne correspondent pas" -signinWith: "Se connecter avec {x}" signinFailed: "Échec d’authentification. Veuillez vérifier que votre nom d’utilisateur\ \ et mot de passe sont corrects." tapSecurityKey: "Appuyez sur votre clé de sécurité" @@ -1331,18 +1327,6 @@ _deck: list: "Listes" mentions: "Mentions" direct: "Direct" -_services: - _discord: - connected: '@{username}#{discriminator} sur Discord est connecté à @{mkUsername} - sur FoundKey !' - disconnected: La liaison avec Discord à été supprimée. - _twitter: - connected: '@{twitterUserName} sur Twitter est connecté à @{userName} sur FoundKey - !' - disconnected: La liaison avec Twitter à été supprimée. - _github: - disconnected: La liaison avec Github à été supprimée. - connected: '@{login} sur Github est connecté à @{userName} sur FoundKey !' exportAll: Tout exporter stopActivityDeliveryDescription: L'activité locale ne sera pas envoyé à cette instance. La réception de l'activité continuera de fonctionner comme avant. diff --git a/locales/id-ID.yml b/locales/id-ID.yml index 585a64499..5e9ba9b46 100644 --- a/locales/id-ID.yml +++ b/locales/id-ID.yml @@ -310,9 +310,6 @@ dayX: "{day}" monthX: "{month}" yearX: "{year}" pages: "Halaman" -integration: "Integrasi" -connectService: "Sambungkan" -disconnectService: "Putuskan" enableLocalTimeline: "Nyalakan linimasa lokal" enableGlobalTimeline: "Nyalakan linimasa global" disablingTimelinesInfo: "Admin dan Moderator akan selalu memiliki akses ke semua linimasa\ @@ -419,7 +416,6 @@ normalPassword: "Kata sandi baik" strongPassword: "Kata sandi kuat" passwordMatched: "Kata sandi sama" passwordNotMatched: "Kata sandi tidak sama" -signinWith: "Masuk dengan {x}" signinFailed: "Tidak dapat masuk. Nama pengguna atau kata sandi yang kamu masukkan\ \ salah." tapSecurityKey: "Ketuk kunci keamanan kamu" diff --git a/locales/it-IT.yml b/locales/it-IT.yml index f502484d0..da6a6a7af 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -304,9 +304,6 @@ dayX: "{day}" monthX: "{month}" yearX: "{year}" pages: "Pagine" -integration: "App collegate" -connectService: "Connessione" -disconnectService: "Disconnessione " enableLocalTimeline: "Abilita Timeline locale" enableGlobalTimeline: "Abilita Timeline federata" disablingTimelinesInfo: "Anche se disabiliti queste timeline, gli amministratori e\ @@ -413,7 +410,6 @@ normalPassword: "Password buona" strongPassword: "Password forte" passwordMatched: "Corretta" passwordNotMatched: "Le password non corrispondono." -signinWith: "Accedi con {x}" signinFailed: "Autenticazione non riuscita. Controlla la tua password e nome utente." tapSecurityKey: "Premi la chiave di sicurezza" or: "oppure" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 30f1cbf69..38be75c28 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -286,9 +286,6 @@ dayX: "{day}日" monthX: "{month}月" yearX: "{year}年" pages: "ページ" -integration: "連携" -connectService: "接続する" -disconnectService: "切断する" enableLocalTimeline: "ローカルタイムラインを有効にする" enableGlobalTimeline: "グローバルタイムラインを有効にする" disablingTimelinesInfo: "これらのタイムラインを無効化しても、利便性のため管理者およびモデレーターは引き続き利用することができます。" @@ -392,7 +389,6 @@ normalPassword: "普通のパスワード" strongPassword: "強いパスワード" passwordMatched: "一致しました" passwordNotMatched: "一致していません" -signinWith: "{x}でログイン" signinFailed: "ログインできませんでした。ユーザー名とパスワードを確認してください。" tapSecurityKey: "セキュリティキーにタッチ" or: "もしくは" @@ -1273,13 +1269,3 @@ _deck: list: "リスト" mentions: "あなた宛て" direct: "ダイレクト" -_services: - _discord: - connected: "Discord: @{username}#{discriminator} を、FoundKey: @{mkUsername} に接続しました!" - disconnected: "Discordの連携を解除しました :v:" - _twitter: - connected: "Twitter: @{twitterUserName} を、FoundKey: @{userName} に接続しました!" - disconnected: "Twitterの連携を解除しました :v:" - _github: - connected: "GitHub: @{login} を、FoundKey: @{userName} に接続しました!" - disconnected: "GitHubの連携を解除しました :v:" diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index 9aa2b4358..d365def00 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -288,7 +288,6 @@ dayX: "{day}日" monthX: "{month}月" yearX: "{year}年" pages: "ページ" -integration: "連携" enableLocalTimeline: "ローカルタイムラインを使えるようにする" enableGlobalTimeline: "グローバルタイムラインを使えるようにする" disablingTimelinesInfo: "ここらへんのタイムラインを使えんようにしてしもても、管理者とモデレーターは使えるままになってるで、そうやなかったら不便やからな。" @@ -391,7 +390,6 @@ normalPassword: "普通のパスワード" strongPassword: "ええ感じのパスワード" passwordMatched: "よし!一致や!" passwordNotMatched: "一致しとらんで?" -signinWith: "{x}でログイン" or: "それか" language: "言語" uiLanguage: "UIの表示言語" diff --git a/locales/kab-KAB.yml b/locales/kab-KAB.yml index 55a3984b1..dc4f8ba17 100644 --- a/locales/kab-KAB.yml +++ b/locales/kab-KAB.yml @@ -39,7 +39,6 @@ userList: "Tibdarin" securityKey: "Tasarutt n tɣellist" securityKeyName: "Isem n tsarutt" signinRequired: "Ttxil jerred" -signinWith: "Tuqqna s {x}" tapSecurityKey: "Sekcem tasarutt-ik·im n tɣellist" uiLanguage: "Tutlayt n wegrudem" plugins: "Izegrar" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 4f8cac7cb..69a2ad1af 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -315,9 +315,6 @@ dayX: "{day}일" monthX: "{month}월" yearX: "{year}년" pages: "페이지" -integration: "연동" -connectService: "계정 연동" -disconnectService: "계정 연동 해제" enableLocalTimeline: "로컬 타임라인 활성화" enableGlobalTimeline: "글로벌 타임라인 활성화" disablingTimelinesInfo: "특정 타임라인을 비활성화하더라도 관리자 및 모더레이터는 계속 사용할 수 있습니다." @@ -438,7 +435,6 @@ normalPassword: "좋은 비밀번호" strongPassword: "강한 비밀번호" passwordMatched: "일치합니다" passwordNotMatched: "일치하지 않습니다" -signinWith: "{x}로 로그인" signinFailed: "로그인할 수 없습니다. 사용자명과 비밀번호를 확인하여 주십시오." tapSecurityKey: "보안 키를 터치" or: "혹은" diff --git a/locales/pl-PL.yml b/locales/pl-PL.yml index 4345bede6..ae9065097 100644 --- a/locales/pl-PL.yml +++ b/locales/pl-PL.yml @@ -298,9 +298,6 @@ dayX: "{day}" monthX: "{month}" yearX: "{year}" pages: "Strony" -integration: "Integracja" -connectService: "Połącz" -disconnectService: "Rozłącz" enableLocalTimeline: "Włącz lokalną oś czasu" enableGlobalTimeline: "Włącz globalną oś czasu" disablingTimelinesInfo: "Administratorzy i moderatorzy będą zawsze mieć dostęp do\ @@ -407,7 +404,6 @@ normalPassword: "Dobre hasło" strongPassword: "Silne hasło" passwordMatched: "Pasuje" passwordNotMatched: "Hasła nie pasują do siebie" -signinWith: "Zaloguj się z {x}" signinFailed: "Nie udało się zalogować. Wprowadzona nazwa użytkownika lub hasło są\ \ nieprawidłowe." tapSecurityKey: "Wybierz swój klucz bezpieczeństwa" diff --git a/locales/ro-RO.yml b/locales/ro-RO.yml index 1b343337a..34c2ce7f6 100644 --- a/locales/ro-RO.yml +++ b/locales/ro-RO.yml @@ -311,9 +311,6 @@ dayX: "{day}" monthX: "{month}" yearX: "{year}" pages: "Pagini" -integration: "Integrare" -connectService: "Conectează" -disconnectService: "Deconectează" enableLocalTimeline: "Activează cronologia locală" enableGlobalTimeline: "Activeaza cronologia globală" disablingTimelinesInfo: "Administratorii și Moderatorii vor avea mereu access la toate\ @@ -420,7 +417,6 @@ normalPassword: "Parolă medie" strongPassword: "Parolă puternică" passwordMatched: "Se potrivește!" passwordNotMatched: "Nu se potrivește" -signinWith: "Autentifică-te cu {x}" signinFailed: "Nu se poate autentifica. Numele de utilizator sau parola introduse\ \ sunt incorecte." tapSecurityKey: "Apasă pe cheia ta de securitate." diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index 697bf9f9e..e851d93cb 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -304,9 +304,6 @@ dayX: "{day} день" monthX: "{month} месяц" yearX: "{year} год" pages: "Страницы" -integration: "Интеграция" -connectService: "Подключиться" -disconnectService: "Отключиться" enableLocalTimeline: "Включить локальную ленту" enableGlobalTimeline: "Включить глобальную ленту" disablingTimelinesInfo: "У администраторов и модераторов есть доступ ко всем лентам,\ @@ -415,7 +412,6 @@ normalPassword: "Годный пароль" strongPassword: "Надёжный пароль" passwordMatched: "Совпали" passwordNotMatched: "Не совпадают" -signinWith: "Использовать {x} для входа" signinFailed: "Невозможно войти в систему. Введенное вами имя пользователя или пароль\ \ неверны." tapSecurityKey: "Нажмите на свой электронный ключ" diff --git a/locales/sk-SK.yml b/locales/sk-SK.yml index 1b41b5d5a..ca8b01729 100644 --- a/locales/sk-SK.yml +++ b/locales/sk-SK.yml @@ -305,9 +305,6 @@ dayX: "{day}" monthX: "{month}" yearX: "{year}" pages: "Stránky" -integration: "Integrácia" -connectService: "Pripojiť" -disconnectService: "Odpojiť" enableLocalTimeline: "Povoliť lokálnu časovú os" enableGlobalTimeline: "Povoliť globálnu časovú os" disablingTimelinesInfo: "Administrátori a moderátori majú vždy prístup ku všetkým\ @@ -414,7 +411,6 @@ normalPassword: "Dobré heslo" strongPassword: "Silné heslo" passwordMatched: "Heslá sú rovnaké" passwordNotMatched: "Heslá nie sú rovnaké" -signinWith: "Prihlásiť sa použitím {x}" signinFailed: "Nedá sa prihlásiť. Skontrolujte prosím meno používateľa a heslo." tapSecurityKey: "Ťuknite na bezpečnostný kľúč" or: "Alebo" diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index 8964a01cd..7f06ac629 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -305,9 +305,6 @@ dayX: "{day}" monthX: "{month}" yearX: "{year}" pages: "Сторінки" -integration: "Інтеграція" -connectService: "Під’єднати" -disconnectService: "Відключитися" enableLocalTimeline: "Увімкнути локальну стрічку" enableGlobalTimeline: "Увімкнути глобальну стрічку" disablingTimelinesInfo: "Адміністратори та модератори завжди мають доступ до всіх\ @@ -414,7 +411,6 @@ normalPassword: "Достатній пароль" strongPassword: "Міцний пароль" passwordMatched: "Все вірно" passwordNotMatched: "Паролі не співпадають" -signinWith: "Увійти за допомогою {x}" signinFailed: "Не вдалося увійти. Введені ім’я користувача або пароль неправильнi." tapSecurityKey: "Торкніться ключа безпеки" or: "або" diff --git a/locales/vi-VN.yml b/locales/vi-VN.yml index 7b450e850..59e95a6ae 100644 --- a/locales/vi-VN.yml +++ b/locales/vi-VN.yml @@ -305,9 +305,6 @@ dayX: "{day}" monthX: "{month}" yearX: "{year}" pages: "Trang" -integration: "Tương tác" -connectService: "Kết nối" -disconnectService: "Ngắt kết nối" enableLocalTimeline: "Bật bảng tin máy chủ" enableGlobalTimeline: "Bật bảng tin liên hợp" disablingTimelinesInfo: "Quản trị viên và Kiểm duyệt viên luôn có quyền truy cập mọi\ @@ -415,7 +412,6 @@ normalPassword: "Mật khẩu tạm được" strongPassword: "Mật khẩu mạnh" passwordMatched: "Trùng khớp" passwordNotMatched: "Không trùng khớp" -signinWith: "Đăng nhập bằng {x}" signinFailed: "Không thể đăng nhập. Vui lòng kiểm tra tên người dùng và mật khẩu của\ \ bạn." tapSecurityKey: "Nhấn mã bảo mật của bạn" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index e7f1b58a9..d01be6f06 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -284,9 +284,6 @@ dayX: "{day}日" monthX: "{month}月" yearX: "{year}年" pages: "页面" -integration: "关联" -connectService: "连接" -disconnectService: "断开连接" enableLocalTimeline: "启用本地时间线功能" enableGlobalTimeline: "启用全局时间线" disablingTimelinesInfo: "即使时间线功能被禁用,出于方便,管理员和数据图表也可以继续使用。" @@ -390,7 +387,6 @@ normalPassword: "密码强度:中等" strongPassword: "密码强度:强" passwordMatched: "密码一致" passwordNotMatched: "密码不一致" -signinWith: "以{x}登录" signinFailed: "无法登录,请检查您的用户名和密码是否正确。" tapSecurityKey: "轻触硬件安全密钥" or: "或者" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 01a810bd6..3beedc5b9 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -284,9 +284,6 @@ dayX: "{day}日" monthX: "{month}月" yearX: "{year}年" pages: "頁面" -integration: "整合" -connectService: "己連結" -disconnectService: "己斷開 " enableLocalTimeline: "開啟本地時間軸" enableGlobalTimeline: "啟用公開時間軸" disablingTimelinesInfo: "即使您關閉了時間線功能,管理員和協調人仍可以繼續使用,以方便您。" @@ -390,7 +387,6 @@ normalPassword: "密碼強度普通" strongPassword: "密碼強度高" passwordMatched: "密碼一致" passwordNotMatched: "密碼不一致" -signinWith: "以{x}登錄" signinFailed: "登入失敗。 請檢查使用者名稱和密碼。" tapSecurityKey: "點擊安全密鑰" or: "或者" diff --git a/package.json b/package.json index e584965e2..e917d516f 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "migrateandstart": "yarn migrate && yarn start", "gulp": "gulp build", "watch": "yarn dev", - "dev": "node ./scripts/dev.js", + "dev": "node ./scripts/dev.mjs", "lint": "yarn workspaces foreach run lint", "cy:open": "cypress open --browser --e2e --config-file=cypress.config.ts", "cy:run": "cypress run", @@ -26,8 +26,8 @@ "mocha": "yarn workspace backend run mocha", "test": "yarn mocha", "format": "gulp format", - "clean": "node ./scripts/clean.js", - "clean-all": "node ./scripts/clean-all.js", + "clean": "node ./scripts/clean.mjs", + "clean-all": "node ./scripts/clean-all.mjs", "cleanall": "yarn clean-all" }, "resolutions": { @@ -46,11 +46,11 @@ "devDependencies": { "@types/gulp": "4.0.9", "@types/gulp-rename": "2.0.1", - "@typescript-eslint/parser": "^5.44.0", + "@typescript-eslint/parser": "^5.46.1", "cross-env": "7.0.3", "cypress": "10.3.0", "start-server-and-test": "1.14.0", - "typescript": "^4.9.3" + "typescript": "^4.9.4" }, "packageManager": "yarn@3.3.0" } diff --git a/packages/backend/migration/1667653936442-token-permissions.js b/packages/backend/migration/1667653936442-token-permissions.js new file mode 100644 index 000000000..df6925bee --- /dev/null +++ b/packages/backend/migration/1667653936442-token-permissions.js @@ -0,0 +1,33 @@ +export class tokenPermissions1667653936442 { + name = 'tokenPermissions1667653936442' + + async up(queryRunner) { + // Carry over the permissions from the app for tokens that have an associated app. + await queryRunner.query(`UPDATE "access_token" SET permission = (SELECT permission FROM "app" WHERE "app"."id" = "access_token"."appId") WHERE "appId" IS NOT NULL AND CARDINALITY("permission") = 0`); + // The permission column should now always be set explicitly, so the default is not needed any more. + await queryRunner.query(`ALTER TABLE "access_token" ALTER COLUMN "permission" DROP DEFAULT`); + // Drop all currently running authorization sessions. Already created tokens remain untouched. + // If you were registering an app just before upgrade started, try again later. ¯\_(ツ)_/¯ + await queryRunner.query(`TRUNCATE TABLE "auth_session"`); + // Refactor scheme to allow multiple access tokens per app. + await queryRunner.query(`ALTER TABLE "auth_session" DROP CONSTRAINT "FK_c072b729d71697f959bde66ade0"`); + await queryRunner.query(`ALTER TABLE "auth_session" RENAME COLUMN "userId" TO "accessTokenId"`); + await queryRunner.query(`ALTER TABLE "auth_session" ADD CONSTRAINT "UQ_8e001e5a101c6dca37df1a76d66" UNIQUE ("accessTokenId")`); + await queryRunner.query(`ALTER TABLE "auth_session" ADD CONSTRAINT "FK_8e001e5a101c6dca37df1a76d66" FOREIGN KEY ("accessTokenId") REFERENCES "access_token"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + // Drop all currently running authorization sessions. Already created tokens remain untouched. + // If you were registering an app just before downgrade started, try again later. ¯\_(ツ)_/¯ + await queryRunner.query(`TRUNCATE TABLE "auth_session"`); + + await queryRunner.query(`ALTER TABLE "auth_session" DROP CONSTRAINT "FK_8e001e5a101c6dca37df1a76d66"`); + await queryRunner.query(`ALTER TABLE "auth_session" DROP CONSTRAINT "UQ_8e001e5a101c6dca37df1a76d66"`); + await queryRunner.query(`ALTER TABLE "access_token" ALTER COLUMN "permission" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "auth_session" RENAME COLUMN "accessTokenId" TO "userId"`); + await queryRunner.query(`ALTER TABLE "auth_session" ADD CONSTRAINT "FK_c072b729d71697f959bde66ade0" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + + await queryRunner.query(`ALTER TABLE "access_token" ALTER COLUMN "permission" SET DEFAULT '{}'::varchar[]`); + await queryRunner.query(`UPDATE "access_token" SET permission = '{}'::varchar[] WHERE "appId" IS NOT NULL`); + } +} diff --git a/packages/backend/migration/1667738304733-pkce.js b/packages/backend/migration/1667738304733-pkce.js new file mode 100644 index 000000000..4036bd649 --- /dev/null +++ b/packages/backend/migration/1667738304733-pkce.js @@ -0,0 +1,12 @@ +export class pkce1667738304733 { + name = 'pkce1667738304733' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "auth_session" ADD "pkceChallenge" text`); + await queryRunner.query(`COMMENT ON COLUMN "auth_session"."pkceChallenge" IS 'PKCE code_challenge value, if provided (OAuth only)'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "auth_session" DROP COLUMN "pkceChallenge"`); + } +} diff --git a/packages/backend/migration/1670359028055-removeIntegrations.js b/packages/backend/migration/1670359028055-removeIntegrations.js new file mode 100644 index 000000000..fd0888a6e --- /dev/null +++ b/packages/backend/migration/1670359028055-removeIntegrations.js @@ -0,0 +1,29 @@ +export class removeIntegrations1670359028055 { + name = 'removeIntegrations1670359028055' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableTwitterIntegration"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "twitterConsumerKey"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "twitterConsumerSecret"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableGithubIntegration"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "githubClientId"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "githubClientSecret"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableDiscordIntegration"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "discordClientId"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "discordClientSecret"`); + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "integrations"`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" ADD "integrations" jsonb NOT NULL DEFAULT '{}'`); + await queryRunner.query(`ALTER TABLE "meta" ADD "discordClientSecret" character varying(128)`); + await queryRunner.query(`ALTER TABLE "meta" ADD "discordClientId" character varying(128)`); + await queryRunner.query(`ALTER TABLE "meta" ADD "enableDiscordIntegration" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "meta" ADD "githubClientSecret" character varying(128)`); + await queryRunner.query(`ALTER TABLE "meta" ADD "githubClientId" character varying(128)`); + await queryRunner.query(`ALTER TABLE "meta" ADD "enableGithubIntegration" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "meta" ADD "twitterConsumerSecret" character varying(128)`); + await queryRunner.query(`ALTER TABLE "meta" ADD "twitterConsumerKey" character varying(128)`); + await queryRunner.query(`ALTER TABLE "meta" ADD "enableTwitterIntegration" boolean NOT NULL DEFAULT false`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index dc18c5e30..261207528 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -7,7 +7,7 @@ "scripts": { "build": "tsc -p tsconfig.json || echo done. && tsc-alias -p tsconfig.json", "watch": "node watch.mjs", - "lint": "eslint src --ext .ts", + "lint": "tsc --noEmit && eslint src --ext .ts", "mocha": "cross-env NODE_ENV=test TS_NODE_FILES=true TS_NODE_TRANSPILE_ONLY=true TS_NODE_PROJECT=\"./test/tsconfig.json\" mocha", "migrate": "npx typeorm migration:run -d ormconfig.js", "start": "node --experimental-json-modules ./built/index.js", @@ -29,7 +29,6 @@ "ajv": "8.11.0", "archiver": "5.3.1", "autobind-decorator": "2.4.0", - "autwh": "0.1.0", "aws-sdk": "2.1165.0", "bcryptjs": "2.4.3", "blurhash": "1.1.5", @@ -92,7 +91,6 @@ "reflect-metadata": "0.1.13", "rename": "1.0.4", "require-all": "3.0.0", - "rndstr": "1.0.0", "rss-parser": "3.12.0", "sanitize-html": "2.7.0", "semver": "7.3.7", @@ -124,6 +122,7 @@ "@types/bcryptjs": "2.4.2", "@types/bull": "3.15.8", "@types/cbor": "6.0.0", + "@types/color-convert": "^2.0.0", "@types/escape-regexp": "0.0.1", "@types/fluent-ffmpeg": "2.1.20", "@types/is-url": "1.2.30", @@ -146,7 +145,6 @@ "@types/node": "18.7.16", "@types/node-fetch": "3.0.3", "@types/nodemailer": "6.4.5", - "@types/oauth": "^0.9.1", "@types/pg": "^8.6.5", "@types/pug": "2.0.6", "@types/punycode": "2.1.0", @@ -161,20 +159,21 @@ "@types/sinon": "^10.0.13", "@types/sinonjs__fake-timers": "8.1.2", "@types/speakeasy": "2.0.7", + "@types/syslog-pro": "^1.0.0", "@types/tinycolor2": "1.4.3", "@types/tmp": "0.2.3", "@types/uuid": "8.3.4", "@types/web-push": "3.3.2", "@types/websocket": "1.0.5", "@types/ws": "8.5.3", - "@typescript-eslint/eslint-plugin": "^5.44.0", - "@typescript-eslint/parser": "^5.44.0", + "@typescript-eslint/eslint-plugin": "^5.46.1", + "@typescript-eslint/parser": "^5.46.1", "cross-env": "7.0.3", - "eslint": "^8.28.0", + "eslint": "^8.29.0", "eslint-plugin-import": "^2.26.0", "execa": "6.1.0", "form-data": "^4.0.0", "sinon": "^14.0.2", - "typescript": "^4.9.3" + "typescript": "^4.9.4" } } diff --git a/packages/backend/src/boot/index.ts b/packages/backend/src/boot/index.ts index f88d89d0c..ef98f0eea 100644 --- a/packages/backend/src/boot/index.ts +++ b/packages/backend/src/boot/index.ts @@ -17,7 +17,7 @@ const ev = new Xev(); /** * Init process */ -export default async function(): Promise { +export async function boot(): Promise { process.title = `FoundKey (${cluster.isPrimary ? 'master' : 'worker'})`; if (cluster.isPrimary || envOption.disableClustering) { diff --git a/packages/backend/src/boot/master.ts b/packages/backend/src/boot/master.ts index b4d95346f..6b1fce48c 100644 --- a/packages/backend/src/boot/master.ts +++ b/packages/backend/src/boot/master.ts @@ -153,7 +153,7 @@ async function spawnWorkers(clusterLimits: Required): P bootLogger.info(`Starting ${total} workers...`); await Promise.all(workers.map(mode => spawnWorker(mode))); - bootLogger.succ(`All workers started`); + bootLogger.succ('All workers started'); } function spawnWorker(mode: 'web' | 'queue'): Promise { diff --git a/packages/backend/src/config/load.ts b/packages/backend/src/config/load.ts index 231f7c20b..95286585d 100644 --- a/packages/backend/src/config/load.ts +++ b/packages/backend/src/config/load.ts @@ -38,6 +38,12 @@ export default function load(): Config { config.port = config.port || parseInt(process.env.PORT || '', 10); + config.images = Object.assign({ + info: '/twemoji/1f440.svg', + notFound: '/twemoji/2049.svg', + error: '/twemoji/1f480.svg', + }, config.images ?? {}); + if (!config.maxNoteTextLength) config.maxNoteTextLength = 3000; mixin.version = meta.version; diff --git a/packages/backend/src/config/types.ts b/packages/backend/src/config/types.ts index 7308fd7c7..55226ca47 100644 --- a/packages/backend/src/config/types.ts +++ b/packages/backend/src/config/types.ts @@ -59,7 +59,7 @@ export type Source = { deliverJobMaxAttempts?: number; inboxJobMaxAttempts?: number; - syslog: { + syslog?: { host: string; port: number; }; @@ -67,6 +67,12 @@ export type Source = { mediaProxy?: string; proxyRemoteFiles?: boolean; internalStoragePath?: string; + + images?: { + info?: string; + notFound?: string; + error?: string; + }; }; /** diff --git a/packages/backend/src/db/postgre.ts b/packages/backend/src/db/postgre.ts index b3d103082..90b123ae8 100644 --- a/packages/backend/src/db/postgre.ts +++ b/packages/backend/src/db/postgre.ts @@ -1,6 +1,5 @@ // https://github.com/typeorm/typeorm/issues/2400 import pg from 'pg'; -import { SECOND } from '@/const.js'; pg.types.setTypeParser(20, Number); @@ -8,6 +7,7 @@ import { Logger, DataSource } from 'typeorm'; import * as highlight from 'cli-highlight'; import config from '@/config/index.js'; +import { SECOND } from '@/const.js'; import { User } from '@/models/entities/user.js'; import { DriveFile } from '@/models/entities/drive-file.js'; import { DriveFolder } from '@/models/entities/drive-folder.js'; @@ -78,33 +78,33 @@ import { redisClient } from './redis.js'; const sqlLogger = dbLogger.createSubLogger('sql', 'gray', false); class MyCustomLogger implements Logger { - private highlight(sql: string) { + private highlight(sql: string): string { return highlight.highlight(sql, { language: 'sql', ignoreIllegals: true, }); } - public logQuery(query: string, parameters?: any[]) { + public logQuery(query: string): void { sqlLogger.info(this.highlight(query).substring(0, 100)); } - public logQueryError(error: string, query: string, parameters?: any[]) { + public logQueryError(error: string, query: string): void { sqlLogger.error(this.highlight(query)); } - public logQuerySlow(time: number, query: string, parameters?: any[]) { + public logQuerySlow(time: number, query: string): void { sqlLogger.warn(this.highlight(query)); } - public logSchemaBuild(message: string) { + public logSchemaBuild(message: string): void { sqlLogger.info(message); } - public log(message: string) { + public log(message: string): void { sqlLogger.info(message); } - public logMigration(message: string) { + public logMigration(message: string): void { sqlLogger.info(message); } } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index ba5a61247..a25b735d4 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -3,7 +3,7 @@ */ import { EventEmitter } from 'node:events'; -import boot from '@/boot/index.js'; +import { boot } from '@/boot/index.js'; Error.stackTraceLimit = Infinity; EventEmitter.defaultMaxListeners = 128; diff --git a/packages/backend/src/mfm/from-html.ts b/packages/backend/src/mfm/from-html.ts index 087d59187..0ec5b962e 100644 --- a/packages/backend/src/mfm/from-html.ts +++ b/packages/backend/src/mfm/from-html.ts @@ -7,6 +7,16 @@ const treeAdapter = parse5.defaultTreeAdapter; const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/; const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/; +function getAttr(node: TreeAdapter.Node, attr: string): string { + return node.attrs.find(({ name }) => name === attr)?.value; +} +function attrHas(node: TreeAdapter.Node, attr: string, value: string): boolean { + const attrValue = getAttr(node, attr); + if (!attrValue) return false; + + return new RegExp('\\b' + value + '\\b').test(attrValue); +} + export function fromHtml(html: string, quoteUri?: string | null): string { const dom = parse5.parseFragment( // some AP servers like Pixelfed use br tags as well as newlines @@ -59,19 +69,18 @@ export function fromHtml(html: string, quoteUri?: string | null): string { case 'a': { const txt = getText(node); - const rel = node.attrs.find(x => x.name === 'rel'); - const href = node.attrs.find(x => x.name === 'href'); + const href = getAttr(node, 'href'); // hashtags - if (txt.startsWith('#') && href && /\btag\b/.test(rel?.value)) { + if (txt.startsWith('#') && href && (attrHas(node, 'rel', 'tag') || attrHas(node, 'class', 'hashtag'))) { text += txt; // mentions - } else if (txt.startsWith('@') && !(rel && rel.value.match(/^me /))) { + } else if (txt.startsWith('@') && !attrHas(node, 'rel', 'me')) { const part = txt.split('@'); if (part.length === 2 && href) { // restore the host name part - const acct = `${txt}@${(new URL(href.value)).hostname}`; + const acct = `${txt}@${(new URL(href)).hostname}`; text += acct; } else if (part.length === 3) { text += txt; @@ -85,17 +94,17 @@ export function fromHtml(html: string, quoteUri?: string | null): string { if (!href) { return txt; } - if (!txt || txt === href.value) { // #6383: Missing text node - if (href.value.match(urlRegexFull)) { - return href.value; + if (!txt || txt === href) { // #6383: Missing text node + if (href.match(urlRegexFull)) { + return href; } else { - return `<${href.value}>`; + return `<${href}>`; } } - if (href.value.match(urlRegex) && !href.value.match(urlRegexFull)) { - return `[${txt}](<${href.value}>)`; // #6846 + if (href.match(urlRegex) && !href.match(urlRegexFull)) { + return `[${txt}](<${href}>)`; // #6846 } else { - return `[${txt}](${href.value})`; + return `[${txt}](${href})`; } }; @@ -204,8 +213,7 @@ export function fromHtml(html: string, quoteUri?: string | null): string { case 'span': { - const nodeClass = node.attrs.find(({ name }) => name === 'class')?.value; - if (/\bquote-inline\b/.test(nodeClass) && quoteUri && getText(node).trim() === `RE: ${quoteUri}`) { + if (attrHas(node, 'class', 'quote-inline') && quoteUri && getText(node).trim() === `RE: ${quoteUri}`) { // embedded quote thingy for backwards compatibility, don't show it } else { appendChildren(node.childNodes); diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index e472acd38..ee6e90a66 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -1,7 +1,7 @@ export class Cache { - public cache: Map; + public cache: Map; private lifetime: number; - public fetcher: (key: string | null) => Promise; + public fetcher: (key: string) => Promise; constructor(lifetime: number, fetcher: Cache['fetcher']) { this.cache = new Map(); @@ -9,14 +9,14 @@ export class Cache { this.fetcher = fetcher; } - public set(key: string | null, value: T): void { + public set(key: string, value: T): void { this.cache.set(key, { date: Date.now(), value, }); } - public get(key: string | null): T | undefined { + public get(key: string): T | undefined { const cached = this.cache.get(key); if (cached == null) return undefined; @@ -29,7 +29,7 @@ export class Cache { return cached.value; } - public delete(key: string | null): void { + public delete(key: string): void { this.cache.delete(key); } @@ -38,7 +38,7 @@ export class Cache { * run to get the value. If the fetcher returns undefined, it is * returned but not cached. */ - public async fetch(key: string | null): Promise { + public async fetch(key: string): Promise { const cached = this.get(key); if (cached !== undefined) { return cached; diff --git a/packages/backend/src/misc/secure-rndstr.ts b/packages/backend/src/misc/secure-rndstr.ts index 8d4fcb1ba..963e3c00c 100644 --- a/packages/backend/src/misc/secure-rndstr.ts +++ b/packages/backend/src/misc/secure-rndstr.ts @@ -1,10 +1,9 @@ import * as crypto from 'node:crypto'; const L_CHARS = '0123456789abcdefghijklmnopqrstuvwxyz'; -const LU_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; +const LU_CHARS = L_CHARS + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; -export function secureRndstr(length = 32, useLU = true): string { - const chars = useLU ? LU_CHARS : L_CHARS; +export function secureRndstrCustom(length = 32, chars: string): string { const chars_len = chars.length; let str = ''; @@ -19,3 +18,8 @@ export function secureRndstr(length = 32, useLU = true): string { return str; } + +export function secureRndstr(length = 32, useLU = true): string { + const chars = useLU ? LU_CHARS : L_CHARS; + return secureRndstrCustom(length, chars); +} diff --git a/packages/backend/src/misc/should-block-instance.ts b/packages/backend/src/misc/should-block-instance.ts new file mode 100644 index 000000000..7396983d7 --- /dev/null +++ b/packages/backend/src/misc/should-block-instance.ts @@ -0,0 +1,16 @@ +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { Instance } from '@/models/entities/instance.js'; +import { Meta } from '@/models/entities/meta.js'; + +/** + * Returns whether a specific host (punycoded) should be blocked. + * + * @param host punycoded instance host + * @param meta a Promise contatining the information from the meta table (optional) + * @returns whether the given host should be blocked + */ + +export async function shouldBlockInstance(host: Instance['host'], meta: Promise = fetchMeta()): Promise { + const { blockedHosts } = await meta; + return blockedHosts.some(blockedHost => host === blockedHost || host.endsWith('.' + blockedHost)); +} diff --git a/packages/backend/src/misc/skipped-instances.ts b/packages/backend/src/misc/skipped-instances.ts index 40f106746..f5b5c3b73 100644 --- a/packages/backend/src/misc/skipped-instances.ts +++ b/packages/backend/src/misc/skipped-instances.ts @@ -2,39 +2,12 @@ import { db } from '@/db/postgre.js'; import { fetchMeta } from '@/misc/fetch-meta.js'; import { Instance } from '@/models/entities/instance.js'; import { DAY } from '@/const.js'; -import { Meta } from '@/models/entities/meta.js'; +import { shouldBlockInstance } from '@/misc/should-block-instance.js'; // Threshold from last contact after which an instance will be considered // "dead" and should no longer get activities delivered to it. const deadThreshold = 7 * DAY; -/** - * Returns whether a given host matches a wildcard pattern. - * @param host punycoded instance host - * @param pattern wildcard pattern containing a punycoded instance host - * @returns whether the post matches the pattern - */ -function matchHost(host: Instance['host'], pattern: string): boolean { - // Escape all of the regex special characters. Pattern from: - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping - const escape = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const re = new RegExp('^' + pattern.split('*').map(escape).join('.*') + '$'); - - return re.test(host); -} - -/** - * Returns whether a specific host (punycoded) should be blocked. - * - * @param host punycoded instance host - * @param meta a Promise contatining the information from the meta table (oprional) - * @returns whether the given host should be blocked - */ -export async function shouldBlockInstance(host: string, meta: Promise = fetchMeta()): Promise { - const { blockedHosts } = await meta; - return blockedHosts.some(blockedHost => matchHost(host, blockedHost)); -} - /** * Returns the subset of hosts which should be skipped. * @@ -62,7 +35,7 @@ export async function skippedInstances(hosts: Array): Promise< hosts.filter(host => !skipped.includes(host) && !host.includes(',')).join(','), ], ) - .then(res => res.map(row => row.host)), + .then((res: Instance[]) => res.map(row => row.host)), ); } diff --git a/packages/backend/src/models/entities/abuse-user-report.ts b/packages/backend/src/models/entities/abuse-user-report.ts index 9e5c4f793..dc7f27377 100644 --- a/packages/backend/src/models/entities/abuse-user-report.ts +++ b/packages/backend/src/models/entities/abuse-user-report.ts @@ -17,7 +17,7 @@ export class AbuseUserReport { @Column(id()) public targetUserId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -27,7 +27,7 @@ export class AbuseUserReport { @Column(id()) public reporterId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -39,7 +39,7 @@ export class AbuseUserReport { }) public assigneeId: User['id'] | null; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'SET NULL', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/access-token.ts b/packages/backend/src/models/entities/access-token.ts index 59e6b8857..b6dc8cebc 100644 --- a/packages/backend/src/models/entities/access-token.ts +++ b/packages/backend/src/models/entities/access-token.ts @@ -41,7 +41,7 @@ export class AccessToken { @Column(id()) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -53,7 +53,7 @@ export class AccessToken { }) public appId: App['id'] | null; - @ManyToOne(type => App, { + @ManyToOne(() => App, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/announcement-read.ts b/packages/backend/src/models/entities/announcement-read.ts index 6e21dc1b4..0102a2fa2 100644 --- a/packages/backend/src/models/entities/announcement-read.ts +++ b/packages/backend/src/models/entities/announcement-read.ts @@ -18,7 +18,7 @@ export class AnnouncementRead { @Column(id()) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -28,7 +28,7 @@ export class AnnouncementRead { @Column(id()) public announcementId: Announcement['id']; - @ManyToOne(type => Announcement, { + @ManyToOne(() => Announcement, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/antenna-note.ts b/packages/backend/src/models/entities/antenna-note.ts index 46f600d4e..1ff1c75fe 100644 --- a/packages/backend/src/models/entities/antenna-note.ts +++ b/packages/backend/src/models/entities/antenna-note.ts @@ -16,7 +16,7 @@ export class AntennaNote { }) public noteId: Note['id']; - @ManyToOne(type => Note, { + @ManyToOne(() => Note, { onDelete: 'CASCADE', }) @JoinColumn() @@ -29,7 +29,7 @@ export class AntennaNote { }) public antennaId: Antenna['id']; - @ManyToOne(type => Antenna, { + @ManyToOne(() => Antenna, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/antenna.ts b/packages/backend/src/models/entities/antenna.ts index b500d597d..8fe47f1e4 100644 --- a/packages/backend/src/models/entities/antenna.ts +++ b/packages/backend/src/models/entities/antenna.ts @@ -21,7 +21,7 @@ export class Antenna { }) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -42,7 +42,7 @@ export class Antenna { }) public userListId: UserList['id'] | null; - @ManyToOne(type => UserList, { + @ManyToOne(() => UserList, { onDelete: 'CASCADE', }) @JoinColumn() @@ -54,7 +54,7 @@ export class Antenna { }) public userGroupJoiningId: UserGroupJoining['id'] | null; - @ManyToOne(type => UserGroupJoining, { + @ManyToOne(() => UserGroupJoining, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/app.ts b/packages/backend/src/models/entities/app.ts index 567f840bb..dcca3be4c 100644 --- a/packages/backend/src/models/entities/app.ts +++ b/packages/backend/src/models/entities/app.ts @@ -21,7 +21,7 @@ export class App { }) public userId: User['id'] | null; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true, }) diff --git a/packages/backend/src/models/entities/attestation-challenge.ts b/packages/backend/src/models/entities/attestation-challenge.ts index a5725342e..2a99953ee 100644 --- a/packages/backend/src/models/entities/attestation-challenge.ts +++ b/packages/backend/src/models/entities/attestation-challenge.ts @@ -11,7 +11,7 @@ export class AttestationChallenge { @PrimaryColumn(id()) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/auth-session.ts b/packages/backend/src/models/entities/auth-session.ts index 51f79e475..511a7fad6 100644 --- a/packages/backend/src/models/entities/auth-session.ts +++ b/packages/backend/src/models/entities/auth-session.ts @@ -1,6 +1,6 @@ import { Entity, PrimaryColumn, Index, Column, ManyToOne, JoinColumn } from 'typeorm'; import { id } from '../id.js'; -import { User } from './user.js'; +import { AccessToken } from './access-token.js'; import { App } from './app.js'; @Entity() @@ -23,21 +23,27 @@ export class AuthSession { ...id(), nullable: true, }) - public userId: User['id'] | null; + public accessTokenId: AccessToken['id'] | null; - @ManyToOne(type => User, { + @ManyToOne(() => AccessToken, { onDelete: 'CASCADE', nullable: true, }) @JoinColumn() - public user: User | null; + public accessToken: AccessToken | null; @Column(id()) public appId: App['id']; - @ManyToOne(type => App, { + @ManyToOne(() => App, { onDelete: 'CASCADE', }) @JoinColumn() public app: App | null; + + @Column('text', { + nullable: true, + comment: 'PKCE code_challenge value, if provided (OAuth only)', + }) + pkceChallenge: string | null; } diff --git a/packages/backend/src/models/entities/blocking.ts b/packages/backend/src/models/entities/blocking.ts index c13962567..ad45a0569 100644 --- a/packages/backend/src/models/entities/blocking.ts +++ b/packages/backend/src/models/entities/blocking.ts @@ -21,7 +21,7 @@ export class Blocking { }) public blockeeId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -34,7 +34,7 @@ export class Blocking { }) public blockerId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/channel-following.ts b/packages/backend/src/models/entities/channel-following.ts index f914fe592..5a717c9e8 100644 --- a/packages/backend/src/models/entities/channel-following.ts +++ b/packages/backend/src/models/entities/channel-following.ts @@ -22,7 +22,7 @@ export class ChannelFollowing { }) public followeeId: Channel['id']; - @ManyToOne(type => Channel, { + @ManyToOne(() => Channel, { onDelete: 'CASCADE', }) @JoinColumn() @@ -35,7 +35,7 @@ export class ChannelFollowing { }) public followerId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/channel-note-pining.ts b/packages/backend/src/models/entities/channel-note-pining.ts index 28f2e51dc..0be4f0dba 100644 --- a/packages/backend/src/models/entities/channel-note-pining.ts +++ b/packages/backend/src/models/entities/channel-note-pining.ts @@ -18,7 +18,7 @@ export class ChannelNotePining { @Column(id()) public channelId: Channel['id']; - @ManyToOne(type => Channel, { + @ManyToOne(() => Channel, { onDelete: 'CASCADE', }) @JoinColumn() @@ -27,7 +27,7 @@ export class ChannelNotePining { @Column(id()) public noteId: Note['id']; - @ManyToOne(type => Note, { + @ManyToOne(() => Note, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/channel.ts b/packages/backend/src/models/entities/channel.ts index 82f7cd6b9..44219c37d 100644 --- a/packages/backend/src/models/entities/channel.ts +++ b/packages/backend/src/models/entities/channel.ts @@ -28,7 +28,7 @@ export class Channel { }) public userId: User['id'] | null; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'SET NULL', }) @JoinColumn() @@ -53,7 +53,7 @@ export class Channel { }) public bannerId: DriveFile['id'] | null; - @ManyToOne(type => DriveFile, { + @ManyToOne(() => DriveFile, { onDelete: 'SET NULL', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/clip-note.ts b/packages/backend/src/models/entities/clip-note.ts index 59d589461..89c5f03d1 100644 --- a/packages/backend/src/models/entities/clip-note.ts +++ b/packages/backend/src/models/entities/clip-note.ts @@ -16,7 +16,7 @@ export class ClipNote { }) public noteId: Note['id']; - @ManyToOne(type => Note, { + @ManyToOne(() => Note, { onDelete: 'CASCADE', }) @JoinColumn() @@ -29,7 +29,7 @@ export class ClipNote { }) public clipId: Clip['id']; - @ManyToOne(type => Clip, { + @ManyToOne(() => Clip, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/clip.ts b/packages/backend/src/models/entities/clip.ts index b4b7568c8..0580db129 100644 --- a/packages/backend/src/models/entities/clip.ts +++ b/packages/backend/src/models/entities/clip.ts @@ -19,7 +19,7 @@ export class Clip { }) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/drive-file.ts b/packages/backend/src/models/entities/drive-file.ts index 95647c1ae..61f6cb2ec 100644 --- a/packages/backend/src/models/entities/drive-file.ts +++ b/packages/backend/src/models/entities/drive-file.ts @@ -23,7 +23,7 @@ export class DriveFile { }) public userId: User['id'] | null; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'RESTRICT', }) @JoinColumn() @@ -144,7 +144,7 @@ export class DriveFile { }) public folderId: DriveFolder['id'] | null; - @ManyToOne(type => DriveFolder, { + @ManyToOne(() => DriveFolder, { onDelete: 'SET NULL', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/drive-folder.ts b/packages/backend/src/models/entities/drive-folder.ts index 2d952cae9..d7e323e72 100644 --- a/packages/backend/src/models/entities/drive-folder.ts +++ b/packages/backend/src/models/entities/drive-folder.ts @@ -27,7 +27,7 @@ export class DriveFolder { }) public userId: User['id'] | null; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -41,7 +41,7 @@ export class DriveFolder { }) public parentId: DriveFolder['id'] | null; - @ManyToOne(type => DriveFolder, { + @ManyToOne(() => DriveFolder, { onDelete: 'SET NULL', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/follow-request.ts b/packages/backend/src/models/entities/follow-request.ts index cd0acc453..93d685c55 100644 --- a/packages/backend/src/models/entities/follow-request.ts +++ b/packages/backend/src/models/entities/follow-request.ts @@ -20,7 +20,7 @@ export class FollowRequest { }) public followeeId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -33,7 +33,7 @@ export class FollowRequest { }) public followerId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/following.ts b/packages/backend/src/models/entities/following.ts index 55b9dc84f..c430cd388 100644 --- a/packages/backend/src/models/entities/following.ts +++ b/packages/backend/src/models/entities/following.ts @@ -21,7 +21,7 @@ export class Following { }) public followeeId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -34,7 +34,7 @@ export class Following { }) public followerId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/gallery-like.ts b/packages/backend/src/models/entities/gallery-like.ts index a8ff57899..259981392 100644 --- a/packages/backend/src/models/entities/gallery-like.ts +++ b/packages/backend/src/models/entities/gallery-like.ts @@ -16,7 +16,7 @@ export class GalleryLike { @Column(id()) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -25,7 +25,7 @@ export class GalleryLike { @Column(id()) public postId: GalleryPost['id']; - @ManyToOne(type => GalleryPost, { + @ManyToOne(() => GalleryPost, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/gallery-post.ts b/packages/backend/src/models/entities/gallery-post.ts index 6ed392d86..315bcd371 100644 --- a/packages/backend/src/models/entities/gallery-post.ts +++ b/packages/backend/src/models/entities/gallery-post.ts @@ -37,7 +37,7 @@ export class GalleryPost { }) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/messaging-message.ts b/packages/backend/src/models/entities/messaging-message.ts index 4ac387813..95862aab4 100644 --- a/packages/backend/src/models/entities/messaging-message.ts +++ b/packages/backend/src/models/entities/messaging-message.ts @@ -22,7 +22,7 @@ export class MessagingMessage { }) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -35,7 +35,7 @@ export class MessagingMessage { }) public recipientId: User['id'] | null; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -48,7 +48,7 @@ export class MessagingMessage { }) public groupId: UserGroup['id'] | null; - @ManyToOne(type => UserGroup, { + @ManyToOne(() => UserGroup, { onDelete: 'CASCADE', }) @JoinColumn() @@ -81,7 +81,7 @@ export class MessagingMessage { }) public fileId: DriveFile['id'] | null; - @ManyToOne(type => DriveFile, { + @ManyToOne(() => DriveFile, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/meta.ts b/packages/backend/src/models/entities/meta.ts index c812b882a..9c438bcd6 100644 --- a/packages/backend/src/models/entities/meta.ts +++ b/packages/backend/src/models/entities/meta.ts @@ -134,7 +134,7 @@ export class Meta { }) public proxyAccountId: User['id'] | null; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'SET NULL', }) @JoinColumn() @@ -246,57 +246,6 @@ export class Meta { }) public swPrivateKey: string; - @Column('boolean', { - default: false, - }) - public enableTwitterIntegration: boolean; - - @Column('varchar', { - length: 128, - nullable: true, - }) - public twitterConsumerKey: string | null; - - @Column('varchar', { - length: 128, - nullable: true, - }) - public twitterConsumerSecret: string | null; - - @Column('boolean', { - default: false, - }) - public enableGithubIntegration: boolean; - - @Column('varchar', { - length: 128, - nullable: true, - }) - public githubClientId: string | null; - - @Column('varchar', { - length: 128, - nullable: true, - }) - public githubClientSecret: string | null; - - @Column('boolean', { - default: false, - }) - public enableDiscordIntegration: boolean; - - @Column('varchar', { - length: 128, - nullable: true, - }) - public discordClientId: string | null; - - @Column('varchar', { - length: 128, - nullable: true, - }) - public discordClientSecret: string | null; - @Column('enum', { enum: TranslationService, nullable: true, diff --git a/packages/backend/src/models/entities/moderation-log.ts b/packages/backend/src/models/entities/moderation-log.ts index 47b0bc715..ad97db27f 100644 --- a/packages/backend/src/models/entities/moderation-log.ts +++ b/packages/backend/src/models/entities/moderation-log.ts @@ -16,7 +16,7 @@ export class ModerationLog { @Column(id()) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/muted-note.ts b/packages/backend/src/models/entities/muted-note.ts index 649b26879..3c034bec2 100644 --- a/packages/backend/src/models/entities/muted-note.ts +++ b/packages/backend/src/models/entities/muted-note.ts @@ -17,7 +17,7 @@ export class MutedNote { }) public noteId: Note['id']; - @ManyToOne(type => Note, { + @ManyToOne(() => Note, { onDelete: 'CASCADE', }) @JoinColumn() @@ -30,7 +30,7 @@ export class MutedNote { }) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/muting.ts b/packages/backend/src/models/entities/muting.ts index 19ff0da84..02b37a78d 100644 --- a/packages/backend/src/models/entities/muting.ts +++ b/packages/backend/src/models/entities/muting.ts @@ -27,7 +27,7 @@ export class Muting { }) public muteeId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -40,7 +40,7 @@ export class Muting { }) public muterId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/note-favorite.ts b/packages/backend/src/models/entities/note-favorite.ts index 63fb54bbe..8b4449c3e 100644 --- a/packages/backend/src/models/entities/note-favorite.ts +++ b/packages/backend/src/models/entities/note-favorite.ts @@ -18,7 +18,7 @@ export class NoteFavorite { @Column(id()) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -27,7 +27,7 @@ export class NoteFavorite { @Column(id()) public noteId: Note['id']; - @ManyToOne(type => Note, { + @ManyToOne(() => Note, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/note-reaction.ts b/packages/backend/src/models/entities/note-reaction.ts index d84e64304..d86421671 100644 --- a/packages/backend/src/models/entities/note-reaction.ts +++ b/packages/backend/src/models/entities/note-reaction.ts @@ -19,7 +19,7 @@ export class NoteReaction { @Column(id()) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -29,7 +29,7 @@ export class NoteReaction { @Column(id()) public noteId: Note['id']; - @ManyToOne(type => Note, { + @ManyToOne(() => Note, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/note-thread-muting.ts b/packages/backend/src/models/entities/note-thread-muting.ts index 325bcfc1b..90ed0c9f8 100644 --- a/packages/backend/src/models/entities/note-thread-muting.ts +++ b/packages/backend/src/models/entities/note-thread-muting.ts @@ -20,7 +20,7 @@ export class NoteThreadMuting { }) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/note-unread.ts b/packages/backend/src/models/entities/note-unread.ts index 3219970f3..dbcc7e2d8 100644 --- a/packages/backend/src/models/entities/note-unread.ts +++ b/packages/backend/src/models/entities/note-unread.ts @@ -14,7 +14,7 @@ export class NoteUnread { @Column(id()) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -24,7 +24,7 @@ export class NoteUnread { @Column(id()) public noteId: Note['id']; - @ManyToOne(type => Note, { + @ManyToOne(() => Note, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/note-watching.ts b/packages/backend/src/models/entities/note-watching.ts index d51d14693..7e9dfdb98 100644 --- a/packages/backend/src/models/entities/note-watching.ts +++ b/packages/backend/src/models/entities/note-watching.ts @@ -22,7 +22,7 @@ export class NoteWatching { }) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -35,7 +35,7 @@ export class NoteWatching { }) public noteId: Note['id']; - @ManyToOne(type => Note, { + @ManyToOne(() => Note, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/note.ts b/packages/backend/src/models/entities/note.ts index 0e15921b7..1e5f418c5 100644 --- a/packages/backend/src/models/entities/note.ts +++ b/packages/backend/src/models/entities/note.ts @@ -27,7 +27,7 @@ export class Note { }) public replyId: Note['id'] | null; - @ManyToOne(type => Note, { + @ManyToOne(() => Note, { onDelete: 'CASCADE', }) @JoinColumn() @@ -41,7 +41,7 @@ export class Note { }) public renoteId: Note['id'] | null; - @ManyToOne(type => Note, { + @ManyToOne(() => Note, { onDelete: 'CASCADE', }) @JoinColumn() @@ -75,7 +75,7 @@ export class Note { }) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -179,7 +179,7 @@ export class Note { }) public channelId: Channel['id'] | null; - @ManyToOne(type => Channel, { + @ManyToOne(() => Channel, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/notification.ts b/packages/backend/src/models/entities/notification.ts index a757548bd..cab6551f7 100644 --- a/packages/backend/src/models/entities/notification.ts +++ b/packages/backend/src/models/entities/notification.ts @@ -28,7 +28,7 @@ export class Notification { }) public notifieeId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -45,7 +45,7 @@ export class Notification { }) public notifierId: User['id'] | null; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -89,7 +89,7 @@ export class Notification { }) public noteId: Note['id'] | null; - @ManyToOne(type => Note, { + @ManyToOne(() => Note, { onDelete: 'CASCADE', }) @JoinColumn() @@ -101,7 +101,7 @@ export class Notification { }) public followRequestId: FollowRequest['id'] | null; - @ManyToOne(type => FollowRequest, { + @ManyToOne(() => FollowRequest, { onDelete: 'CASCADE', }) @JoinColumn() @@ -113,7 +113,7 @@ export class Notification { }) public userGroupInvitationId: UserGroupInvitation['id'] | null; - @ManyToOne(type => UserGroupInvitation, { + @ManyToOne(() => UserGroupInvitation, { onDelete: 'CASCADE', }) @JoinColumn() @@ -165,7 +165,7 @@ export class Notification { }) public appAccessTokenId: AccessToken['id'] | null; - @ManyToOne(type => AccessToken, { + @ManyToOne(() => AccessToken, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/page-like.ts b/packages/backend/src/models/entities/page-like.ts index 6c8e03f7e..149def459 100644 --- a/packages/backend/src/models/entities/page-like.ts +++ b/packages/backend/src/models/entities/page-like.ts @@ -16,7 +16,7 @@ export class PageLike { @Column(id()) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -25,7 +25,7 @@ export class PageLike { @Column(id()) public pageId: Page['id']; - @ManyToOne(type => Page, { + @ManyToOne(() => Page, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/page.ts b/packages/backend/src/models/entities/page.ts index 67da0c13a..b25e064e2 100644 --- a/packages/backend/src/models/entities/page.ts +++ b/packages/backend/src/models/entities/page.ts @@ -57,7 +57,7 @@ export class Page { }) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -69,7 +69,7 @@ export class Page { }) public eyeCatchingImageId: DriveFile['id'] | null; - @ManyToOne(type => DriveFile, { + @ManyToOne(() => DriveFile, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/password-reset-request.ts b/packages/backend/src/models/entities/password-reset-request.ts index 05e62cc5a..4edd3d2a8 100644 --- a/packages/backend/src/models/entities/password-reset-request.ts +++ b/packages/backend/src/models/entities/password-reset-request.ts @@ -22,7 +22,7 @@ export class PasswordResetRequest { }) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/poll-vote.ts b/packages/backend/src/models/entities/poll-vote.ts index 442c58d35..9824f5017 100644 --- a/packages/backend/src/models/entities/poll-vote.ts +++ b/packages/backend/src/models/entities/poll-vote.ts @@ -19,7 +19,7 @@ export class PollVote { @Column(id()) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -29,7 +29,7 @@ export class PollVote { @Column(id()) public noteId: Note['id']; - @ManyToOne(type => Note, { + @ManyToOne(() => Note, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/poll.ts b/packages/backend/src/models/entities/poll.ts index ece6c8a3b..2e90a24d8 100644 --- a/packages/backend/src/models/entities/poll.ts +++ b/packages/backend/src/models/entities/poll.ts @@ -9,7 +9,7 @@ export class Poll { @PrimaryColumn(id()) public noteId: Note['id']; - @OneToOne(type => Note, { + @OneToOne(() => Note, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/registry-item.ts b/packages/backend/src/models/entities/registry-item.ts index 7f8c5d6a2..bbafae4a3 100644 --- a/packages/backend/src/models/entities/registry-item.ts +++ b/packages/backend/src/models/entities/registry-item.ts @@ -25,7 +25,7 @@ export class RegistryItem { }) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/renote-muting.ts b/packages/backend/src/models/entities/renote-muting.ts index 8e4adbfcd..ee9bbb49b 100644 --- a/packages/backend/src/models/entities/renote-muting.ts +++ b/packages/backend/src/models/entities/renote-muting.ts @@ -21,7 +21,7 @@ export class RenoteMuting { }) public muteeId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -34,7 +34,7 @@ export class RenoteMuting { }) public muterId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/signin.ts b/packages/backend/src/models/entities/signin.ts index d50c052e9..19587eee4 100644 --- a/packages/backend/src/models/entities/signin.ts +++ b/packages/backend/src/models/entities/signin.ts @@ -16,7 +16,7 @@ export class Signin { @Column(id()) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/sw-subscription.ts b/packages/backend/src/models/entities/sw-subscription.ts index 801a5fd81..2e723b76d 100644 --- a/packages/backend/src/models/entities/sw-subscription.ts +++ b/packages/backend/src/models/entities/sw-subscription.ts @@ -14,7 +14,7 @@ export class SwSubscription { @Column(id()) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/user-group-invitation.ts b/packages/backend/src/models/entities/user-group-invitation.ts index d51ec6324..52899f348 100644 --- a/packages/backend/src/models/entities/user-group-invitation.ts +++ b/packages/backend/src/models/entities/user-group-invitation.ts @@ -21,7 +21,7 @@ export class UserGroupInvitation { }) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -34,7 +34,7 @@ export class UserGroupInvitation { }) public userGroupId: UserGroup['id']; - @ManyToOne(type => UserGroup, { + @ManyToOne(() => UserGroup, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/user-group-joining.ts b/packages/backend/src/models/entities/user-group-joining.ts index fbce5f6e7..836db73dd 100644 --- a/packages/backend/src/models/entities/user-group-joining.ts +++ b/packages/backend/src/models/entities/user-group-joining.ts @@ -21,7 +21,7 @@ export class UserGroupJoining { }) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -34,7 +34,7 @@ export class UserGroupJoining { }) public userGroupId: UserGroup['id']; - @ManyToOne(type => UserGroup, { + @ManyToOne(() => UserGroup, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/user-group.ts b/packages/backend/src/models/entities/user-group.ts index 1538153bf..7a488a625 100644 --- a/packages/backend/src/models/entities/user-group.ts +++ b/packages/backend/src/models/entities/user-group.ts @@ -25,7 +25,7 @@ export class UserGroup { }) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/user-keypair.ts b/packages/backend/src/models/entities/user-keypair.ts index 7db39f6df..596cb64a3 100644 --- a/packages/backend/src/models/entities/user-keypair.ts +++ b/packages/backend/src/models/entities/user-keypair.ts @@ -7,7 +7,7 @@ export class UserKeypair { @PrimaryColumn(id()) public userId: User['id']; - @OneToOne(type => User, { + @OneToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/user-list-joining.ts b/packages/backend/src/models/entities/user-list-joining.ts index f66151ff9..466113d58 100644 --- a/packages/backend/src/models/entities/user-list-joining.ts +++ b/packages/backend/src/models/entities/user-list-joining.ts @@ -21,7 +21,7 @@ export class UserListJoining { }) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -34,7 +34,7 @@ export class UserListJoining { }) public userListId: UserList['id']; - @ManyToOne(type => UserList, { + @ManyToOne(() => UserList, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/user-list.ts b/packages/backend/src/models/entities/user-list.ts index 4e895b1d1..922565c59 100644 --- a/packages/backend/src/models/entities/user-list.ts +++ b/packages/backend/src/models/entities/user-list.ts @@ -19,7 +19,7 @@ export class UserList { }) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/user-note-pining.ts b/packages/backend/src/models/entities/user-note-pining.ts index c4ba30bef..2f0eadd04 100644 --- a/packages/backend/src/models/entities/user-note-pining.ts +++ b/packages/backend/src/models/entities/user-note-pining.ts @@ -18,7 +18,7 @@ export class UserNotePining { @Column(id()) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -27,7 +27,7 @@ export class UserNotePining { @Column(id()) public noteId: Note['id']; - @ManyToOne(type => Note, { + @ManyToOne(() => Note, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/user-profile.ts b/packages/backend/src/models/entities/user-profile.ts index 58ab00d01..efa525f35 100644 --- a/packages/backend/src/models/entities/user-profile.ts +++ b/packages/backend/src/models/entities/user-profile.ts @@ -11,7 +11,7 @@ export class UserProfile { @PrimaryColumn(id()) public userId: User['id']; - @OneToOne(type => User, { + @OneToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() @@ -161,17 +161,12 @@ export class UserProfile { }) public pinnedPageId: Page['id'] | null; - @OneToOne(type => Page, { + @OneToOne(() => Page, { onDelete: 'SET NULL', }) @JoinColumn() public pinnedPage: Page | null; - @Column('jsonb', { - default: {}, - }) - public integrations: Record; - @Index() @Column('boolean', { default: false, select: false, diff --git a/packages/backend/src/models/entities/user-publickey.ts b/packages/backend/src/models/entities/user-publickey.ts index c0a439227..ab686567d 100644 --- a/packages/backend/src/models/entities/user-publickey.ts +++ b/packages/backend/src/models/entities/user-publickey.ts @@ -7,7 +7,7 @@ export class UserPublickey { @PrimaryColumn(id()) public userId: User['id']; - @OneToOne(type => User, { + @OneToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/user-security-key.ts b/packages/backend/src/models/entities/user-security-key.ts index 78f78a501..1f472f7e9 100644 --- a/packages/backend/src/models/entities/user-security-key.ts +++ b/packages/backend/src/models/entities/user-security-key.ts @@ -13,7 +13,7 @@ export class UserSecurityKey { @Column(id()) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/entities/user.ts b/packages/backend/src/models/entities/user.ts index dd2598322..449debcfc 100644 --- a/packages/backend/src/models/entities/user.ts +++ b/packages/backend/src/models/entities/user.ts @@ -81,7 +81,7 @@ export class User { }) public avatarId: DriveFile['id'] | null; - @OneToOne(type => DriveFile, { + @OneToOne(() => DriveFile, { onDelete: 'SET NULL', }) @JoinColumn() @@ -94,7 +94,7 @@ export class User { }) public bannerId: DriveFile['id'] | null; - @OneToOne(type => DriveFile, { + @OneToOne(() => DriveFile, { onDelete: 'SET NULL', }) @JoinColumn() @@ -214,7 +214,7 @@ export class User { @Index({ unique: true }) @Column('char', { length: 16, nullable: true, unique: true, - comment: 'The native access token of the User. It will be null if the origin of the user is local.', + comment: 'The native access token of local users, or null.', }) public token: string | null; @@ -234,10 +234,12 @@ export class User { export interface ILocalUser extends User { host: null; + token: string; } export interface IRemoteUser extends User { host: string; + token: null; } export type CacheableLocalUser = ILocalUser; diff --git a/packages/backend/src/models/entities/webhook.ts b/packages/backend/src/models/entities/webhook.ts index f89fe06c0..fe559af1c 100644 --- a/packages/backend/src/models/entities/webhook.ts +++ b/packages/backend/src/models/entities/webhook.ts @@ -21,7 +21,7 @@ export class Webhook { }) public userId: User['id']; - @ManyToOne(type => User, { + @ManyToOne(() => User, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/repositories/drive-file.ts b/packages/backend/src/models/repositories/drive-file.ts index fbfa832ae..b4a7691b5 100644 --- a/packages/backend/src/models/repositories/drive-file.ts +++ b/packages/backend/src/models/repositories/drive-file.ts @@ -27,9 +27,7 @@ export const DriveFileRepository = db.getRepository(DriveFile).extend({ getPublicProperties(file: DriveFile): DriveFile['properties'] { if (file.properties.orientation != null) { - // TODO - //const properties = structuredClone(file.properties); - const properties = JSON.parse(JSON.stringify(file.properties)); + const properties = structuredClone(file.properties); if (file.properties.orientation >= 5) { [properties.width, properties.height] = [properties.height, properties.width]; } diff --git a/packages/backend/src/models/repositories/instance.ts b/packages/backend/src/models/repositories/instance.ts index 5f0fd8d58..8e6a33a13 100644 --- a/packages/backend/src/models/repositories/instance.ts +++ b/packages/backend/src/models/repositories/instance.ts @@ -1,13 +1,12 @@ 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 { shouldBlockInstance } from '@/misc/should-block-instance.js'; export const InstanceRepository = db.getRepository(Instance).extend({ async pack( instance: Instance, ): Promise> { - const meta = await fetchMeta(); return { id: instance.id, caughtAt: instance.caughtAt.toISOString(), @@ -20,7 +19,7 @@ export const InstanceRepository = db.getRepository(Instance).extend({ lastCommunicatedAt: instance.lastCommunicatedAt.toISOString(), isNotResponding: instance.isNotResponding, isSuspended: instance.isSuspended, - isBlocked: meta.blockedHosts.includes(instance.host), + isBlocked: await shouldBlockInstance(instance.host), softwareName: instance.softwareName, softwareVersion: instance.softwareVersion, openRegistrations: instance.openRegistrations, diff --git a/packages/backend/src/models/repositories/user.ts b/packages/backend/src/models/repositories/user.ts index a36a575fe..13ddfd428 100644 --- a/packages/backend/src/models/repositories/user.ts +++ b/packages/backend/src/models/repositories/user.ts @@ -19,7 +19,7 @@ const userInstanceCache = new Cache( type IsUserDetailed = Detailed extends true ? Packed<'UserDetailed'> : Packed<'UserLite'>; type IsMeAndIsUserDetailed = - Detailed extends true ? + Detailed extends true ? ExpectsMe extends true ? Packed<'MeDetailed'> : ExpectsMe extends false ? Packed<'UserDetailedNotMe'> : Packed<'UserDetailed'> : @@ -35,7 +35,7 @@ const locationSchema = { type: 'string', minLength: 1, maxLength: 50 } as const; const birthdaySchema = { type: 'string', pattern: /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.toString().slice(1, -1) } as const; function isLocalUser(user: User): user is ILocalUser; -function isLocalUser(user: T): user is T & { host: null; }; +function isLocalUser(user: T): user is T & { host: null; token: string; }; function isLocalUser(user: User | { host: User['host'] }): boolean { return user.host == null; } @@ -387,7 +387,6 @@ export const UserRepository = db.getRepository(User).extend({ hasUnreadMessagingMessage: this.getHasUnreadMessagingMessage(user.id), hasUnreadNotification: this.getHasUnreadNotification(user.id), hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), - integrations: profile!.integrations, mutedWords: profile!.mutedWords, mutedInstances: profile!.mutedInstances, mutingNotificationTypes: profile!.mutingNotificationTypes, diff --git a/packages/backend/src/models/schema/user.ts b/packages/backend/src/models/schema/user.ts index dae8f692c..fcb61f918 100644 --- a/packages/backend/src/models/schema/user.ts +++ b/packages/backend/src/models/schema/user.ts @@ -352,10 +352,6 @@ export const packedMeDetailedOnlySchema = { type: 'boolean', nullable: false, optional: false, }, - integrations: { - type: 'object', - nullable: true, optional: false, - }, mutedWords: { type: 'array', nullable: false, optional: false, diff --git a/packages/backend/src/queue/initialize.ts b/packages/backend/src/queue/initialize.ts index 709466e05..d866ef61b 100644 --- a/packages/backend/src/queue/initialize.ts +++ b/packages/backend/src/queue/initialize.ts @@ -20,7 +20,7 @@ export function initialize(name: string, limitPerSec = -1): Bull.Queue { } // ref. https://github.com/misskey-dev/misskey/pull/7635#issue-971097019 -function apBackoff(attemptsMade: number, err: Error) { +function apBackoff(attemptsMade: number /*, err: Error */): number { const baseDelay = MINUTE; const maxBackoff = 8 * HOUR; let backoff = (Math.pow(2, attemptsMade) - 1) * baseDelay; diff --git a/packages/backend/src/queue/processors/deliver.ts b/packages/backend/src/queue/processors/deliver.ts index 638de71dd..60355e39e 100644 --- a/packages/backend/src/queue/processors/deliver.ts +++ b/packages/backend/src/queue/processors/deliver.ts @@ -1,6 +1,6 @@ import { URL } from 'node:url'; import Bull from 'bull'; -import request from '@/remote/activitypub/request.js'; +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'; diff --git a/packages/backend/src/queue/processors/inbox.ts b/packages/backend/src/queue/processors/inbox.ts index 2334cc9f8..db2d87dce 100644 --- a/packages/backend/src/queue/processors/inbox.ts +++ b/packages/backend/src/queue/processors/inbox.ts @@ -1,7 +1,7 @@ import { URL } from 'node:url'; import Bull from 'bull'; import httpSignature from '@peertube/http-signature'; -import perform from '@/remote/activitypub/perform.js'; +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'; @@ -9,14 +9,12 @@ import { apRequestChart, federationChart, instanceChart } from '@/services/chart import { toPuny, extractDbHost } from '@/misc/convert-host.js'; import { getApId } from '@/remote/activitypub/type.js'; import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata.js'; -import DbResolver from '@/remote/activitypub/db-resolver.js'; -import { resolvePerson } from '@/remote/activitypub/models/person.js'; +import { Resolver } from '@/remote/activitypub/resolver.js'; import { LdSignature } from '@/remote/activitypub/misc/ld-signature.js'; +import { getAuthUser } from '@/remote/activitypub/misc/auth-user.js'; import { StatusError } from '@/misc/fetch.js'; -import { CacheableRemoteUser } from '@/models/entities/user.js'; -import { UserPublickey } from '@/models/entities/user-publickey.js'; import { InboxJobData } from '@/queue/types.js'; -import { shouldBlockInstance } from '@/misc/skipped-instances.js'; +import { shouldBlockInstance } from '@/misc/should-block-instance.js'; const logger = new Logger('inbox'); @@ -43,75 +41,58 @@ export default async (job: Bull.Job): Promise => { return `Old keyId is no longer supported. ${keyIdLower}`; } - const dbResolver = new DbResolver(); + const resolver = new Resolver(); - // 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}`; - } + let authUser; + try { + authUser = await getAuthUser(signature.keyId, getApId(activity.actor), resolver); + } catch (e) { + if (e instanceof StatusError) { + if (e.isClientError) { + return `skip: Ignored deleted actors on both ends ${activity.actor} - ${e.statusCode}`; + } else { throw new Error(`Error in actor ${activity.actor} - ${e.statusCode || e}`); } } } - // それでもわからなければ終了 if (authUser == null) { + // Key not found? Unacceptable! return 'skip: failed to resolve user'; + } else { + // Found key! } - // publicKey がなくても終了 - if (authUser.key == null) { - return 'skip: failed to resolve user publicKey'; - } - - // HTTP-Signatureの検証 + // verify the HTTP Signature const httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem); - // また、signatureのsignerは、activity.actorと一致する必要がある + // The signature must be valid. + // The signature must also match the actor otherwise anyone could sign any activity. if (!httpSignatureValidated || authUser.user.uri !== activity.actor) { - // 一致しなくても、でもLD-Signatureがありそうならそっちも見る + // Last resort: 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); - } + // get user based on LD-Signature key id. + // lets assume that the creator has this common form: + // + // Then we can use it as the key id and (without fragment part) user id. + authUser = await getAuthUser(activity.signature.creator, activity.signature.creator.replace(/#.*$/, ''), resolver); - // keyIdからLD-Signatureのユーザーを取得 - authUser = await dbResolver.getAuthUserFromKeyId(activity.signature.creator); if (authUser == null) { - return 'skip: LD-Signatureのユーザーが取得できませんでした'; + return 'skip: failed to resolve LD-Signature user'; } - if (authUser.key == null) { - return 'skip: LD-SignatureのユーザーはpublicKeyを持っていませんでした'; - } - - // LD-Signature検証 + // LD-Signature verification const ldSignature = new LdSignature(); const verified = await ldSignature.verifyRsaSignature2017(activity, authUser.key.keyPem).catch(() => false); if (!verified) { return 'skip: LD-Signatureの検証に失敗しました'; } - // もう一度actorチェック + // Again, the actor must match. if (authUser.user.uri !== activity.actor) { return `skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`; } @@ -156,6 +137,6 @@ export default async (job: Bull.Job): Promise => { }); // アクティビティを処理 - await perform(authUser.user, activity); + await perform(authUser.user, activity, resolver); return 'ok'; }; diff --git a/packages/backend/src/queue/processors/system/check-expired.ts b/packages/backend/src/queue/processors/system/check-expired.ts index 5608dc43c..fe7012b1d 100644 --- a/packages/backend/src/queue/processors/system/check-expired.ts +++ b/packages/backend/src/queue/processors/system/check-expired.ts @@ -1,6 +1,6 @@ import Bull from 'bull'; import { In, LessThan } from 'typeorm'; -import { AttestationChallenges, Mutings, PasswordResetRequests, Signins } from '@/models/index.js'; +import { AttestationChallenges, AuthSessions, Mutings, PasswordResetRequests, Signins } from '@/models/index.js'; import { publishUserEvent } from '@/services/stream.js'; import { MINUTE, DAY } from '@/const.js'; import { queueLogger } from '@/queue/logger.js'; @@ -40,7 +40,11 @@ export async function checkExpired(job: Bull.Job>, done: createdAt: LessThan(new Date(new Date().getTime() - 30 * MINUTE)), }); - logger.succ('Deleted expired mutes, signins and attestation challenges.'); + await AuthSessions.delete({ + createdAt: LessThan(new Date(new Date().getTime() - 15 * MINUTE)), + }); + + logger.succ('Deleted expired data.'); done(); } diff --git a/packages/backend/src/remote/activitypub/audience.ts b/packages/backend/src/remote/activitypub/audience.ts index 0e2111f0c..9c04ecb6d 100644 --- a/packages/backend/src/remote/activitypub/audience.ts +++ b/packages/backend/src/remote/activitypub/audience.ts @@ -2,7 +2,7 @@ import promiseLimit from 'promise-limit'; import { CacheableRemoteUser, CacheableUser } from '@/models/entities/user.js'; import { unique, concat } from '@/prelude/array.js'; import { resolvePerson } from './models/person.js'; -import Resolver from './resolver.js'; +import { Resolver } from './resolver.js'; import { ApObject, getApIds } from './type.js'; type Visibility = 'public' | 'home' | 'followers' | 'specified'; diff --git a/packages/backend/src/remote/activitypub/db-resolver.ts b/packages/backend/src/remote/activitypub/db-resolver.ts index 097747970..7f913de0b 100644 --- a/packages/backend/src/remote/activitypub/db-resolver.ts +++ b/packages/backend/src/remote/activitypub/db-resolver.ts @@ -1,23 +1,11 @@ import escapeRegexp from 'escape-regexp'; import config from '@/config/index.js'; import { Note } from '@/models/entities/note.js'; -import { CacheableRemoteUser, CacheableUser } from '@/models/entities/user.js'; -import { UserPublickey } from '@/models/entities/user-publickey.js'; +import { CacheableUser } from '@/models/entities/user.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 { Notes, MessagingMessages } from '@/models/index.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, - (keyId) => UserPublickeys.findOneBy({ keyId }).then(x => x ?? undefined), -); -const publicKeyByUserIdCache = new Cache( - Infinity, - (userId) => UserPublickeys.findOneBy({ userId }).then(x => x ?? undefined), -); export type UriParseResult = { /** wether the URI was generated by us */ @@ -57,7 +45,7 @@ export function parseUri(value: string | IObject): UriParseResult { } } -export default class DbResolver { +export class DbResolver { constructor() { } @@ -110,40 +98,4 @@ export default class DbResolver { return await uriPersonCache.fetch(parsed.uri) ?? null; } } - - /** - * AP KeyId => FoundKey User and Key - */ - public async getAuthUserFromKeyId(keyId: string): Promise<{ - user: CacheableRemoteUser; - key: UserPublickey; - } | null> { - const key = await publicKeyCache.fetch(keyId); - - if (key == null) return null; - - return { - user: await userByIdCache.fetch(key.userId) as CacheableRemoteUser, - key, - }; - } - - /** - * AP Actor id => FoundKey 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); - - return { - user, - key, - }; - } } diff --git a/packages/backend/src/remote/activitypub/deliver-manager.ts b/packages/backend/src/remote/activitypub/deliver-manager.ts index c706968df..4bc651c98 100644 --- a/packages/backend/src/remote/activitypub/deliver-manager.ts +++ b/packages/backend/src/remote/activitypub/deliver-manager.ts @@ -1,4 +1,3 @@ -import { IsNull, Not } from 'typeorm'; import { ILocalUser, IRemoteUser, User } from '@/models/entities/user.js'; import { Users, Followings } from '@/models/index.js'; import { deliver } from '@/queue/index.js'; @@ -32,7 +31,7 @@ const isDirect = (recipe: any): recipe is IDirectRecipe => recipe.type === 'Direct'; //#endregion -export default class DeliverManager { +export class DeliverManager { private actor: { id: User['id']; host: null; }; private activity: any; private recipes: IRecipe[] = []; @@ -147,8 +146,8 @@ export default class DeliverManager { // get (unique) list of hosts Array.from(new Set( Array.from(inboxes) - .map(inbox => new URL(inbox).host) - )) + .map(inbox => new URL(inbox).host), + )), ); // deliver diff --git a/packages/backend/src/remote/activitypub/kernel/accept/follow.ts b/packages/backend/src/remote/activitypub/kernel/accept/follow.ts index 21e7c29ce..58249d2de 100644 --- a/packages/backend/src/remote/activitypub/kernel/accept/follow.ts +++ b/packages/backend/src/remote/activitypub/kernel/accept/follow.ts @@ -2,7 +2,7 @@ import { CacheableRemoteUser } from '@/models/entities/user.js'; import accept from '@/services/following/requests/accept.js'; import { relayAccepted } from '@/services/relay.js'; import { IFollow } from '@/remote/activitypub/type.js'; -import DbResolver from '@/remote/activitypub/db-resolver.js'; +import { DbResolver } from '@/remote/activitypub/db-resolver.js'; export default async (actor: CacheableRemoteUser, activity: IFollow): Promise => { // ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある diff --git a/packages/backend/src/remote/activitypub/kernel/accept/index.ts b/packages/backend/src/remote/activitypub/kernel/accept/index.ts index ee0b6ed88..be9b80096 100644 --- a/packages/backend/src/remote/activitypub/kernel/accept/index.ts +++ b/packages/backend/src/remote/activitypub/kernel/accept/index.ts @@ -1,16 +1,14 @@ import { CacheableRemoteUser } from '@/models/entities/user.js'; import { apLogger } from '@/remote/activitypub/logger.js'; -import Resolver from '@/remote/activitypub/resolver.js'; +import { Resolver } from '@/remote/activitypub/resolver.js'; import { IAccept, isFollow, getApType } from '@/remote/activitypub/type.js'; import acceptFollow from './follow.js'; -export default async (actor: CacheableRemoteUser, activity: IAccept): Promise => { +export default async (actor: CacheableRemoteUser, activity: IAccept, resolver: Resolver): Promise => { const uri = activity.id || activity; apLogger.info(`Accept: ${uri}`); - const resolver = new Resolver(); - const object = await resolver.resolve(activity.object).catch(e => { apLogger.error(`Resolution failed: ${e}`); throw e; diff --git a/packages/backend/src/remote/activitypub/kernel/add/index.ts b/packages/backend/src/remote/activitypub/kernel/add/index.ts index e3ae01ef2..3fd5f4723 100644 --- a/packages/backend/src/remote/activitypub/kernel/add/index.ts +++ b/packages/backend/src/remote/activitypub/kernel/add/index.ts @@ -2,8 +2,9 @@ import { CacheableRemoteUser } from '@/models/entities/user.js'; import { addPinned } from '@/services/i/pin.js'; import { resolveNote } from '@/remote/activitypub/models/note.js'; import { IAdd } from '@/remote/activitypub/type.js'; +import { Resolver } from '@/remote/activitypub/resolver.js'; -export default async (actor: CacheableRemoteUser, activity: IAdd): Promise => { +export default async (actor: CacheableRemoteUser, activity: IAdd, resolver: Resolver): Promise => { if ('actor' in activity && actor.uri !== activity.actor) { throw new Error('invalid actor'); } @@ -13,7 +14,7 @@ export default async (actor: CacheableRemoteUser, activity: IAdd): Promise } if (activity.target === actor.featured) { - const note = await resolveNote(activity.object); + const note = await resolveNote(activity.object, resolver); if (note == null) throw new Error('note not found'); await addPinned(actor, note.id); return; diff --git a/packages/backend/src/remote/activitypub/kernel/announce/index.ts b/packages/backend/src/remote/activitypub/kernel/announce/index.ts index e32e8fef4..e4d77e1c5 100644 --- a/packages/backend/src/remote/activitypub/kernel/announce/index.ts +++ b/packages/backend/src/remote/activitypub/kernel/announce/index.ts @@ -1,16 +1,14 @@ import { CacheableRemoteUser } from '@/models/entities/user.js'; import { apLogger } from '@/remote/activitypub/logger.js'; -import Resolver from '@/remote/activitypub/resolver.js'; +import { Resolver } from '@/remote/activitypub/resolver.js'; import { IAnnounce, getApId } from '@/remote/activitypub/type.js'; import announceNote from './note.js'; -export default async (actor: CacheableRemoteUser, activity: IAnnounce): Promise => { +export default async (actor: CacheableRemoteUser, activity: IAnnounce, resolver: Resolver): Promise => { const uri = getApId(activity); apLogger.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 index 306ce75b1..254ef2727 100644 --- a/packages/backend/src/remote/activitypub/kernel/announce/note.ts +++ b/packages/backend/src/remote/activitypub/kernel/announce/note.ts @@ -1,15 +1,15 @@ import post from '@/services/note/create.js'; import { CacheableRemoteUser } from '@/models/entities/user.js'; import { extractDbHost } from '@/misc/convert-host.js'; -import { fetchMeta } from '@/misc/fetch-meta.js'; import { getApLock } from '@/misc/app-lock.js'; import { StatusError } from '@/misc/fetch.js'; import { Notes } from '@/models/index.js'; import { parseAudience } from '@/remote/activitypub/audience.js'; import { apLogger } from '@/remote/activitypub/logger.js'; import { fetchNote, resolveNote } from '@/remote/activitypub/models/note.js'; -import Resolver from '@/remote/activitypub/resolver.js'; +import { Resolver } from '@/remote/activitypub/resolver.js'; import { IAnnounce, getApId } from '@/remote/activitypub/type.js'; +import { shouldBlockInstance } from '@/misc/should-block-instance.js'; export default async function(resolver: Resolver, actor: CacheableRemoteUser, activity: IAnnounce, targetUri: string): Promise { const uri = getApId(activity); @@ -19,8 +19,7 @@ export default async function(resolver: Resolver, actor: CacheableRemoteUser, ac } // Cancel if the announced from host is blocked. - const meta = await fetchMeta(); - if (meta.blockedHosts.includes(extractDbHost(uri))) return; + if (await shouldBlockInstance(extractDbHost(uri))) return; const unlock = await getApLock(uri); @@ -34,7 +33,7 @@ export default async function(resolver: Resolver, actor: CacheableRemoteUser, ac // resolve the announce target let renote; try { - renote = await resolveNote(targetUri); + renote = await resolveNote(targetUri, resolver); } catch (e) { // skip if the target returns a HTTP client error if (e instanceof StatusError) { diff --git a/packages/backend/src/remote/activitypub/kernel/block/index.ts b/packages/backend/src/remote/activitypub/kernel/block/index.ts index e66ed2799..7095a36a5 100644 --- a/packages/backend/src/remote/activitypub/kernel/block/index.ts +++ b/packages/backend/src/remote/activitypub/kernel/block/index.ts @@ -1,7 +1,7 @@ import block from '@/services/blocking/create.js'; import { CacheableRemoteUser } from '@/models/entities/user.js'; import { Users } from '@/models/index.js'; -import DbResolver from '@/remote/activitypub/db-resolver.js'; +import { DbResolver } from '@/remote/activitypub/db-resolver.js'; import { IBlock } from '@/remote/activitypub/type.js'; export default async (actor: CacheableRemoteUser, activity: IBlock): Promise => { diff --git a/packages/backend/src/remote/activitypub/kernel/create/index.ts b/packages/backend/src/remote/activitypub/kernel/create/index.ts index e7531d3d3..f6e86d91d 100644 --- a/packages/backend/src/remote/activitypub/kernel/create/index.ts +++ b/packages/backend/src/remote/activitypub/kernel/create/index.ts @@ -1,11 +1,11 @@ import { CacheableRemoteUser } from '@/models/entities/user.js'; import { toArray, concat, unique } from '@/prelude/array.js'; -import Resolver from '../../resolver.js'; +import { Resolver } from '@/remote/activitypub/resolver.js'; import { ICreate, getApId, isPost, getApType } from '../../type.js'; import { apLogger } from '../../logger.js'; import createNote from './note.js'; -export default async (actor: CacheableRemoteUser, activity: ICreate): Promise => { +export default async (actor: CacheableRemoteUser, activity: ICreate, resolver: Resolver): Promise => { const uri = getApId(activity); apLogger.info(`Create: ${uri}`); @@ -26,8 +26,6 @@ export default async (actor: CacheableRemoteUser, activity: ICreate): Promise { apLogger.error(`Resolution failed: ${e}`); throw e; diff --git a/packages/backend/src/remote/activitypub/kernel/create/note.ts b/packages/backend/src/remote/activitypub/kernel/create/note.ts index 12b07c18e..0b71566ba 100644 --- a/packages/backend/src/remote/activitypub/kernel/create/note.ts +++ b/packages/backend/src/remote/activitypub/kernel/create/note.ts @@ -2,14 +2,14 @@ import { CacheableRemoteUser } from '@/models/entities/user.js'; import { getApLock } from '@/misc/app-lock.js'; import { extractDbHost } from '@/misc/convert-host.js'; import { StatusError } from '@/misc/fetch.js'; -import Resolver from '@/remote/activitypub/resolver.js'; +import { Resolver } from '@/remote/activitypub/resolver.js'; import { createNote, fetchNote } from '@/remote/activitypub/models/note.js'; import { getApId, IObject, ICreate } from '@/remote/activitypub/type.js'; /** * 投稿作成アクティビティを捌きます */ -export default async function(resolver: Resolver, actor: CacheableRemoteUser, note: IObject, silent = false, activity?: ICreate): Promise { +export default async function(resolver: Resolver, actor: CacheableRemoteUser, note: IObject, silent = false): Promise { const uri = getApId(note); if (typeof note === 'object') { diff --git a/packages/backend/src/remote/activitypub/kernel/delete/actor.ts b/packages/backend/src/remote/activitypub/kernel/delete/actor.ts index 7d09d8fa3..9467eb535 100644 --- a/packages/backend/src/remote/activitypub/kernel/delete/actor.ts +++ b/packages/backend/src/remote/activitypub/kernel/delete/actor.ts @@ -10,7 +10,12 @@ export async function deleteActor(actor: CacheableRemoteUser, uri: string): Prom return `skip: delete actor ${actor.uri} !== ${uri}`; } - const user = await Users.findOneByOrFail({ id: actor.id }); + const user = await Users.findOneBy({ id: actor.id }); + if (!user) { + // maybe a race condition, relay or something else? + // anyway, the user is gone now so dont care + return 'ok: gone'; + } if (user.isDeleted) { apLogger.info('skip: already deleted'); } diff --git a/packages/backend/src/remote/activitypub/kernel/delete/note.ts b/packages/backend/src/remote/activitypub/kernel/delete/note.ts index 8be9153b6..15c1cba8b 100644 --- a/packages/backend/src/remote/activitypub/kernel/delete/note.ts +++ b/packages/backend/src/remote/activitypub/kernel/delete/note.ts @@ -2,7 +2,7 @@ import { CacheableRemoteUser } from '@/models/entities/user.js'; import deleteNode from '@/services/note/delete.js'; import { getApLock } from '@/misc/app-lock.js'; import { deleteMessage } from '@/services/messages/delete.js'; -import DbResolver from '@/remote/activitypub/db-resolver.js'; +import { DbResolver } from '@/remote/activitypub/db-resolver.js'; import { apLogger } from '@/remote/activitypub/logger.js'; export default async function(actor: CacheableRemoteUser, uri: string): Promise { diff --git a/packages/backend/src/remote/activitypub/kernel/follow.ts b/packages/backend/src/remote/activitypub/kernel/follow.ts index f5d551db8..8125b4606 100644 --- a/packages/backend/src/remote/activitypub/kernel/follow.ts +++ b/packages/backend/src/remote/activitypub/kernel/follow.ts @@ -1,7 +1,7 @@ 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'; +import { DbResolver } from '../db-resolver.js'; export default async (actor: CacheableRemoteUser, activity: IFollow): Promise => { const dbResolver = new DbResolver(); diff --git a/packages/backend/src/remote/activitypub/kernel/index.ts b/packages/backend/src/remote/activitypub/kernel/index.ts index e2da77ef0..4a5951824 100644 --- a/packages/backend/src/remote/activitypub/kernel/index.ts +++ b/packages/backend/src/remote/activitypub/kernel/index.ts @@ -1,7 +1,7 @@ import { CacheableRemoteUser } from '@/models/entities/user.js'; import { toArray } from '@/prelude/array.js'; +import { Resolver } from '@/remote/activitypub/resolver.js'; import { apLogger } from '../logger.js'; -import Resolver from '../resolver.js'; import { IObject, isCreate, isDelete, isUpdate, isRead, isFollow, isAccept, isReject, isAdd, isRemove, isAnnounce, isLike, isUndo, isBlock, isCollectionOrOrderedCollection, isCollection, isFlag } from '../type.js'; import create from './create/index.js'; import performDeleteActivity from './delete/index.js'; @@ -18,13 +18,12 @@ import remove from './remove/index.js'; import block from './block/index.js'; import flag from './flag/index.js'; -export async function performActivity(actor: CacheableRemoteUser, activity: IObject) { +export async function performActivity(actor: CacheableRemoteUser, activity: IObject, resolver: Resolver) { 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); + await performOneActivity(actor, act, resolver); } catch (err) { if (err instanceof Error || typeof err === 'string') { apLogger.error(err); @@ -32,37 +31,37 @@ export async function performActivity(actor: CacheableRemoteUser, activity: IObj } } } else { - await performOneActivity(actor, activity); + await performOneActivity(actor, activity, resolver); } } -async function performOneActivity(actor: CacheableRemoteUser, activity: IObject): Promise { +async function performOneActivity(actor: CacheableRemoteUser, activity: IObject, resolver: Resolver): Promise { if (actor.isSuspended) return; if (isCreate(activity)) { - await create(actor, activity); + await create(actor, activity, resolver); } else if (isDelete(activity)) { await performDeleteActivity(actor, activity); } else if (isUpdate(activity)) { - await performUpdateActivity(actor, activity); + await performUpdateActivity(actor, activity, resolver); } else if (isRead(activity)) { await performReadActivity(actor, activity); } else if (isFollow(activity)) { await follow(actor, activity); } else if (isAccept(activity)) { - await accept(actor, activity); + await accept(actor, activity, resolver); } else if (isReject(activity)) { - await reject(actor, activity); + await reject(actor, activity, resolver); } else if (isAdd(activity)) { - await add(actor, activity).catch(err => apLogger.error(err)); + await add(actor, activity, resolver).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); + await announce(actor, activity, resolver); } else if (isLike(activity)) { await like(actor, activity); } else if (isUndo(activity)) { - await undo(actor, activity); + await undo(actor, activity, resolver); } else if (isBlock(activity)) { await block(actor, activity); } else if (isFlag(activity)) { diff --git a/packages/backend/src/remote/activitypub/kernel/reject/follow.ts b/packages/backend/src/remote/activitypub/kernel/reject/follow.ts index 203e01ea9..bd3ad1660 100644 --- a/packages/backend/src/remote/activitypub/kernel/reject/follow.ts +++ b/packages/backend/src/remote/activitypub/kernel/reject/follow.ts @@ -3,7 +3,7 @@ import { remoteReject } from '@/services/following/reject.js'; import { relayRejected } from '@/services/relay.js'; import { Users } from '@/models/index.js'; import { IFollow } from '../../type.js'; -import DbResolver from '../../db-resolver.js'; +import { DbResolver } from '../../db-resolver.js'; export default async (actor: CacheableRemoteUser, activity: IFollow): Promise => { // ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある diff --git a/packages/backend/src/remote/activitypub/kernel/reject/index.ts b/packages/backend/src/remote/activitypub/kernel/reject/index.ts index f75b8b44f..3a91c8ec7 100644 --- a/packages/backend/src/remote/activitypub/kernel/reject/index.ts +++ b/packages/backend/src/remote/activitypub/kernel/reject/index.ts @@ -1,16 +1,14 @@ import { CacheableRemoteUser } from '@/models/entities/user.js'; +import { Resolver } from '@/remote/activitypub/resolver.js'; import { apLogger } from '../../logger.js'; import { IReject, isFollow, getApType } from '../../type.js'; -import Resolver from '../../resolver.js'; import rejectFollow from './follow.js'; -export default async (actor: CacheableRemoteUser, activity: IReject): Promise => { +export default async (actor: CacheableRemoteUser, activity: IReject, resolver: Resolver): Promise => { const uri = activity.id || activity; apLogger.info(`Reject: ${uri}`); - const resolver = new Resolver(); - const object = await resolver.resolve(activity.object).catch(e => { apLogger.error(`Resolution failed: ${e}`); throw e; diff --git a/packages/backend/src/remote/activitypub/kernel/remove/index.ts b/packages/backend/src/remote/activitypub/kernel/remove/index.ts index 5ff71c91f..6591f82b1 100644 --- a/packages/backend/src/remote/activitypub/kernel/remove/index.ts +++ b/packages/backend/src/remote/activitypub/kernel/remove/index.ts @@ -1,9 +1,10 @@ import { CacheableRemoteUser } from '@/models/entities/user.js'; import { removePinned } from '@/services/i/pin.js'; +import { Resolver } from '@/remote/activitypub/resolver.js'; import { IRemove } from '../../type.js'; import { resolveNote } from '../../models/note.js'; -export default async (actor: CacheableRemoteUser, activity: IRemove): Promise => { +export default async (actor: CacheableRemoteUser, activity: IRemove, resolver: Resolver): Promise => { if ('actor' in activity && actor.uri !== activity.actor) { throw new Error('invalid actor'); } @@ -13,7 +14,7 @@ export default async (actor: CacheableRemoteUser, activity: IRemove): Promise => { diff --git a/packages/backend/src/remote/activitypub/kernel/undo/block.ts b/packages/backend/src/remote/activitypub/kernel/undo/block.ts index fc6763673..ae1c9c0b6 100644 --- a/packages/backend/src/remote/activitypub/kernel/undo/block.ts +++ b/packages/backend/src/remote/activitypub/kernel/undo/block.ts @@ -2,7 +2,7 @@ import unblock from '@/services/blocking/delete.js'; import { CacheableRemoteUser } from '@/models/entities/user.js'; import { Users } from '@/models/index.js'; import { IBlock } from '@/remote/activitypub/type.js'; -import DbResolver from '@/remote/activitypub/db-resolver.js'; +import { DbResolver } from '@/remote/activitypub/db-resolver.js'; export default async (actor: CacheableRemoteUser, activity: IBlock): Promise => { const dbResolver = new DbResolver(); diff --git a/packages/backend/src/remote/activitypub/kernel/undo/follow.ts b/packages/backend/src/remote/activitypub/kernel/undo/follow.ts index 418eb5255..b2607351a 100644 --- a/packages/backend/src/remote/activitypub/kernel/undo/follow.ts +++ b/packages/backend/src/remote/activitypub/kernel/undo/follow.ts @@ -3,7 +3,7 @@ import cancelRequest from '@/services/following/requests/cancel.js'; import { CacheableRemoteUser } from '@/models/entities/user.js'; import { FollowRequests, Followings } from '@/models/index.js'; import { IFollow } from '@/remote/activitypub/type.js'; -import DbResolver from '@/remote/activitypub/db-resolver.js'; +import { DbResolver } from '@/remote/activitypub/db-resolver.js'; export default async (actor: CacheableRemoteUser, activity: IFollow): Promise => { const dbResolver = new DbResolver(); diff --git a/packages/backend/src/remote/activitypub/kernel/undo/index.ts b/packages/backend/src/remote/activitypub/kernel/undo/index.ts index d85a68fdc..05382f0f5 100644 --- a/packages/backend/src/remote/activitypub/kernel/undo/index.ts +++ b/packages/backend/src/remote/activitypub/kernel/undo/index.ts @@ -1,6 +1,6 @@ import { CacheableRemoteUser } from '@/models/entities/user.js'; import { apLogger } from '@/remote/activitypub/logger.js'; -import Resolver from '@/remote/activitypub/resolver.js'; +import { Resolver } from '@/remote/activitypub/resolver.js'; import { IUndo, isFollow, isBlock, isLike, isAnnounce, getApType, isAccept } from '@/remote/activitypub/type.js'; import unfollow from './follow.js'; import unblock from './block.js'; @@ -8,7 +8,7 @@ import undoLike from './like.js'; import undoAccept from './accept.js'; import { undoAnnounce } from './announce.js'; -export default async (actor: CacheableRemoteUser, activity: IUndo): Promise => { +export default async (actor: CacheableRemoteUser, activity: IUndo, resolver: Resolver): Promise => { if ('actor' in activity && actor.uri !== activity.actor) { throw new Error('invalid actor'); } @@ -17,7 +17,6 @@ export default async (actor: CacheableRemoteUser, activity: IUndo): Promise { apLogger.error(`Resolution failed: ${e}`); throw e; diff --git a/packages/backend/src/remote/activitypub/kernel/update/index.ts b/packages/backend/src/remote/activitypub/kernel/update/index.ts index 29a352f2f..73085b181 100644 --- a/packages/backend/src/remote/activitypub/kernel/update/index.ts +++ b/packages/backend/src/remote/activitypub/kernel/update/index.ts @@ -1,29 +1,31 @@ import { CacheableRemoteUser } from '@/models/entities/user.js'; -import { getApType, IUpdate, isActor } from '@/remote/activitypub/type.js'; +import { getApId, getApType, IUpdate, isActor } from '@/remote/activitypub/type.js'; import { apLogger } from '@/remote/activitypub/logger.js'; import { updateQuestion } from '@/remote/activitypub/models/question.js'; -import Resolver from '@/remote/activitypub/resolver.js'; +import { Resolver } from '@/remote/activitypub/resolver.js'; import { updatePerson } from '@/remote/activitypub/models/person.js'; /** * Updateアクティビティを捌きます */ -export default async (actor: CacheableRemoteUser, activity: IUpdate): Promise => { +export default async (actor: CacheableRemoteUser, activity: IUpdate, resolver: Resolver): 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); + if (actor.uri !== getApId(object)) { + return 'skip: actor id !== updated actor id'; + } + + await updatePerson(object, resolver); return 'ok: Person updated'; } else if (getApType(object) === 'Question') { await updateQuestion(object, resolver).catch(e => console.log(e)); diff --git a/packages/backend/src/remote/activitypub/misc/auth-user.ts b/packages/backend/src/remote/activitypub/misc/auth-user.ts new file mode 100644 index 000000000..4705bb791 --- /dev/null +++ b/packages/backend/src/remote/activitypub/misc/auth-user.ts @@ -0,0 +1,50 @@ +import { Cache } from '@/misc/cache.js'; +import { UserPublickeys } from '@/models/index.js'; +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import { UserPublickey } from '@/models/entities/user-publickey.js'; +import { uriPersonCache, userByIdCache } from '@/services/user-cache.js'; +import { createPerson } from '@/remote/activitypub/models/person.js'; + +export type AuthUser = { + user: CacheableRemoteUser; + key: UserPublickey; +}; + +const publicKeyCache = new Cache( + Infinity, + (keyId) => UserPublickeys.findOneBy({ keyId }).then(x => x ?? undefined), +); +const publicKeyByUserIdCache = new Cache( + Infinity, + (userId) => UserPublickeys.findOneBy({ userId }).then(x => x ?? undefined), +); + +function authUserFromApId(uri: string): Promise { + return uriPersonCache.fetch(uri) + .then(async user => { + if (!user) return null; + const key = await publicKeyByUserIdCache.fetch(user.id); + if (!key) return null; + return { user, key }; + }); +} + +export async function getAuthUser(keyId: string, actorUri: string, resolver: Resolver): Promise { + let authUser = await publicKeyCache.fetch(keyId) + .then(async key => { + if (!key) return null; + else return { + user: await userByIdCache.fetch(key.userId), + key, + }; + }); + if (authUser != null) return authUser; + + authUser = await authUserFromApId(actorUri); + if (authUser != null) return authUser; + + // fetch from remote and then one last try + await createPerson(actorUri, resolver); + // if this one still returns null it seems this user really does not exist + return await authUserFromApId(actorUri); +} diff --git a/packages/backend/src/remote/activitypub/models/image.ts b/packages/backend/src/remote/activitypub/models/image.ts index d9d533f4d..281cbdf9a 100644 --- a/packages/backend/src/remote/activitypub/models/image.ts +++ b/packages/backend/src/remote/activitypub/models/image.ts @@ -5,19 +5,19 @@ import { DriveFile } from '@/models/entities/drive-file.js'; import { DriveFiles } from '@/models/index.js'; import { truncate } from '@/misc/truncate.js'; import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js'; -import Resolver from '../resolver.js'; +import { Resolver } from '@/remote/activitypub/resolver.js'; import { apLogger } from '../logger.js'; /** * Imageを作成します。 */ -export async function createImage(actor: CacheableRemoteUser, value: any): Promise { +export async function createImage(actor: CacheableRemoteUser, value: any, resolver: Resolver): Promise { // 投稿者が凍結されていたらスキップ if (actor.isSuspended) { throw new Error('actor has been suspended'); } - const image = await new Resolver().resolve(value) as any; + const image = await resolver.resolve(value) as any; if (image.url == null) { throw new Error('invalid image: url not privided'); @@ -58,9 +58,9 @@ export async function createImage(actor: CacheableRemoteUser, value: any): Promi * If the target Image is registered in FoundKey, return it; otherwise, fetch it from the remote server and return it. * Fetch the image from the remote server, register it in FoundKey and return it. */ -export async function resolveImage(actor: CacheableRemoteUser, value: any): Promise { +export async function resolveImage(actor: CacheableRemoteUser, value: any, resolver: Resolver): Promise { // TODO - // リモートサーバーからフェッチしてきて登録 - return await createImage(actor, value); + // Fetch from remote server and register it. + return await createImage(actor, value, resolver); } diff --git a/packages/backend/src/remote/activitypub/models/mention.ts b/packages/backend/src/remote/activitypub/models/mention.ts index 9baf82f59..183ab841a 100644 --- a/packages/backend/src/remote/activitypub/models/mention.ts +++ b/packages/backend/src/remote/activitypub/models/mention.ts @@ -1,15 +1,13 @@ import promiseLimit from 'promise-limit'; import { toArray, unique } from '@/prelude/array.js'; import { CacheableUser } from '@/models/entities/user.js'; +import { Resolver } from '@/remote/activitypub/resolver.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) { +export async function extractApMentions(tags: IObject | IObject[] | null | undefined, resolver: Resolver): Promise { 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))), diff --git a/packages/backend/src/remote/activitypub/models/note.ts b/packages/backend/src/remote/activitypub/models/note.ts index a53783785..e52d86d79 100644 --- a/packages/backend/src/remote/activitypub/models/note.ts +++ b/packages/backend/src/remote/activitypub/models/note.ts @@ -12,15 +12,15 @@ import { Emojis, Polls, MessagingMessages } from '@/models/index.js'; import { Note } from '@/models/entities/note.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 { StatusError } from '@/misc/fetch.js'; import { fromHtml } from '@/mfm/from-html.js'; +import { shouldBlockInstance } from '@/misc/should-block-instance.js'; +import { Resolver } from '@/remote/activitypub/resolver.js'; import { parseAudience } from '../audience.js'; import { IObject, getOneApId, getApId, getOneApHrefNullable, validPost, IPost, isEmoji, getApType } from '../type.js'; -import DbResolver from '../db-resolver.js'; -import Resolver from '../resolver.js'; +import { DbResolver } from '../db-resolver.js'; import { apLogger } from '../logger.js'; import { resolvePerson } from './person.js'; import { resolveImage } from './image.js'; @@ -28,9 +28,7 @@ import { extractApHashtags } from './tag.js'; import { extractPollFromQuestion } from './question.js'; import { extractApMentions } from './mention.js'; -export function validateNote(object: any, uri: string) { - const expectHost = extractDbHost(uri); - +export function validateNote(object: IObject): Error | null { if (object == null) { return new Error('invalid Note: object is null'); } @@ -39,12 +37,20 @@ export function validateNote(object: any, uri: string) { 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)}`); + const id = getApId(object); + if (id == null) { + // Only transient objects or anonymous objects may not have an id or an id that is explicitly null. + // We consider all Notes as not transient and not anonymous so require ids for them. + return new Error(`invalid Note: id required but not present`); } - if (object.attributedTo && extractDbHost(getOneApId(object.attributedTo)) !== expectHost) { - return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${extractDbHost(object.attributedTo)}`); + // Check that the server is authorized to act on behalf of this author. + const expectHost = extractDbHost(id); + const attributedToHost = object.attributedTo + ? extractDbHost(getOneApId(object.attributedTo)) + : null; + if (attributedToHost !== expectHost) { + return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${attributedToHost}`); } return null; @@ -63,11 +69,10 @@ export async function fetchNote(object: string | IObject): Promise /** * Noteを作成します。 */ -export async function createNote(value: string | IObject, resolver?: Resolver = new Resolver(), silent = false): Promise { - const object: any = await resolver.resolve(value); +export async function createNote(value: string | IObject, resolver: Resolver, silent = false): Promise { + const object: IObject = await resolver.resolve(value); - const entryUri = getApId(value); - const err = validateNote(object, entryUri); + const err = validateNote(object); if (err) { apLogger.error(`${err.message}`, { resolver: { @@ -107,7 +112,7 @@ export async function createNote(value: string | IObject, resolver?: Resolver = let isTalk = note._misskey_talk && visibility === 'specified'; - const apMentions = await extractApMentions(note.tag); + const apMentions = await extractApMentions(note.tag, resolver); const apHashtags = await extractApHashtags(note.tag); // 添付ファイル @@ -119,7 +124,7 @@ export async function createNote(value: string | IObject, resolver?: Resolver = 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))) + ? (await Promise.all(note.attachment.map(x => limit(() => resolveImage(actor, x, resolver)) as Promise))) .filter(image => image != null) : []; @@ -161,7 +166,7 @@ export async function createNote(value: string | IObject, resolver?: Resolver = }> => { if (typeof uri !== 'string' || !uri.match(/^https?:/)) return { status: 'permerror' }; try { - const res = await resolveNote(uri); + const res = await resolveNote(uri, resolver); if (res) { return { status: 'ok', @@ -266,18 +271,17 @@ export async function createNote(value: string | IObject, resolver?: Resolver = * If the target Note is registered in FoundKey, return it; otherwise, fetch it from a remote server and return it. * Fetch the Note from the remote server, register it in FoundKey, and return it. */ -export async function resolveNote(value: string | IObject, resolver?: Resolver): Promise { +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 new StatusError('host blocked', 451, `host ${extractDbHost(uri)} is blocked`); + // Interrupt if blocked. + if (await shouldBlockInstance(extractDbHost(uri))) throw new StatusError('host blocked', 451, `host ${extractDbHost(uri)} is blocked`); const unlock = await getApLock(uri); try { - //#region このサーバーに既に登録されていたらそれを返す + //#region If already registered on this server, return it. const exist = await fetchNote(uri); if (exist) { diff --git a/packages/backend/src/remote/activitypub/models/person.ts b/packages/backend/src/remote/activitypub/models/person.ts index a34612a74..0136aeea3 100644 --- a/packages/backend/src/remote/activitypub/models/person.ts +++ b/packages/backend/src/remote/activitypub/models/person.ts @@ -13,7 +13,7 @@ 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 { extractDbHost } 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'; @@ -23,10 +23,10 @@ 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 { 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 { Resolver } from '@/remote/activitypub/resolver.js'; +import { apLogger } from '../logger.js'; +import { isCollectionOrOrderedCollection, isCollection, IActor, getApId, getOneApHrefNullable, IObject, isPropertyValue, getApType, isActor } from '../type.js'; import { extractApHashtags } from './tag.js'; import { resolveNote, extractEmojis } from './note.js'; import { resolveImage } from './image.js'; @@ -39,8 +39,7 @@ const summaryLength = 2048; * @param x Fetched object * @param uri Fetch target URI */ -function validateActor(x: IObject, uri: string): IActor { - const expectHost = toPuny(new URL(uri).hostname); +function validateActor(x: IObject): IActor { if (x == null) { throw new Error('invalid Actor: object is null'); @@ -50,7 +49,10 @@ function validateActor(x: IObject, uri: string): IActor { throw new Error(`invalid Actor type '${x.type}'`); } - if (!(typeof x.id === 'string' && x.id.length > 0)) { + const uri = getApId(x); + if (uri == null) { + // Only transient objects or anonymous objects may not have an id or an id that is explicitly null. + // We consider all actors as not transient and not anonymous so require ids for them. throw new Error('invalid Actor: wrong id'); } @@ -78,17 +80,13 @@ function validateActor(x: IObject, uri: string): IActor { 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); + const expectHost = extractDbHost(uri); + const publicKeyIdHost = extractDbHost(x.publicKey.id); if (publicKeyIdHost !== expectHost) { throw new Error('invalid Actor: publicKey.id has different host'); } @@ -102,7 +100,7 @@ function validateActor(x: IObject, uri: string): IActor { * * If the target Person is registered in FoundKey, it is returned. */ -export async function fetchPerson(uri: string, resolver?: Resolver): Promise { +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); @@ -131,20 +129,18 @@ export async function fetchPerson(uri: string, resolver?: Resolver): Promise { - if (typeof uri !== 'string') throw new Error('uri is not string'); - - if (uri.startsWith(config.url)) { +export async function createPerson(value: string | IObject, resolver: Resolver): Promise { + if (getApId(value).startsWith(config.url)) { throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user'); } - const object = await resolver.resolve(uri) as any; + const object = await resolver.resolve(value) as any; - const person = validateActor(object, uri); + const person = validateActor(object); apLogger.info(`Creating the Person: ${person.id}`); - const host = toPuny(new URL(object.id).hostname); + const host = extractDbHost(object.id); const { fields } = analyzeAttachments(person.attachment || []); @@ -238,7 +234,7 @@ export async function createPerson(uri: string, resolver?: Resolver = new Resolv ].map(img => img == null ? Promise.resolve(null) - : resolveImage(user!, img).catch(() => null), + : resolveImage(user!, img, resolver).catch(() => null), )); const avatarId = avatar ? avatar.id : null; @@ -274,43 +270,42 @@ export async function createPerson(uri: string, resolver?: Resolver = new Resolv /** * Update Person information. * If the target Person is not registered in FoundKey, it is ignored. - * @param uri URI of Person + * @param value URI of Person or Person itself * @param resolver Resolver * @param hint Hint of Person object (If this value is a valid Person, it is used for updating without Remote resolve.) */ -export async function updatePerson(uri: string, resolver?: Resolver = new Resolver(), hint?: IObject): Promise { - if (typeof uri !== 'string') throw new Error('uri is not string'); +export async function updatePerson(value: IObject | string, resolver: Resolver): Promise { + const uri = getApId(value); - // URIがこのサーバーを指しているならスキップ - if (uri.startsWith(config.url + '/')) { + // skip local URIs + if (uri.startsWith(config.url)) { return; } - //#region このサーバーに既に登録されているか + // do we already know this user? const exist = await Users.findOneBy({ uri }) as IRemoteUser; if (exist == null) { return; } - //#endregion - const object = hint || await resolver.resolve(uri); + const object = await resolver.resolve(value); - const person = validateActor(object, uri); + const person = validateActor(object); apLogger.info(`Updating the Person: ${person.id}`); - // アバターとヘッダー画像をフェッチ + // Fetch avatar and banner image const [avatar, banner] = await Promise.all([ person.icon, person.image, ].map(img => img == null ? Promise.resolve(null) - : resolveImage(exist, img).catch(() => null), + : resolveImage(exist, img, resolver).catch(() => null), )); - // カスタム絵文字取得 + // Get custom emoji const emojis = await extractEmojis(person.tag || [], exist.host).catch(e => { apLogger.info(`extractEmojis: ${e}`); return [] as Emoji[]; @@ -318,7 +313,7 @@ export async function updatePerson(uri: string, resolver?: Resolver = new Resolv const emojiNames = emojis.map(emoji => emoji.name); - const { fields } = analyzeAttachments(person.attachment || []); + const { fields } = analyzeAttachments(person.attachment ?? []); const tags = extractApHashtags(person.tag).map(tag => normalizeForSearch(tag)).splice(0, 32); @@ -327,7 +322,7 @@ export async function updatePerson(uri: string, resolver?: Resolver = new Resolv const updates = { lastFetchedAt: new Date(), inbox: person.inbox, - sharedInbox: person.sharedInbox || (person.endpoints ? person.endpoints.sharedInbox : undefined), + sharedInbox: person.sharedInbox ?? (person.endpoints ? person.endpoints.sharedInbox : undefined), followersUri: person.followers ? getApId(person.followers) : undefined, featured: person.featured, emojis: emojiNames, @@ -367,14 +362,13 @@ export async function updatePerson(uri: string, resolver?: Resolver = new Resolv publishInternalEvent('remoteUserUpdated', { id: exist.id }); - // ハッシュタグ更新 updateUsertags(exist, tags); - // 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする + // If the user in question is already a follower, followers will also be updated. await Followings.update({ followerId: exist.id, }, { - followerSharedInbox: person.sharedInbox || (person.endpoints ? person.endpoints.sharedInbox : undefined), + followerSharedInbox: person.sharedInbox ?? (person.endpoints ? person.endpoints.sharedInbox : undefined), }); await updateFeatured(exist.id, resolver).catch(err => apLogger.error(err)); @@ -386,11 +380,11 @@ export async function updatePerson(uri: string, resolver?: Resolver = new Resolv * If the target Person is registered in FoundKey, return it; otherwise, fetch it from a remote server and return it. * Fetch the person from the remote server, register it in FoundKey, and return it. */ -export async function resolvePerson(uri: string, resolver?: Resolver): Promise { +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); + const exist = await fetchPerson(uri, resolver); if (exist) { return exist; @@ -398,38 +392,7 @@ export async function resolvePerson(uri: string, resolver?: Resolver): Promise 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') { - return { id, username: 'unknown', discriminator: '0000' }; - } else { - 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); - } + return await createPerson(uri, resolver); } export function analyzeAttachments(attachments: IObject | IObject[] | undefined) { @@ -441,29 +404,23 @@ export function analyzeAttachments(attachments: IObject | IObject[] | undefined) 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), - }); - } + fields.push({ + name: attachment.name, + value: fromHtml(attachment.value), + }); } } return { fields, services }; } -export async function updateFeatured(userId: User['id'], resolver?: Resolver) { +async function updateFeatured(userId: User['id'], resolver: Resolver) { const user = await Users.findOneByOrFail({ id: userId }); if (!Users.isRemoteUser(user)) return; if (!user.featured) return; apLogger.info(`Updating the featured: ${user.uri}`); - if (resolver == null) 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'); diff --git a/packages/backend/src/remote/activitypub/models/question.ts b/packages/backend/src/remote/activitypub/models/question.ts index 9c0a0c52e..088cdbe94 100644 --- a/packages/backend/src/remote/activitypub/models/question.ts +++ b/packages/backend/src/remote/activitypub/models/question.ts @@ -1,11 +1,11 @@ import config from '@/config/index.js'; import { Notes, Polls } from '@/models/index.js'; import { IPoll } from '@/models/entities/poll.js'; -import Resolver from '../resolver.js'; +import { Resolver } from '@/remote/activitypub/resolver.js'; import { IObject, IQuestion, isQuestion } from '../type.js'; import { apLogger } from '../logger.js'; -export async function extractPollFromQuestion(source: string | IObject, resolver?: Resolver = new Resolver()): Promise { +export async function extractPollFromQuestion(source: string | IObject, resolver: Resolver): Promise { const question = await resolver.resolve(source); if (!isQuestion(question)) { @@ -39,7 +39,7 @@ export async function extractPollFromQuestion(source: string | IObject, resolver * @param resolver Resolver to use * @returns true if updated */ -export async function updateQuestion(value: string | IObject, resolver?: Resolver = new Resolver()) { +export async function updateQuestion(value: string | IObject, resolver: Resolver) { const uri = typeof value === 'string' ? value : value.id; // URIがこのサーバーを指しているならスキップ diff --git a/packages/backend/src/remote/activitypub/perform.ts b/packages/backend/src/remote/activitypub/perform.ts index c8ecff3e7..37fd2fc12 100644 --- a/packages/backend/src/remote/activitypub/perform.ts +++ b/packages/backend/src/remote/activitypub/perform.ts @@ -1,17 +1,18 @@ import { DAY } from '@/const.js'; import { CacheableRemoteUser } from '@/models/entities/user.js'; +import { Resolver } from '@/remote/activitypub/resolver.js'; import { IObject } from './type.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); +export async function perform(actor: CacheableRemoteUser, activity: IObject, resolver: Resolver): Promise { + await performActivity(actor, activity, resolver); // And while I'm at it, I'll update the remote user information if it's out of date. if (actor.uri) { if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > DAY) { setImmediate(() => { - updatePerson(actor.uri!); + updatePerson(actor.uri!, resolver); }); } } diff --git a/packages/backend/src/remote/activitypub/renderer/follow-relay.ts b/packages/backend/src/remote/activitypub/renderer/follow-relay.ts index 2c9678090..b172f6ba9 100644 --- a/packages/backend/src/remote/activitypub/renderer/follow-relay.ts +++ b/packages/backend/src/remote/activitypub/renderer/follow-relay.ts @@ -2,13 +2,20 @@ 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) { +export type FollowRelay = { + id: string; + type: 'Follow'; + actor: string; + object: 'https://www.w3.org/ns/activitystreams#Public'; +}; + +export function renderFollowRelay(relay: Relay, relayActor: ILocalUser): FollowRelay { 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', - }; + } as const; return follow; } diff --git a/packages/backend/src/remote/activitypub/request.ts b/packages/backend/src/remote/activitypub/request.ts index 99c2b8d16..ccdd30a00 100644 --- a/packages/backend/src/remote/activitypub/request.ts +++ b/packages/backend/src/remote/activitypub/request.ts @@ -4,7 +4,7 @@ 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) => { +export async function request(user: { id: User['id'] }, url: string, object: any): Promise { const body = JSON.stringify(object); const keypair = await getUserKeypair(user.id); diff --git a/packages/backend/src/remote/activitypub/resolver.ts b/packages/backend/src/remote/activitypub/resolver.ts index a38b1fd0b..8cf1ecd71 100644 --- a/packages/backend/src/remote/activitypub/resolver.ts +++ b/packages/backend/src/remote/activitypub/resolver.ts @@ -1,5 +1,3 @@ -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 { extractDbHost, isSelfHost } from '@/misc/convert-host.js'; @@ -11,9 +9,9 @@ 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'; -import { shouldBlockInstance } from '@/misc/skipped-instances.js'; +import { shouldBlockInstance } from '@/misc/should-block-instance.js'; import { signedGet } from './request.js'; -import { IObject, isCollectionOrOrderedCollection, ICollection, IOrderedCollection } from './type.js'; +import { getApId, IObject, isCollectionOrOrderedCollection, ICollection, IOrderedCollection } from './type.js'; import { parseUri } from './db-resolver.js'; /** @@ -21,7 +19,7 @@ import { parseUri } from './db-resolver.js'; * * As opposed to the DbResolver which will try to resolve an ActivityPub URI into a database object. */ -export default class Resolver { +export class Resolver { private history: Set; private user?: ILocalUser; private recursionLimit?: number; @@ -86,11 +84,18 @@ export default class Resolver { const object = await signedGet(value, this.user); - 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' - )) { + if ( + object == null + || // check that this is an activitypub object by looking at the @context + ( + Array.isArray(object['@context']) ? + !(object['@context'] as unknown[]).includes('https://www.w3.org/ns/activitystreams') : + object['@context'] !== 'https://www.w3.org/ns/activitystreams' + ) + // Did we actually get the object that corresponds to the canonical URL? + // Does the host we requested stuff from actually correspond to the host that owns the activity? + || !(getApId(object) == null || getApId(object) === value) + ) { throw new Error('invalid response'); } diff --git a/packages/backend/src/remote/resolve-user.ts b/packages/backend/src/remote/resolve-user.ts index a9b47e77c..2b49afc40 100644 --- a/packages/backend/src/remote/resolve-user.ts +++ b/packages/backend/src/remote/resolve-user.ts @@ -2,17 +2,17 @@ import { URL } from 'node:url'; import chalk from 'chalk'; import { IsNull } from 'typeorm'; import { DAY } from '@/const.js'; -import config from '@/config/index.js'; import { isSelfHost, toPuny } from '@/misc/convert-host.js'; import { User, IRemoteUser } from '@/models/entities/user.js'; import { Users } from '@/models/index.js'; +import { Resolver } from '@/remote/activitypub/resolver.js'; import webFinger from './webfinger.js'; import { createPerson, updatePerson } from './activitypub/models/person.js'; import { remoteLogger } from './logger.js'; const logger = remoteLogger.createSubLogger('resolve-user'); -export async function resolveUser(username: string, idnHost: string | null): Promise { +export async function resolveUser(username: string, idnHost: string | null, resolver: Resolver = new Resolver()): Promise { const usernameLower = username.toLowerCase(); if (idnHost == null) { @@ -47,7 +47,7 @@ export async function resolveUser(username: string, idnHost: string | null): Pro const self = await resolveSelf(acctLower); logger.succ(`return new remote user: ${chalk.magenta(acctLower)}`); - return await createPerson(self.href); + return await createPerson(self, resolver); } // If user information is out of date, start over with webfinger @@ -60,13 +60,13 @@ export async function resolveUser(username: string, idnHost: string | null): Pro logger.info(`try resync: ${acctLower}`); const self = await resolveSelf(acctLower); - if (user.uri !== self.href) { + if (user.uri !== self) { // 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}`); + logger.info(`recovery missmatch uri for (username=${username}, host=${host}) from ${user.uri} to ${self}`); // validate uri - const uri = new URL(self.href); + const uri = new URL(self); if (uri.hostname !== host) { throw new Error('Invalid uri'); } @@ -75,16 +75,16 @@ export async function resolveUser(username: string, idnHost: string | null): Pro usernameLower, host, }, { - uri: self.href, + uri: self, }); } else { logger.info(`uri is fine: ${acctLower}`); } - await updatePerson(self.href); + await updatePerson(self, resolver); logger.info(`return resynced remote user: ${acctLower}`); - return await Users.findOneBy({ uri: self.href }).then(u => { + return await Users.findOneBy({ uri: self }).then(u => { if (u == null) { throw new Error('user not found'); } else { @@ -97,16 +97,21 @@ export async function resolveUser(username: string, idnHost: string | null): Pro return user; } -async function resolveSelf(acctLower: string) { +/** + * Gets the Webfinger href matching rel="self". + */ +async function resolveSelf(acctLower: string): string { logger.info(`WebFinger for ${chalk.yellow(acctLower)}`); + // get webfinger response for user 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) { + // try to find the rel="self" link + const self = finger.links.find(link => link.rel?.toLowerCase() === 'self'); + if (!self?.href) { logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: self link not found`); throw new Error('self link not found'); } - return self; + return self.href; } diff --git a/packages/backend/src/server/api/authenticate.ts b/packages/backend/src/server/api/authenticate.ts index f6d6a646a..25e87b75e 100644 --- a/packages/backend/src/server/api/authenticate.ts +++ b/packages/backend/src/server/api/authenticate.ts @@ -1,16 +1,9 @@ -import { CacheableLocalUser, ILocalUser } from '@/models/entities/user.js'; -import { Users, AccessTokens, Apps } from '@/models/index.js'; +import { CacheableLocalUser } from '@/models/entities/user.js'; +import { Users, AccessTokens } 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 { userByIdCache, localUserByNativeTokenCache } from '@/services/user-cache.js'; import isNativeToken from './common/is-native-token.js'; -const appCache = new Cache( - Infinity, - (id) => Apps.findOneByOrFail({ id }), -); - export class AuthenticationError extends Error { constructor(message: string) { super(message); @@ -71,15 +64,6 @@ export default async (authorization: string | null | undefined, bodyToken: strin // can't authorize remote users if (!Users.isLocalUser(user)) return [null, null]; - if (accessToken.appId) { - const app = await appCache.fetch(accessToken.appId); - - return [user, { - id: accessToken.id, - permission: app.permission, - } as AccessToken]; - } else { - return [user, accessToken]; - } + return [user, accessToken]; } }; diff --git a/packages/backend/src/server/api/common/compare-url.ts b/packages/backend/src/server/api/common/compare-url.ts new file mode 100644 index 000000000..2d35dbd97 --- /dev/null +++ b/packages/backend/src/server/api/common/compare-url.ts @@ -0,0 +1,42 @@ +import { URL } from 'node:url'; + +/** + * Compares two URLs for OAuth. The first parameter is the trusted URL + * which decides how the comparison is conducted. + * + * Invalid URLs are never equal. + * + * Implements the current draft-ietf-oauth-security-topics-21 § 4.1.3 + * (published 2022-09-27) + */ +export function compareUrl(trusted: string, untrusted: string): boolean { + let trustedUrl, untrustedUrl; + + try { + trustedUrl = new URL(trusted); + untrustedUrl = new URL(untrusted); + } catch { + return false; + } + + // Excerpt from RFC 8252: + //> Loopback redirect URIs use the "http" scheme and are constructed with + //> the loopback IP literal and whatever port the client is listening on. + //> That is, "http://127.0.0.1:{port}/{path}" for IPv4, and + //> "http://[::1]:{port}/{path}" for IPv6. + // + // To be nice we also include the "localhost" name, since it is required + // to resolve to one of the other two. + if (trustedUrl.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(trustedUrl.host)) { + // localhost comparisons should ignore port number + trustedUrl.port = ''; + untrustedUrl.port = ''; + } + + // security recommendation is to just compare the (normalized) string + //> This document therefore advises to simplify the required logic and configuration + //> by using exact redirect URI matching. This means the authorization server MUST + //> compare the two URIs using simple string comparison as defined in [RFC3986], + //> Section 6.2.1. + return trustedUrl.href === untrustedUrl.href; +} diff --git a/packages/backend/src/server/api/common/inject-featured.ts b/packages/backend/src/server/api/common/inject-featured.ts index f79c57cbf..13bf156c1 100644 --- a/packages/backend/src/server/api/common/inject-featured.ts +++ b/packages/backend/src/server/api/common/inject-featured.ts @@ -1,7 +1,7 @@ -import rndstr from 'rndstr'; import { DAY } from '@/const.js'; import { Note } from '@/models/entities/note.js'; import { User } from '@/models/entities/user.js'; +import { secureRndstr } from '@/misc/secure-rndstr.js'; import { Notes, UserProfiles, NoteReactions } from '@/models/index.js'; import { generateMutedUserQuery } from './generate-muted-user-query.js'; import { generateBlockedUserQuery } from './generate-block-query.js'; @@ -50,7 +50,7 @@ export async function injectFeatured(timeline: Note[], user?: User | null) { // Pick random one const featured = notes[Math.floor(Math.random() * notes.length)]; - (featured as any)._featuredId_ = rndstr('a-z0-9', 8); + (featured as any)._featuredId_ = secureRndstr(8); // Inject featured timeline.splice(3, 0, featured); diff --git a/packages/backend/src/server/api/common/oauth.ts b/packages/backend/src/server/api/common/oauth.ts new file mode 100644 index 000000000..3fec3ef11 --- /dev/null +++ b/packages/backend/src/server/api/common/oauth.ts @@ -0,0 +1,129 @@ +import * as crypto from 'node:crypto'; +import Koa from 'koa'; +import { IsNull, Not } from 'typeorm'; +import { Apps, AuthSessions } from '@/models/index.js'; +import { compareUrl } from './compare-url.js'; + +export async function oauth(ctx: Koa.Context): void { + const { + grant_type, + code, + redirect_uri, + code_verifier, + } = ctx.request.body; + + // check if any of the parameters are null or empty string + if ([grant_type, code].some(x => !x)) { + ctx.response.status = 400; + ctx.response.body = { + error: 'invalid_request', + }; + return; + } + + if (grant_type !== 'authorization_code') { + ctx.response.status = 400; + ctx.response.body = { + error: 'unsupported_grant_type', + error_description: 'only authorization_code grants are supported', + }; + return; + } + + const authHeader = ctx.headers.authorization; + if (!authHeader?.toLowerCase().startsWith('basic ')) { + ctx.response.status = 401; + ctx.response.set('WWW-Authenticate', 'Basic'); + ctx.response.body = { + error: 'invalid_client', + error_description: 'HTTP Basic Authentication required', + }; + return; + } + + const [client_id, client_secret] = new Buffer(authHeader.slice(6), 'base64') + .toString('ascii') + .split(':', 2); + + const [app, session] = await Promise.all([ + Apps.findOneBy({ + id: client_id, + secret: client_secret, + }), + AuthSessions.findOne({ + where: { + appId: client_id, + token: code, + // only check for approved auth sessions + accessTokenId: Not(IsNull()), + }, + relations: { + accessToken: true, + }, + }), + ]); + if (app == null) { + ctx.response.status = 401; + ctx.response.set('WWW-Authenticate', 'Basic'); + ctx.response.body = { + error: 'invalid_client', + error_description: 'authentication failed', + }; + return; + } + if (session == null) { + ctx.response.status = 400; + ctx.response.body = { + error: 'invalid_grant', + }; + return; + } + + // check PKCE challenge, if provided before + if (session.pkceChallenge) { + // Also checking the client's homework, the RFC says: + //> minimum length of 43 characters and a maximum length of 128 characters + if (!code_verifier || code_verifier.length < 43 || code_verifier.length > 128) { + ctx.response.status = 400; + ctx.response.body = { + error: 'invalid_grant', + error_description: 'invalid or missing PKCE code_verifier', + }; + return; + } else { + // verify that (from RFC 7636): + //> BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) == code_challenge + const hash = crypto.createHash('sha256'); + hash.update(code_verifier); + + if (hash.digest('base64url') !== code_challenge) { + ctx.response.status = 400; + ctx.response.body = { + error: 'invalid_grant', + error_description: 'invalid PKCE code_verifier', + }; + return; + } + } + } + + // check redirect URI + if (!compareUrl(app.callbackUrl, redirect_uri)) { + ctx.response.status = 400; + ctx.response.body = { + error: 'invalid_grant', + error_description: 'Mismatched redirect_uri', + }; + return; + } + + // session is single use + await AuthSessions.delete(session.id), + + ctx.response.status = 200; + ctx.response.body = { + access_token: session.accessToken.token, + token_type: 'bearer', + scope: session.accessToken.permission.join(' '), + }; +} diff --git a/packages/backend/src/server/api/define.ts b/packages/backend/src/server/api/define.ts index 840283957..243b105ae 100644 --- a/packages/backend/src/server/api/define.ts +++ b/packages/backend/src/server/api/define.ts @@ -1,6 +1,6 @@ import * as fs from 'node:fs'; import Ajv from 'ajv'; -import { CacheableLocalUser, ILocalUser } from '@/models/entities/user.js'; +import { CacheableLocalUser } 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'; diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 3237935d5..c85bb6632 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -67,6 +67,7 @@ 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_deny from './endpoints/auth/deny.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'; @@ -375,6 +376,7 @@ const eps = [ ['app/create', ep___app_create], ['app/show', ep___app_show], ['auth/accept', ep___auth_accept], + ['auth/deny', ep___auth_deny], ['auth/session/generate', ep___auth_session_generate], ['auth/session/show', ep___auth_session_show], ['auth/session/userkey', ep___auth_session_userkey], 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 ad5b9896c..a4871da27 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts @@ -1,4 +1,3 @@ -import rndstr from 'rndstr'; import { publishBroadcastStream } from '@/services/stream.js'; import { db } from '@/db/postgre.js'; import { Emojis, DriveFiles } from '@/models/index.js'; @@ -30,7 +29,7 @@ export default define(meta, paramDef, async (ps, me) => { if (file == null) throw new ApiError('NO_SUCH_FILE'); - const name = file.name.split('.')[0].match(/^[a-z0-9_]+$/) ? file.name.split('.')[0] : `_${rndstr('a-z0-9', 8)}_`; + const name = file.name.split('.')[0].match(/^[a-z0-9_]+$/) ? file.name.split('.')[0] : `_${genId()}_`; const emoji = await Emojis.insert({ id: genId(), diff --git a/packages/backend/src/server/api/endpoints/admin/invite.ts b/packages/backend/src/server/api/endpoints/admin/invite.ts index a7b2dac4d..38ec85e51 100644 --- a/packages/backend/src/server/api/endpoints/admin/invite.ts +++ b/packages/backend/src/server/api/endpoints/admin/invite.ts @@ -1,6 +1,6 @@ -import rndstr from 'rndstr'; import { RegistrationTickets } from '@/models/index.js'; import { genId } from '@/misc/gen-id.js'; +import { secureRndstrCustom } from '@/misc/secure-rndstr.js'; import define from '../../define.js'; export const meta = { @@ -32,10 +32,8 @@ export const paramDef = { // 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) - }); + // omit visually ambiguous zero and letter O as well as one and letter I + const code = secureRndstrCustom(8, '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'); await RegistrationTickets.insert({ id: genId(), diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index ddd64f6b1..57e60f1c6 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -102,18 +102,6 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, - enableTwitterIntegration: { - type: 'boolean', - optional: false, nullable: false, - }, - enableGithubIntegration: { - type: 'boolean', - optional: false, nullable: false, - }, - enableDiscordIntegration: { - type: 'boolean', - optional: false, nullable: false, - }, translatorAvailable: { type: 'boolean', optional: false, nullable: false, @@ -163,30 +151,6 @@ export const meta = { optional: true, nullable: true, format: 'id', }, - twitterConsumerKey: { - type: 'string', - optional: true, nullable: true, - }, - twitterConsumerSecret: { - type: 'string', - optional: true, nullable: true, - }, - githubClientId: { - type: 'string', - optional: true, nullable: true, - }, - githubClientSecret: { - type: 'string', - optional: true, nullable: true, - }, - discordClientId: { - type: 'string', - optional: true, nullable: true, - }, - discordClientSecret: { - type: 'string', - optional: true, nullable: true, - }, summaryProxy: { type: 'string', optional: true, nullable: true, @@ -324,9 +288,6 @@ export default define(meta, paramDef, async (ps, me) => { defaultLightTheme: instance.defaultLightTheme, defaultDarkTheme: instance.defaultDarkTheme, enableEmail: instance.enableEmail, - enableTwitterIntegration: instance.enableTwitterIntegration, - enableGithubIntegration: instance.enableGithubIntegration, - enableDiscordIntegration: instance.enableDiscordIntegration, pinnedPages: instance.pinnedPages, pinnedClipId: instance.pinnedClipId, cacheRemoteFiles: instance.cacheRemoteFiles, @@ -338,12 +299,6 @@ export default define(meta, paramDef, async (ps, me) => { hcaptchaSecretKey: instance.hcaptchaSecretKey, recaptchaSecretKey: instance.recaptchaSecretKey, 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, 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 d0a98ff5b..97d6f51d4 100644 --- a/packages/backend/src/server/api/endpoints/admin/reset-password.ts +++ b/packages/backend/src/server/api/endpoints/admin/reset-password.ts @@ -1,5 +1,5 @@ import bcrypt from 'bcryptjs'; -import rndstr from 'rndstr'; +import { secureRndstr } from '@/misc/secure-rndstr.js'; import { Users, UserProfiles } from '@/models/index.js'; import define from '../../define.js'; @@ -43,7 +43,7 @@ export default define(meta, paramDef, async (ps) => { throw new Error('cannot reset password of admin'); } - const passwd = rndstr('a-zA-Z0-9', 8); + const passwd = secureRndstr(8, true); // Generate hash of password const hash = bcrypt.hashSync(passwd); 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 bc9c193f8..d7cf37ea3 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -45,11 +45,6 @@ export default define(meta, paramDef, async (ps, me) => { }; } - 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 { @@ -61,7 +56,6 @@ export default define(meta, paramDef, async (ps, me) => { carefulBot: profile.carefulBot, injectFeaturedNote: profile.injectFeaturedNote, receiveAnnouncementEmail: profile.receiveAnnouncementEmail, - integrations: profile.integrations, mutedWords: profile.mutedWords, mutedInstances: profile.mutedInstances, mutingNotificationTypes: profile.mutingNotificationTypes, 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 c6913a675..965140018 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -60,15 +60,6 @@ export const paramDef = { deeplAuthKey: { type: 'string', nullable: true }, libreTranslateAuthKey: { type: 'string', nullable: true }, libreTranslateEndpoint: { type: 'string', nullable: true }, - enableTwitterIntegration: { type: 'boolean' }, - twitterConsumerKey: { type: 'string', nullable: true }, - twitterConsumerSecret: { type: 'string', nullable: true }, - enableGithubIntegration: { type: 'boolean' }, - githubClientId: { type: 'string', nullable: true }, - githubClientSecret: { type: 'string', nullable: true }, - enableDiscordIntegration: { type: 'boolean' }, - discordClientId: { type: 'string', nullable: true }, - discordClientSecret: { type: 'string', nullable: true }, enableEmail: { type: 'boolean' }, email: { type: 'string', nullable: true }, smtpSecure: { type: 'boolean' }, @@ -230,42 +221,6 @@ export default define(meta, paramDef, async (ps, me) => { set.summalyProxy = ps.summalyProxy; } - if (ps.enableTwitterIntegration !== undefined) { - set.enableTwitterIntegration = ps.enableTwitterIntegration; - } - - if (ps.twitterConsumerKey !== undefined) { - set.twitterConsumerKey = ps.twitterConsumerKey; - } - - if (ps.twitterConsumerSecret !== undefined) { - set.twitterConsumerSecret = ps.twitterConsumerSecret; - } - - if (ps.enableGithubIntegration !== undefined) { - set.enableGithubIntegration = ps.enableGithubIntegration; - } - - if (ps.githubClientId !== undefined) { - set.githubClientId = ps.githubClientId; - } - - if (ps.githubClientSecret !== undefined) { - set.githubClientSecret = ps.githubClientSecret; - } - - if (ps.enableDiscordIntegration !== undefined) { - set.enableDiscordIntegration = ps.enableDiscordIntegration; - } - - if (ps.discordClientId !== undefined) { - set.discordClientId = ps.discordClientId; - } - - if (ps.discordClientSecret !== undefined) { - set.discordClientSecret = ps.discordClientSecret; - } - if (ps.enableEmail !== undefined) { set.enableEmail = ps.enableEmail; } diff --git a/packages/backend/src/server/api/endpoints/ap/get.ts b/packages/backend/src/server/api/endpoints/ap/get.ts index d20c36e7c..7f261b0bf 100644 --- a/packages/backend/src/server/api/endpoints/ap/get.ts +++ b/packages/backend/src/server/api/endpoints/ap/get.ts @@ -1,4 +1,4 @@ -import Resolver from '@/remote/activitypub/resolver.js'; +import { Resolver } from '@/remote/activitypub/resolver.js'; import { HOUR } from '@/const.js'; import define from '../../define.js'; diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index ef66bac3f..4f8832d6b 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -1,7 +1,7 @@ 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 { DbResolver } from '@/remote/activitypub/db-resolver.js'; +import { Resolver } from '@/remote/activitypub/resolver.js'; import { extractDbHost } from '@/misc/convert-host.js'; import { Users, Notes } from '@/models/index.js'; import { Note } from '@/models/entities/note.js'; @@ -9,7 +9,7 @@ import { CacheableLocalUser, User } from '@/models/entities/user.js'; import { isActor, isPost, getApId } from '@/remote/activitypub/type.js'; import { SchemaType } from '@/misc/schema.js'; import { HOUR } from '@/const.js'; -import { shouldBlockInstance } from '@/misc/skipped-instances.js'; +import { shouldBlockInstance } from '@/misc/should-block-instance.js'; import define from '../../define.js'; import { ApiError } from '../../error.js'; @@ -114,8 +114,8 @@ async function fetchAny(uri: string, me: CacheableLocalUser | null | undefined): return await mergePack( me, - isActor(object) ? await createPerson(getApId(object)) : null, - isPost(object) ? await createNote(getApId(object), undefined, true) : null, + isActor(object) ? await createPerson(object, resolver) : null, + isPost(object) ? await createNote(object, resolver, true) : null, ); } diff --git a/packages/backend/src/server/api/endpoints/app/create.ts b/packages/backend/src/server/api/endpoints/app/create.ts index 350f988e2..61de50d39 100644 --- a/packages/backend/src/server/api/endpoints/app/create.ts +++ b/packages/backend/src/server/api/endpoints/app/create.ts @@ -1,6 +1,5 @@ import { Apps } from '@/models/index.js'; import { genId } from '@/misc/gen-id.js'; -import { unique } from '@/prelude/array.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { kinds } from '@/misc/api-permissions.js'; import define from '../../define.js'; diff --git a/packages/backend/src/server/api/endpoints/auth/accept.ts b/packages/backend/src/server/api/endpoints/auth/accept.ts index 691b4a867..736c1e3f6 100644 --- a/packages/backend/src/server/api/endpoints/auth/accept.ts +++ b/packages/backend/src/server/api/endpoints/auth/accept.ts @@ -2,6 +2,7 @@ import * as crypto from 'node:crypto'; import { AuthSessions, AccessTokens, Apps } from '@/models/index.js'; import { genId } from '@/misc/gen-id.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; +import { kinds } from '@/misc/api-permissions.js'; import define from '../../define.js'; import { ApiError } from '../../error.js'; @@ -19,6 +20,17 @@ export const paramDef = { type: 'object', properties: { token: { type: 'string' }, + permission: { + description: 'The permissions which the user wishes to grant in this token. ' + + 'Permissions that the app has not registered before will be removed. ' + + 'Defaults to all permissions the app was registered with if not provided.', + type: 'array', + uniqueItems: true, + items: { + type: 'string', + enum: kinds, + }, + }, }, required: ['token'], } as const; @@ -34,37 +46,35 @@ export default define(meta, paramDef, async (ps, user) => { // Generate access token const accessToken = secureRndstr(32, true); - // Fetch exist access token - const exist = await AccessTokens.findOneBy({ + // Check for existing access token. + const app = await Apps.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(); + + // Calculate the set intersection between requested permissions and + // permissions that the app registered with. If no specific permissions + // are given, grant all permissions the app registered with. + const permission = ps.permission?.filter(x => app.permission.includes(x)) ?? app.permission; + + const accessTokenId = genId(); + + // Insert access token doc + await AccessTokens.insert({ + id: accessTokenId, + createdAt: now, + lastUsedAt: now, appId: session.appId, userId: user.id, + token: accessToken, + hash, + permission, }); - if (exist == null) { - // Lookup app - const app = await Apps.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 AccessTokens.insert({ - id: genId(), - createdAt: now, - lastUsedAt: now, - appId: session.appId, - userId: user.id, - token: accessToken, - hash, - }); - } - // Update session - await AuthSessions.update(session.id, { - userId: user.id, - }); + await AuthSessions.update(session.id, { accessTokenId }); }); diff --git a/packages/backend/src/server/api/endpoints/auth/deny.ts b/packages/backend/src/server/api/endpoints/auth/deny.ts new file mode 100644 index 000000000..b3bb4ab8c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/auth/deny.ts @@ -0,0 +1,38 @@ +import { AuthSessions } from '@/models/index.js'; +import define from '../../define.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['auth'], + + requireCredential: true, + + secure: true, + + errors: { + noSuchSession: { + message: 'No such session.', + code: 'NO_SUCH_SESSION', + id: '9c72d8de-391a-43c1-9d06-08d29efde8df', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + token: { type: 'string' }, + }, + required: ['token'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const result = await AuthSessions.delete({ + token: ps.token, + }); + + if (result.affected === 0) { + throw new ApiError(meta.errors.noSuchSession); + } +}); 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 eeb51abc6..8fb133bdf 100644 --- a/packages/backend/src/server/api/endpoints/auth/session/generate.ts +++ b/packages/backend/src/server/api/endpoints/auth/session/generate.ts @@ -2,6 +2,7 @@ import { v4 as uuid } from 'uuid'; import config from '@/config/index.js'; import { Apps, AuthSessions } from '@/models/index.js'; import { genId } from '@/misc/gen-id.js'; +import { compareUrl } from '@/server/api/common/compare-url.js'; import define from '../../../define.js'; import { ApiError } from '../../../error.js'; @@ -23,6 +24,19 @@ export const meta = { optional: false, nullable: false, format: 'url', }, + // stuff that auth/session/show would respond with + id: { + type: 'string', + description: 'The ID of the authentication session. Same as returned by `auth/session/show`.', + optional: false, nullable: false, + format: 'id', + }, + app: { + type: 'object', + description: 'The App requesting permissions. Same as returned by `auth/session/show`.', + optional: false, nullable: false, + ref: 'App', + }, }, }, @@ -31,16 +45,33 @@ export const meta = { export const paramDef = { type: 'object', - properties: { - appSecret: { type: 'string' }, - }, - required: ['appSecret'], + oneOf: [{ + properties: { + clientId: { type: 'string' }, + callbackUrl: { + type: 'string', + minLength: 1, + }, + pkceChallenge: { + type: 'string', + minLength: 1, + }, + }, + required: ['clientId'], + }, { + properties: { + appSecret: { type: 'string' }, + }, + required: ['appSecret'], + }], } as const; // eslint-disable-next-line import/no-default-export export default define(meta, paramDef, async (ps) => { // Lookup app - const app = await Apps.findOneBy({ + const app = await Apps.findOneBy(ps.clientId ? { + id: ps.clientId, + } : { secret: ps.appSecret, }); @@ -48,19 +79,31 @@ export default define(meta, paramDef, async (ps) => { throw new ApiError('NO_SUCH_APP'); } + // check URL if provided + // technically the OAuth specification says that the redirect URI has to be + // bound with the token request, but since an app may only register one + // redirect URI, we don't actually have to store that. + if (ps.callbackUrl && !compareUrl(app.callbackUrl, ps.callbackUrl)) { + throw new ApiError('NO_SUCH_APP', 'redirect URI mismatch'); + } + // Generate token const token = uuid(); + const id = genId(); // Create session token document const doc = await AuthSessions.insert({ - id: genId(), + id, createdAt: new Date(), appId: app.id, token, + pkceChallenge: ps.pkceChallenge, }).then(x => AuthSessions.findOneByOrFail(x.identifiers[0])); return { token: doc.token, url: `${config.authUrl}/${doc.token}`, + id, + app: await Apps.pack(app), }; }); diff --git a/packages/backend/src/server/api/endpoints/auth/session/oauth.ts b/packages/backend/src/server/api/endpoints/auth/session/oauth.ts new file mode 100644 index 000000000..d6aa6caab --- /dev/null +++ b/packages/backend/src/server/api/endpoints/auth/session/oauth.ts @@ -0,0 +1,5 @@ +/* +This route is already in use, but the functionality is provided +by '@/server/api/common/oauth.ts'. The route is not here because +that route requires more deep level access to HTTP data. +*/ 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 3a741db44..a9e69c895 100644 --- a/packages/backend/src/server/api/endpoints/auth/session/userkey.ts +++ b/packages/backend/src/server/api/endpoints/auth/session/userkey.ts @@ -1,4 +1,4 @@ -import { Apps, AuthSessions, AccessTokens, Users } from '@/models/index.js'; +import { Apps, AuthSessions, Users } from '@/models/index.js'; import define from '../../../define.js'; import { ApiError } from '../../../error.js'; @@ -46,27 +46,26 @@ export default define(meta, paramDef, async (ps) => { if (app == null) throw new ApiError('NO_SUCH_APP'); // Fetch token - const session = await AuthSessions.findOneBy({ - token: ps.token, - appId: app.id, + const session = await AuthSessions.findOne({ + where: { + token: ps.token, + appId: app.id, + }, + relations: { + accessToken: true, + }, }); if (session == null) throw new ApiError('NO_SUCH_SESSION'); - if (session.userId == null) throw new ApiError('PENDING_SESSION'); - - // Lookup access token - const accessToken = await AccessTokens.findOneByOrFail({ - appId: app.id, - userId: session.userId, - }); + if (session.accessTokenId == null) throw new ApiError('PENDING_SESSION'); // Delete session AuthSessions.delete(session.id); return { - accessToken: accessToken.token, - user: await Users.pack(session.userId, null, { + accessToken: session.accessToken.token, + user: await Users.pack(session.accessToken.userId, null, { detail: true, }), }; 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 8b7be8e36..65b505070 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,3 +1,4 @@ +import { Resolver } from '@/remote/activitypub/resolver.js'; import { updatePerson } from '@/remote/activitypub/models/person.js'; import define from '../../define.js'; import { getRemoteUser } from '../../common/getters.js'; @@ -19,5 +20,5 @@ export const paramDef = { // 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!); + await updatePerson(user.uri!, new Resolver()); }); 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 b0d1a2dba..cbbb3a0ad 100644 --- a/packages/backend/src/server/api/endpoints/i/import-following.ts +++ b/packages/backend/src/server/api/endpoints/i/import-following.ts @@ -8,7 +8,7 @@ export const meta = { secure: true, requireCredential: true, limit: { - duratition: HOUR, + duration: HOUR, max: 1, }, 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 cb3d6356a..057ad5cf3 100644 --- a/packages/backend/src/server/api/endpoints/i/update-email.ts +++ b/packages/backend/src/server/api/endpoints/i/update-email.ts @@ -1,7 +1,7 @@ -import rndstr from 'rndstr'; import bcrypt from 'bcryptjs'; import { publishMainStream } from '@/services/stream.js'; import config from '@/config/index.js'; +import { secureRndstr } from '@/misc/secure-rndstr.js'; import { Users, UserProfiles } from '@/models/index.js'; import { sendEmail } from '@/services/send-email.js'; import { validateEmailForAccount } from '@/services/validate-email-for-account.js'; @@ -62,7 +62,7 @@ export default define(meta, paramDef, async (ps, user) => { publishMainStream(user.id, 'meUpdated', iObj); if (ps.email != null) { - const code = rndstr('a-z0-9', 16); + const code = secureRndstr(16); await UserProfiles.update(user.id, { emailVerifyCode: code, diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts index 569b8f56b..cf22b0cc8 100644 --- a/packages/backend/src/server/api/endpoints/meta.ts +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -170,18 +170,6 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, - enableTwitterIntegration: { - type: 'boolean', - optional: false, nullable: false, - }, - enableGithubIntegration: { - type: 'boolean', - optional: false, nullable: false, - }, - enableDiscordIntegration: { - type: 'boolean', - optional: false, nullable: false, - }, translatorAvailable: { type: 'boolean', optional: false, nullable: false, @@ -190,6 +178,15 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + images: { + type: 'object', + optional: false, nullable: false, + properties: { + info: { type: 'string' }, + notFound: { type: 'string' }, + error: { type: 'string' }, + }, + }, features: { type: 'object', optional: true, nullable: false, @@ -222,18 +219,6 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, - twitter: { - type: 'boolean', - optional: false, nullable: false, - }, - github: { - type: 'boolean', - optional: false, nullable: false, - }, - discord: { - type: 'boolean', - optional: false, nullable: false, - }, serviceWorker: { type: 'boolean', optional: true, nullable: false, @@ -314,10 +299,6 @@ export default define(meta, paramDef, async (ps, me) => { defaultDarkTheme: instance.defaultDarkTheme, enableEmail: instance.enableEmail, - enableTwitterIntegration: instance.enableTwitterIntegration, - enableGithubIntegration: instance.enableGithubIntegration, - enableDiscordIntegration: instance.enableDiscordIntegration, - translatorAvailable: translatorAvailable(instance), pinnedPages: instance.pinnedPages, @@ -329,6 +310,8 @@ export default define(meta, paramDef, async (ps, me) => { proxyAccountName: instance.proxyAccountId ? (await Users.pack(instance.proxyAccountId).catch(() => null))?.username : null, + images: config.images, + features: { registration: !instance.disableRegistration, localTimeLine: !instance.disableLocalTimeline, @@ -338,9 +321,6 @@ export default define(meta, paramDef, async (ps, me) => { hcaptcha: instance.enableHcaptcha, recaptcha: instance.enableRecaptcha, objectStorage: instance.useObjectStorage, - twitter: instance.enableTwitterIntegration, - github: instance.enableGithubIntegration, - discord: instance.enableDiscordIntegration, serviceWorker: true, miauth: true, }, diff --git a/packages/backend/src/server/api/endpoints/notes/replies.ts b/packages/backend/src/server/api/endpoints/notes/replies.ts index ab0018f58..26fb9ff87 100644 --- a/packages/backend/src/server/api/endpoints/notes/replies.ts +++ b/packages/backend/src/server/api/endpoints/notes/replies.ts @@ -1,9 +1,11 @@ import { Notes } from '@/models/index.js'; +import { ApiError } from '@/server/api/error.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 { getNote } from '../../common/getters.js'; export const meta = { tags: ['notes'], @@ -19,6 +21,8 @@ export const meta = { ref: 'Note', }, }, + + errors: ['NO_SUCH_NOTE'], } as const; export const paramDef = { @@ -34,6 +38,11 @@ export const paramDef = { // eslint-disable-next-line import/no-default-export export default define(meta, paramDef, async (ps, user) => { + await getNote(ps.noteId, user).catch(err => { + if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError('NO_SUCH_NOTE'); + throw err; + }); + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) .andWhere('note.replyId = :replyId', { replyId: ps.noteId }) .innerJoinAndSelect('note.user', 'user') diff --git a/packages/backend/src/server/api/endpoints/notes/state.ts b/packages/backend/src/server/api/endpoints/notes/state.ts index 67579b2a6..5a7d9dc40 100644 --- a/packages/backend/src/server/api/endpoints/notes/state.ts +++ b/packages/backend/src/server/api/endpoints/notes/state.ts @@ -1,4 +1,5 @@ -import { NoteFavorites, Notes, NoteThreadMutings, NoteWatchings } from '@/models/index.js'; +import { NoteFavorites, NoteThreadMutings, NoteWatchings } from '@/models/index.js'; +import { ApiError } from '@/server/api/error.js'; import { getNote } from '../../common/getters.js'; import define from '../../define.js'; @@ -25,6 +26,8 @@ export const meta = { }, }, }, + + errors: ['NO_SUCH_NOTE'], } as const; export const paramDef = { @@ -37,7 +40,10 @@ export const paramDef = { // eslint-disable-next-line import/no-default-export export default define(meta, paramDef, async (ps, user) => { - const note = await getNote(ps.noteId, user); + const note = await getNote(ps.noteId, user).catch(err => { + if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError('NO_SUCH_NOTE'); + throw err; + }); const [favorite, watching, threadMuting] = await Promise.all([ NoteFavorites.count({ 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 4ca4703e9..e97d9c4b2 100644 --- a/packages/backend/src/server/api/endpoints/request-reset-password.ts +++ b/packages/backend/src/server/api/endpoints/request-reset-password.ts @@ -1,9 +1,9 @@ -import rndstr from 'rndstr'; import { IsNull } from 'typeorm'; 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 { secureRndstr } from '@/misc/secure-rndstr.js'; import { DAY } from '@/const.js'; import define from '../define.js'; @@ -53,7 +53,7 @@ export default define(meta, paramDef, async (ps) => { return; } - const token = rndstr('a-z0-9', 64); + const token = secureRndstr(64); await PasswordResetRequests.insert({ id: genId(), 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 84096eac0..cbde7ef79 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,5 +1,5 @@ -import { MONTH } from '@/const.js'; import { Brackets } from 'typeorm'; +import { MONTH } from '@/const.js'; import { Followings, Users } from '@/models/index.js'; import { User } from '@/models/entities/user.js'; import define from '../../define.js'; diff --git a/packages/backend/src/server/api/index.ts b/packages/backend/src/server/api/index.ts index 140649dcc..2d2680e55 100644 --- a/packages/backend/src/server/api/index.ts +++ b/packages/backend/src/server/api/index.ts @@ -15,9 +15,7 @@ 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'; +import { oauth } from './common/oauth.js'; // Init app const app = new Koa(); @@ -74,14 +72,13 @@ for (const endpoint of endpoints) { } } +// the OAuth endpoint does some shenanigans and can not use the normal API handler +router.post('/auth/session/oauth', oauth); + 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'], diff --git a/packages/backend/src/server/api/openapi/gen-spec.ts b/packages/backend/src/server/api/openapi/gen-spec.ts index f9795884d..fbb8d3163 100644 --- a/packages/backend/src/server/api/openapi/gen-spec.ts +++ b/packages/backend/src/server/api/openapi/gen-spec.ts @@ -1,9 +1,13 @@ import config from '@/config/index.js'; +import { kinds } from '@/misc/api-permissions.js'; +import { I18n } from '@/misc/i18n.js'; import { errors as errorDefinitions } from '../error.js'; import endpoints from '../endpoints.js'; import { schemas, convertSchemaToOpenApiSchema } from './schemas.js'; import { httpCodes } from './http-codes.js'; +const i18n = new I18n('en-US'); + export function genOpenapiSpec() { const spec = { openapi: '3.0.0', @@ -34,10 +38,18 @@ export function genOpenapiSpec() { in: 'body', name: 'i', }, - // TODO: change this to oauth2 when the remaining oauth stuff is set up - Bearer: { - type: 'http', - scheme: 'bearer', + OAuth: { + type: 'oauth2', + flows: { + authorizationCode: { + authorizationUrl: `${config.url}/auth`, + tokenUrl: `${config.apiUrl}/auth/session/oauth`, + scopes: kinds.reduce((acc, kind) => { + acc[kind] = i18n.ts['_permissions'][kind]; + return acc; + }, {}), + }, + }, }, }, }, @@ -137,10 +149,16 @@ export function genOpenapiSpec() { { ApiKeyAuth: [], }, - { - Bearer: [], - }, ]; + if (endpoint.meta.kind) { + security.push({ + OAuth: [endpoint.meta.kind], + }); + } else { + security.push({ + OAuth: [], + }); + } if (!endpoint.meta.requireCredential) { // add this to make authentication optional security.push({}); diff --git a/packages/backend/src/server/api/private/signup.ts b/packages/backend/src/server/api/private/signup.ts index bb2a11437..e20fb9abb 100644 --- a/packages/backend/src/server/api/private/signup.ts +++ b/packages/backend/src/server/api/private/signup.ts @@ -1,11 +1,11 @@ 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 config from '@/config/index.js'; import { sendEmail } from '@/services/send-email.js'; +import { secureRndstr } from '@/misc/secure-rndstr.js'; import { genId } from '@/misc/gen-id.js'; import { validateEmailForAccount } from '@/services/validate-email-for-account.js'; import { signup } from '../common/signup.js'; @@ -69,7 +69,7 @@ export default async (ctx: Koa.Context) => { } if (instance.emailRequiredForSignup) { - const code = rndstr('a-z0-9', 16); + const code = secureRndstr(16); // Generate hash of password const salt = await bcrypt.genSalt(8); 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 c9b8e03b9..000000000 --- a/packages/backend/src/server/api/service/discord.ts +++ /dev/null @@ -1,294 +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 { I18n } from '@/misc/i18n.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 }); - const i18n = new I18n(profile.lang ?? 'en-US'); - - delete profile.integrations.discord; - - await UserProfiles.update(user.id, { - integrations: profile.integrations, - }); - - ctx.body = i18n.t('_services._discord.disconnected'); - - // 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 }) - .andWhere('"userHost" IS NULL') - .getOne(); - - if (profile == null) { - ctx.throw(404, `There were no FoundKey accounts linked to @${username}#${discriminator}...`); - return; - } - - await UserProfiles.update(profile.userId, { - integrations: { - ...profile.integrations, - discord: { - id, - accessToken, - refreshToken, - expiresDate, - username, - 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 }); - const i18n = new I18n(profile.lang ?? 'en-US'); - - await UserProfiles.update(user.id, { - integrations: { - ...profile.integrations, - discord: { - accessToken, - refreshToken, - expiresDate, - id, - username, - discriminator, - }, - }, - }); - - ctx.body = i18n.t('_services._discord.connected', { - username, - discriminator, - mkUsername: 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 5e78130e7..000000000 --- a/packages/backend/src/server/api/service/github.ts +++ /dev/null @@ -1,265 +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'; -import { I18n } from '@/misc/i18n.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 }); - const i18n = new I18n(profile.lang ?? 'en-US'); - - delete profile.integrations.github; - - await UserProfiles.update(user.id, { - integrations: profile.integrations, - }); - - ctx.body = i18n.t('_services._github.disconnected'); - - // 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 }) - .andWhere('"userHost" IS NULL') - .getOne(); - - if (link == null) { - ctx.throw(404, `There were no FoundKey accounts linked to @${login}...`); - 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 }); - const i18n = new I18n(profile.lang ?? 'en-US'); - - await UserProfiles.update(user.id, { - integrations: { - ...profile.integrations, - github: { - accessToken, - id, - login, - }, - }, - }); - - ctx.body = i18n.t('_services._github.connected', { - login, - userName: 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 59da0f6ba..000000000 --- a/packages/backend/src/server/api/service/twitter.ts +++ /dev/null @@ -1,207 +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 { redisClient } from '@/db/redis.js'; -import signin from '../common/signin.js'; -import { I18n } from '@/misc/i18n.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 }); - const i18n = new I18n(profile.lang ?? 'en-US'); - - delete profile.integrations.twitter; - - await UserProfiles.update(user.id, { - integrations: profile.integrations, - }); - - ctx.body = i18n.t('_services._twitter.disconnected'); - - // 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, `There were no FoundKey accounts linked to @${result.screenName}...`); - 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 }); - const i18n = new I18n(profile.lang ?? 'en-US'); - - await UserProfiles.update(user.id, { - integrations: { - ...profile.integrations, - twitter: { - accessToken: result.accessToken, - accessTokenSecret: result.accessTokenSecret, - userId: result.userId, - screenName: result.screenName, - }, - }, - }); - - ctx.body = i18n.t('_services._twitter.connected', { - twitterUserName: result.screenName, - userName: 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/nodeinfo.ts b/packages/backend/src/server/nodeinfo.ts index 34423bd56..eb1769722 100644 --- a/packages/backend/src/server/nodeinfo.ts +++ b/packages/backend/src/server/nodeinfo.ts @@ -97,9 +97,6 @@ const nodeinfo2 = async (): Promise => { enableHcaptcha: meta.enableHcaptcha, enableRecaptcha: meta.enableRecaptcha, maxNoteTextLength: config.maxNoteTextLength, - enableTwitterIntegration: meta.enableTwitterIntegration, - enableGithubIntegration: meta.enableGithubIntegration, - enableDiscordIntegration: meta.enableDiscordIntegration, enableEmail: meta.enableEmail, proxyAccountName: proxyAccount?.username ?? null, themeColor: meta.themeColor || '#86b300', @@ -107,11 +104,17 @@ const nodeinfo2 = async (): Promise => { }; }; +/* +Nodeinfo is cacheable for 1 day, the parts that change are the usage statistics +and those should not be time critical. +*/ +const cacheControl = 'public, max-age=86400'; + router.get(nodeinfo2_1path, async ctx => { const base = await nodeinfo2(); ctx.body = { version: '2.1', ...base }; - ctx.set('Cache-Control', 'public, max-age=600'); + ctx.set('Cache-Control', cacheControl); }); router.get(nodeinfo2_0path, async ctx => { @@ -120,7 +123,7 @@ router.get(nodeinfo2_0path, async ctx => { delete base.software.repository; ctx.body = { version: '2.0', ...base }; - ctx.set('Cache-Control', 'public, max-age=600'); + ctx.set('Cache-Control', cacheControl); }); export default router; diff --git a/packages/backend/src/server/oauth.ts b/packages/backend/src/server/oauth.ts new file mode 100644 index 000000000..65261ccc9 --- /dev/null +++ b/packages/backend/src/server/oauth.ts @@ -0,0 +1,16 @@ +import { kinds } from '@/misc/api-permissions.js'; +import config from '@/config/index.js'; + +// Since it cannot change while the server is running, we can serialize it once +// instead of having to serialize it every time it is requested. +export const oauthMeta = JSON.stringify({ + issuer: config.url, + authorization_endpoint: `${config.url}/auth`, + token_endpoint: `${config.apiUrl}/auth/session/oauth`, + scopes_supported: kinds, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code'], + token_endpoint_auth_methods_supported: ['client_secret_basic'], + service_documentation: `${config.url}/api-doc`, + code_challenge_methods_supported: ['S256'], +}); diff --git a/packages/backend/src/server/web/bios.css b/packages/backend/src/server/web/bios.css deleted file mode 100644 index b0da3ee39..000000000 --- a/packages/backend/src/server/web/bios.css +++ /dev/null @@ -1,40 +0,0 @@ -* { - font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace; -} - -html { - background: #ffb4e1; -} - -main { - background: #dedede; -} -main > .tabs { - padding: 16px; - border-bottom: solid 4px #c3c3c3; -} - -#lsEditor > .adder { - margin: 16px; - padding: 16px; - border: solid 2px #c3c3c3; -} -#lsEditor > .adder > textarea { - display: block; - width: 100%; - min-height: 5em; - box-sizing: border-box; -} -#lsEditor > .record { - padding: 16px; - border-bottom: solid 1px #c3c3c3; -} -#lsEditor > .record > header { - font-weight: bold; -} -#lsEditor > .record > textarea { - display: block; - width: 100%; - min-height: 5em; - box-sizing: border-box; -} diff --git a/packages/backend/src/server/web/bios.js b/packages/backend/src/server/web/bios.js deleted file mode 100644 index d06dee801..000000000 --- a/packages/backend/src/server/web/bios.js +++ /dev/null @@ -1,87 +0,0 @@ -'use strict'; - -window.onload = async () => { - const account = JSON.parse(localStorage.getItem('account')); - const i = account.token; - - const api = (endpoint, data = {}) => { - const promise = new Promise((resolve, reject) => { - // Append a credential - if (i) data.i = i; - - // Send request - fetch(endpoint.indexOf('://') > -1 ? endpoint : `/api/${endpoint}`, { - method: 'POST', - body: JSON.stringify(data), - credentials: 'omit', - cache: 'no-cache' - }).then(async (res) => { - const body = res.status === 204 ? null : await res.json(); - - if (res.status === 200) { - resolve(body); - } else if (res.status === 204) { - resolve(); - } else { - reject(body.error); - } - }).catch(reject); - }); - - return promise; - }; - - const content = document.getElementById('content'); - - document.getElementById('ls').addEventListener('click', () => { - content.innerHTML = ''; - - const lsEditor = document.createElement('div'); - lsEditor.id = 'lsEditor'; - - const adder = document.createElement('div'); - adder.classList.add('adder'); - const addKeyInput = document.createElement('input'); - const addValueTextarea = document.createElement('textarea'); - const addButton = document.createElement('button'); - addButton.textContent = 'add'; - addButton.addEventListener('click', () => { - localStorage.setItem(addKeyInput.value, addValueTextarea.value); - location.reload(); - }); - - adder.appendChild(addKeyInput); - adder.appendChild(addValueTextarea); - adder.appendChild(addButton); - lsEditor.appendChild(adder); - - for (let i = 0; i < localStorage.length; i++) { - const k = localStorage.key(i); - const record = document.createElement('div'); - record.classList.add('record'); - const header = document.createElement('header'); - header.textContent = k; - const textarea = document.createElement('textarea'); - textarea.textContent = localStorage.getItem(k); - const saveButton = document.createElement('button'); - saveButton.textContent = 'save'; - saveButton.addEventListener('click', () => { - localStorage.setItem(k, textarea.value); - location.reload(); - }); - const removeButton = document.createElement('button'); - removeButton.textContent = 'remove'; - removeButton.addEventListener('click', () => { - localStorage.removeItem(k); - location.reload(); - }); - record.appendChild(header); - record.appendChild(textarea); - record.appendChild(saveButton); - record.appendChild(removeButton); - lsEditor.appendChild(record); - } - - content.appendChild(lsEditor); - }); -}; diff --git a/packages/backend/src/server/web/cli.css b/packages/backend/src/server/web/cli.css deleted file mode 100644 index 07cd27830..000000000 --- a/packages/backend/src/server/web/cli.css +++ /dev/null @@ -1,19 +0,0 @@ -* { - font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace; -} - -html { - background: #ffb4e1; -} - -main { - background: #dedede; -} - -#tl > div { - padding: 16px; - border-bottom: solid 1px #c3c3c3; -} -#tl > div > header { - font-weight: bold; -} diff --git a/packages/backend/src/server/web/cli.js b/packages/backend/src/server/web/cli.js deleted file mode 100644 index 3dff1d486..000000000 --- a/packages/backend/src/server/web/cli.js +++ /dev/null @@ -1,55 +0,0 @@ -'use strict'; - -window.onload = async () => { - const account = JSON.parse(localStorage.getItem('account')); - const i = account.token; - - const api = (endpoint, data = {}) => { - const promise = new Promise((resolve, reject) => { - // Append a credential - if (i) data.i = i; - - // Send request - fetch(endpoint.indexOf('://') > -1 ? endpoint : `/api/${endpoint}`, { - method: 'POST', - body: JSON.stringify(data), - credentials: 'omit', - cache: 'no-cache' - }).then(async (res) => { - const body = res.status === 204 ? null : await res.json(); - - if (res.status === 200) { - resolve(body); - } else if (res.status === 204) { - resolve(); - } else { - reject(body.error); - } - }).catch(reject); - }); - - return promise; - }; - - document.getElementById('submit').addEventListener('click', () => { - api('notes/create', { - text: document.getElementById('text').value - }).then(() => { - location.reload(); - }); - }); - - api('notes/timeline').then(notes => { - const tl = document.getElementById('tl'); - for (const note of notes) { - const el = document.createElement('div'); - const name = document.createElement('header'); - name.textContent = `${note.user.name} @${note.user.username}`; - const text = document.createElement('div'); - text.textContent = `${note.text}`; - el.appendChild(name); - el.appendChild(text); - tl.appendChild(el); - } - }); -}; diff --git a/packages/backend/src/server/web/index.ts b/packages/backend/src/server/web/index.ts index e97b14d8d..1c33fde10 100644 --- a/packages/backend/src/server/web/index.ts +++ b/packages/backend/src/server/web/index.ts @@ -485,18 +485,6 @@ router.get('/_info_card_', async ctx => { }); }); -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('/'); diff --git a/packages/backend/src/server/web/manifest.ts b/packages/backend/src/server/web/manifest.ts index 3d01f31d5..de7d43903 100644 --- a/packages/backend/src/server/web/manifest.ts +++ b/packages/backend/src/server/web/manifest.ts @@ -3,9 +3,7 @@ import { fetchMeta } from '@/misc/fetch-meta.js'; import manifest from './manifest.json' assert { type: 'json' }; export const manifestHandler = async (ctx: Koa.Context): Promise => { - // TODO - //const res = structuredClone(manifest); - const res = JSON.parse(JSON.stringify(manifest)); + const res = structuredClone(manifest); const instance = await fetchMeta(true); diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug index d02031dfe..512babe08 100644 --- a/packages/backend/src/server/web/views/base.pug +++ b/packages/backend/src/server/web/views/base.pug @@ -31,9 +31,9 @@ html link(rel='icon' href= icon || '/favicon.ico') link(rel='apple-touch-icon' href= icon || '/apple-touch-icon.png') link(rel='manifest' href='/manifest.json') - link(rel='prefetch' href='https://xn--931a.moe/assets/info.jpg') - link(rel='prefetch' href='https://xn--931a.moe/assets/not-found.jpg') - link(rel='prefetch' href='https://xn--931a.moe/assets/error.jpg') + link(rel='prefetch' href=config.images.info) + link(rel='prefetch' href=config.images.notFound) + link(rel='prefetch' href=config.images.error) link(rel='stylesheet' href='/assets/fontawesome/css/all.css') link(rel='modulepreload' href=`/assets/${clientEntry.file}`) diff --git a/packages/backend/src/server/web/views/bios.pug b/packages/backend/src/server/web/views/bios.pug deleted file mode 100644 index 2abc80ce9..000000000 --- a/packages/backend/src/server/web/views/bios.pug +++ /dev/null @@ -1,20 +0,0 @@ -doctype html - -html - - head - meta(charset='utf-8') - meta(name='application-name' content='FoundKey') - title FoundKey Repair Tool - style - include ../bios.css - script - include ../bios.js - - body - header - h1 FoundKey Repair Tool #{version} - main - div.tabs - button#ls edit local storage - div#content diff --git a/packages/backend/src/server/web/views/cli.pug b/packages/backend/src/server/web/views/cli.pug deleted file mode 100644 index bc57efbc8..000000000 --- a/packages/backend/src/server/web/views/cli.pug +++ /dev/null @@ -1,21 +0,0 @@ -doctype html - -html - - head - meta(charset='utf-8') - meta(name='application-name' content='FoundKey') - title FoundKey Cli - style - include ../cli.css - script - include ../cli.js - - body - header - h1 FoundKey Cli #{version} - main - div#form - textarea#text - button#submit submit - div#tl diff --git a/packages/backend/src/server/well-known.ts b/packages/backend/src/server/well-known.ts index f4db66354..527aa99bc 100644 --- a/packages/backend/src/server/well-known.ts +++ b/packages/backend/src/server/well-known.ts @@ -7,6 +7,7 @@ import { escapeAttribute, escapeValue } from '@/prelude/xml.js'; import { Users } from '@/models/index.js'; import { User } from '@/models/entities/user.js'; import { links } from './nodeinfo.js'; +import { oauthMeta } from './oauth.js'; // Init router const router = new Router(); @@ -62,10 +63,21 @@ router.get('/.well-known/nodeinfo', async ctx => { ctx.body = { links }; }); -/* TODO -router.get('/.well-known/change-password', async ctx => { -}); -*/ +function oauth(ctx) { + ctx.body = oauthMeta; + ctx.type = 'application/json'; + ctx.set('Cache-Control', 'max-age=31536000, immutable'); +} + +// implements RFC 8414 +router.get('/.well-known/oauth-authorization-server', oauth); +// From the above RFC: +//> The identifiers "/.well-known/openid-configuration" [...] contain strings +//> referring to the OpenID Connect family of specifications [...]. Despite the reuse +//> of these identifiers that appear to be OpenID specific, their usage in this +//> specification is actually referring to general OAuth 2.0 features that are not +//> specific to OpenID Connect. +router.get('/.well-known/openid-configuration', oauth); router.get(webFingerPath, async ctx => { const fromId = (id: User['id']): FindOptionsWhere => ({ diff --git a/packages/backend/src/services/add-note-to-antenna.ts b/packages/backend/src/services/add-note-to-antenna.ts index 99bf1630d..d502be254 100644 --- a/packages/backend/src/services/add-note-to-antenna.ts +++ b/packages/backend/src/services/add-note-to-antenna.ts @@ -6,8 +6,8 @@ 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']; }) { - // 通知しない設定になっているか、自分自身の投稿なら既読にする +export async function addNoteToAntenna(antenna: Antenna, note: Note, noteUser: { id: User['id']; }): Promise { + // If it's set to not notify the user, or if it's the user's own post, read it. const read = !antenna.notify || (antenna.userId === noteUser.id); AntennaNotes.insert({ @@ -43,7 +43,7 @@ export async function addNoteToAntenna(antenna: Antenna, note: Note, noteUser: { return; } - // 2秒経っても既読にならなかったら通知 + // Notify if not read after 2 seconds setTimeout(async () => { const unread = await AntennaNotes.findOneBy({ antennaId: antenna.id, read: false }); if (unread) { diff --git a/packages/backend/src/services/create-notification.ts b/packages/backend/src/services/create-notification.ts index 6b04bb946..bbb4ab356 100644 --- a/packages/backend/src/services/create-notification.ts +++ b/packages/backend/src/services/create-notification.ts @@ -4,13 +4,12 @@ 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, -) { +): Promise { if (data.notifierId && (notifieeId === data.notifierId)) { return null; } @@ -25,7 +24,7 @@ export async function createNotification( createdAt: new Date(), notifieeId, type, - // 相手がこの通知をミュートしているようなら、既読を予めつけておく + // If the other party seems to have muted this notification, pre-read it. isRead: isMuted, ...data, } as Partial) @@ -36,13 +35,13 @@ export async function createNotification( // Publish notification event publishMainStream(notifieeId, 'notification', packed); - // 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する + // If the notification (created this time) has not been read after 2 seconds, issue a "You have unread notifications" event. setTimeout(async () => { const fresh = await Notifications.findOneBy({ id: notification.id }); - if (fresh == null) return; // 既に削除されているかもしれない + if (fresh == null) return; // It may have already been deleted. if (fresh.isRead) return; - //#region ただしミュートしているユーザーからの通知なら無視 + //#region However, if the notification comes from a muted user, ignore it. const mutings = await Mutings.findBy({ muterId: notifieeId, }); @@ -53,9 +52,6 @@ export async function createNotification( 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/delete-account.ts b/packages/backend/src/services/delete-account.ts index c1ab9e2be..ed3e381c4 100644 --- a/packages/backend/src/services/delete-account.ts +++ b/packages/backend/src/services/delete-account.ts @@ -7,7 +7,7 @@ export async function deleteAccount(user: { id: string; host: string | null; }): Promise { - // 物理削除する前にDelete activityを送信する + // Send Delete activity before physical deletion await doPostSuspend(user).catch(() => {}); createDeleteAccountJob(user, { diff --git a/packages/backend/src/services/fetch-instance-metadata.ts b/packages/backend/src/services/fetch-instance-metadata.ts index d7f551d93..ad312c3c8 100644 --- a/packages/backend/src/services/fetch-instance-metadata.ts +++ b/packages/backend/src/services/fetch-instance-metadata.ts @@ -81,6 +81,7 @@ type NodeInfo = { nodeName?: any; nodeDescription?: any; description?: any; + themeColor?: any; maintainer?: { name?: any; email?: any; @@ -125,7 +126,8 @@ async function fetchNodeinfo(instance: Instance): Promise { return info as NodeInfo; } catch (e) { - logger.error(`Failed to fetch nodeinfo of ${instance.host}: ${e.message}`); + const message = e instanceof Error ? e.message : e; + logger.error(`Failed to fetch nodeinfo of ${instance.host}: ${message}`); throw e; } diff --git a/packages/backend/src/services/insert-moderation-log.ts b/packages/backend/src/services/insert-moderation-log.ts index 821199962..6c225eb5b 100644 --- a/packages/backend/src/services/insert-moderation-log.ts +++ b/packages/backend/src/services/insert-moderation-log.ts @@ -2,7 +2,7 @@ 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) { +export async function insertModerationLog(moderator: { id: User['id'] }, type: string, info?: Record): Promise { await ModerationLogs.insert({ id: genId(), createdAt: new Date(), diff --git a/packages/backend/src/services/logger.ts b/packages/backend/src/services/logger.ts index bdae3f876..a3bfc8a0a 100644 --- a/packages/backend/src/services/logger.ts +++ b/packages/backend/src/services/logger.ts @@ -1,25 +1,35 @@ import cluster from 'node:cluster'; import chalk from 'chalk'; -import { default as convertColor } from 'color-convert'; +import convertColor from 'color-convert'; import { format as dateFormat } from 'date-fns'; import * as SyslogPro from 'syslog-pro'; import config from '@/config/index.js'; import { envOption } from '@/env.js'; +import type { KEYWORD } from 'color-convert/conversions.js'; type Domain = { name: string; - color?: string; + color?: KEYWORD; }; type Level = 'error' | 'success' | 'warning' | 'debug' | 'info'; +/** + * Class that facilitates recording log messages to the console and optionally a syslog server. + */ export default class Logger { private domain: Domain; private parentLogger: Logger | null = null; private store: boolean; - private syslogClient: any | null = null; + private syslogClient: SyslogPro.RFC5424 | null = null; - constructor(domain: string, color?: string, store = true) { + /** + * Create a logger instance. + * @param domain Logging domain + * @param color Log message color + * @param store Whether to store messages + */ + constructor(domain: string, color?: KEYWORD, store = true) { this.domain = { name: domain, color, @@ -41,7 +51,14 @@ export default class Logger { } } - public createSubLogger(domain: string, color?: string, store = true): Logger { + /** + * Create a child logger instance. + * @param domain Logging domain + * @param color Log message color + * @param store Whether to store messages + * @returns A Logger instance whose parent logger is this instance. + */ + public createSubLogger(domain: string, color?: KEYWORD, store = true): Logger { const logger = new Logger(domain, color, store); logger.parentLogger = this; return logger; @@ -57,22 +74,20 @@ export default class Logger { } const time = dateFormat(new Date(), 'HH:mm:ss'); - const worker = cluster.isPrimary ? '*' : cluster.worker.id; + const worker = cluster.isPrimary ? '*' : cluster.worker?.id; const l = level === 'error' ? important ? chalk.bgRed.white('ERR ') : chalk.red('ERR ') : level === 'warning' ? chalk.yellow('WARN') : level === 'success' ? important ? chalk.bgGreen.white('DONE') : chalk.green('DONE') : level === 'debug' ? chalk.gray('VERB') : - level === 'info' ? chalk.blue('INFO') : - null; + chalk.blue('INFO'); const domains = [this.domain].concat(subDomains).map(d => d.color ? chalk.rgb(...convertColor.keyword.rgb(d.color))(d.name) : chalk.white(d.name)); const m = level === 'error' ? chalk.red(message) : level === 'warning' ? chalk.yellow(message) : level === 'success' ? chalk.green(message) : level === 'debug' ? chalk.gray(message) : - level === 'info' ? message : - null; + message; let log = `${l} ${worker}\t[${domains.join(' ')}]\t${m}`; if (envOption.withLogTime) log = chalk.gray(time) + ' ' + log; @@ -84,42 +99,74 @@ export default class Logger { const send = level === 'error' ? this.syslogClient.error : level === 'warning' ? this.syslogClient.warning : - level === 'success' ? this.syslogClient.info : - level === 'debug' ? this.syslogClient.info : - level === 'info' ? this.syslogClient.info : - null as never; + this.syslogClient.info; send.bind(this.syslogClient)(message).catch(() => {}); } } } - public error(x: string | Error, data?: Record = {}, important = false): void { // 実行を継続できない状況で使う - if (x instanceof Error) { - 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); + /** + * Log an error message. + * Use in situations where execution cannot be continued. + * @param err Error or string containing an error message + * @param data Data relating to the error + * @param important Whether this error is important + */ + public error(err: string | Error, data: Record = {}, important = false): void { + if (err instanceof Error) { + data.e = err; + this.log('error', err.toString(), data, important); + } else if (typeof err === 'object') { + this.log('error', `${(err as any).message || (err as any).name || err}`, data, important); } else { - this.log('error', `${x}`, data, important); + this.log('error', `${err}`, data, important); } } - public warn(message: string, data?: Record | null, important = false): void { // 実行を継続できるが改善すべき状況で使う + /** + * Log a warning message. + * Use in situations where execution can continue but needs to be improved. + * @param message Warning message + * @param data Data relating to the warning + * @param important Whether this warning is important + */ + public warn(message: string, data?: Record | null, important = false): void { this.log('warning', message, data, important); } - public succ(message: string, data?: Record | null, important = false): void { // 何かに成功した状況で使う + /** + * Log a success message. + * Use in situations where something has been successfully done. + * @param message Success message + * @param data Data relating to the success + * @param important Whether this success message is important + */ + public succ(message: string, data?: Record | null, important = false): void { this.log('success', message, data, important); } - public debug(message: string, data?: Record | null, important = false): void { // デバッグ用に使う(開発者に必要だが利用者に不要な情報) + /** + * Log a debug message. + * Use for debugging (information needed by developers but not required by users). + * @param message Debug message + * @param data Data relating to the debug message + * @param important Whether this debug message is important + */ + public debug(message: string, data?: Record | null, important = false): void { if (process.env.NODE_ENV !== 'production' || envOption.verbose) { this.log('debug', message, data, important); } } - public info(message: string, data?: Record | null, important = false): void { // それ以外 + /** + * Log an informational message. + * Use when something needs to be logged but doesn't fit into other levels. + * @param message Info message + * @param data Data relating to the info message + * @param important Whether this info message is important + */ + public info(message: string, data?: Record | null, important = false): void { this.log('info', message, data, important); } } diff --git a/packages/backend/src/services/note/create.ts b/packages/backend/src/services/note/create.ts index e7dac0886..7ef25a620 100644 --- a/packages/backend/src/services/note/create.ts +++ b/packages/backend/src/services/note/create.ts @@ -3,7 +3,7 @@ import * as mfm from 'mfm-js'; import { db } from '@/db/postgre.js'; import es from '@/db/elasticsearch.js'; import { publishMainStream, publishNotesStream } from '@/services/stream.js'; -import DeliverManager from '@/remote/activitypub/deliver-manager.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'; @@ -98,7 +98,12 @@ class NotificationManager { const threadMuted = await NoteThreadMutings.findOneBy({ userId: x.target, - threadId: this.note.threadId || this.note.id, + threadId: In([ + // replies + this.note.threadId ?? this.note.id, + // renotes + this.note.renoteId ?? undefined + ]), mutingNotificationTypes: ArrayOverlap([x.reason]), }); @@ -266,7 +271,7 @@ export default async (user: { id: User['id']; username: User['username']; host: incNotesCountOfUser(user); // Word mute - mutedWordsCache.fetch(null).then(us => { + mutedWordsCache.fetch('').then(us => { for (const u of us) { checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => { if (shouldMute) { diff --git a/packages/backend/src/services/note/delete.ts b/packages/backend/src/services/note/delete.ts index af9ad350a..faa079e95 100644 --- a/packages/backend/src/services/note/delete.ts +++ b/packages/backend/src/services/note/delete.ts @@ -10,7 +10,7 @@ import { User, ILocalUser, IRemoteUser } from '@/models/entities/user.js'; import { Note } from '@/models/entities/note.js'; import { Notes, Users, Instances } from '@/models/index.js'; import { notesChart, perUserNotesChart, instanceChart } from '@/services/chart/index.js'; -import DeliverManager from '@/remote/activitypub/deliver-manager.js'; +import { DeliverManager } from '@/remote/activitypub/deliver-manager.js'; import { countSameRenotes } from '@/misc/count-same-renotes.js'; import { isPureRenote } from '@/misc/renote.js'; import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js'; diff --git a/packages/backend/src/services/note/polls/vote.ts b/packages/backend/src/services/note/polls/vote.ts index 5e27159e8..6e22af41f 100644 --- a/packages/backend/src/services/note/polls/vote.ts +++ b/packages/backend/src/services/note/polls/vote.ts @@ -68,7 +68,7 @@ export async function vote(user: CacheableUser, note: Note, choice: number): Pro createNotification(note.userId, 'pollVote', { notifierId: user.id, noteId: note.id, - choice: choice, + choice, }); } diff --git a/packages/backend/src/services/note/reaction/create.ts b/packages/backend/src/services/note/reaction/create.ts index df930c15c..d6e128137 100644 --- a/packages/backend/src/services/note/reaction/create.ts +++ b/packages/backend/src/services/note/reaction/create.ts @@ -1,7 +1,7 @@ import { ArrayOverlap, IsNull, Not } from 'typeorm'; import { publishNoteStream } from '@/services/stream.js'; import { renderLike } from '@/remote/activitypub/renderer/like.js'; -import DeliverManager from '@/remote/activitypub/deliver-manager.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'; diff --git a/packages/backend/src/services/note/reaction/delete.ts b/packages/backend/src/services/note/reaction/delete.ts index 3fc85a3d1..616a30015 100644 --- a/packages/backend/src/services/note/reaction/delete.ts +++ b/packages/backend/src/services/note/reaction/delete.ts @@ -2,7 +2,7 @@ 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 { 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'; diff --git a/packages/backend/src/services/push-notification.ts b/packages/backend/src/services/push-notification.ts index e258c217a..6607c05d9 100644 --- a/packages/backend/src/services/push-notification.ts +++ b/packages/backend/src/services/push-notification.ts @@ -16,7 +16,7 @@ type pushNotificationsTypes = { }; // Reduce the content of the push message because of the character limit -function truncateNotification(notification: Packed<'Notification'>): any { +function truncateNotification(notification: Packed<'Notification'>): Record { if (notification.note) { return { ...notification, @@ -37,7 +37,7 @@ function truncateNotification(notification: Packed<'Notification'>): any { return notification; } -export async function pushNotification(userId: string, type: T, body: pushNotificationsTypes[T]) { +export async function pushNotification(userId: string, type: T, body: pushNotificationsTypes[T]): Promise { const meta = await fetchMeta(); // Register key pair information diff --git a/packages/backend/src/services/relay.ts b/packages/backend/src/services/relay.ts index 36bf88b9c..b60bbd425 100644 --- a/packages/backend/src/services/relay.ts +++ b/packages/backend/src/services/relay.ts @@ -36,7 +36,7 @@ export async function getRelayActor(): Promise { return created as ILocalUser; } -export async function addRelay(inbox: string) { +export async function addRelay(inbox: string): Promise { const relay = await Relays.insert({ id: genId(), inbox, @@ -44,14 +44,14 @@ export async function addRelay(inbox: string) { }).then(x => Relays.findOneByOrFail(x.identifiers[0])); const relayActor = await getRelayActor(); - const follow = await renderFollowRelay(relay, relayActor); + const follow = renderFollowRelay(relay, relayActor); const activity = renderActivity(follow); deliver(relayActor, activity, relay.inbox); return relay; } -export async function removeRelay(inbox: string) { +export async function removeRelay(inbox: string): Promise { const relay = await Relays.findOneBy({ inbox, }); @@ -69,12 +69,12 @@ export async function removeRelay(inbox: string) { await Relays.delete(relay.id); } -export async function listRelay() { +export async function listRelay(): Promise { const relays = await Relays.find(); return relays; } -export async function relayAccepted(id: string) { +export async function relayAccepted(id: string): Promise { const result = await Relays.update(id, { status: 'accepted', }); @@ -82,7 +82,7 @@ export async function relayAccepted(id: string) { return JSON.stringify(result); } -export async function relayRejected(id: string) { +export async function relayRejected(id: string): Promise { const result = await Relays.update(id, { status: 'rejected', }); @@ -90,15 +90,13 @@ export async function relayRejected(id: string) { return JSON.stringify(result); } -export async function deliverToRelays(user: { id: User['id']; host: null; }, activity: any) { +export async function deliverToRelays(user: { id: User['id']; host: null; }, activity: any): Promise { if (activity == null) return; - const relays = await relaysCache.fetch(null); - if (relays.length === 0) return; + const relays = await relaysCache.fetch(''); + if (relays == null || relays.length === 0) return; - // TODO - //const copy = structuredClone(activity); - const copy = JSON.parse(JSON.stringify(activity)); + const copy = structuredClone(activity); if (!copy.to) copy.to = ['https://www.w3.org/ns/activitystreams#Public']; const signed = await attachLdSignature(copy, user); 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 cd3c670ac..000000000 --- a/packages/backend/src/services/send-email-notification.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { UserProfiles } from '@/models/index.js'; -import { User } from '@/models/entities/user.js'; -import { I18n } from '@/misc/i18n.js'; -import * as Acct from '@/misc/acct.js'; -import { sendEmail } from './send-email.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 i18n = new I18n(userProfile.lang ?? 'en-US'); - // 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 i18n = new I18n(userProfile.lang ?? 'en-US'); - // 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/suspend-user.ts b/packages/backend/src/services/suspend-user.ts index 80806cd3e..11e6266a0 100644 --- a/packages/backend/src/services/suspend-user.ts +++ b/packages/backend/src/services/suspend-user.ts @@ -1,10 +1,9 @@ -import { Not, IsNull } from 'typeorm'; import renderDelete from '@/remote/activitypub/renderer/delete.js'; import { renderActivity } from '@/remote/activitypub/renderer/index.js'; -import DeliverManager from '@/remote/activitypub/deliver-manager.js'; +import { DeliverManager } from '@/remote/activitypub/deliver-manager.js'; import config from '@/config/index.js'; import { User } from '@/models/entities/user.js'; -import { Users, Followings } from '@/models/index.js'; +import { Users } from '@/models/index.js'; import { publishInternalEvent } from '@/services/stream.js'; export async function doPostSuspend(user: { id: User['id']; host: User['host'] }): Promise { diff --git a/packages/backend/src/services/unsuspend-user.ts b/packages/backend/src/services/unsuspend-user.ts index 3ed7ce850..766b10f21 100644 --- a/packages/backend/src/services/unsuspend-user.ts +++ b/packages/backend/src/services/unsuspend-user.ts @@ -8,7 +8,7 @@ import { User } from '@/models/entities/user.js'; import { Users, Followings } from '@/models/index.js'; import { publishInternalEvent } from '@/services/stream.js'; -export async function doPostUnsuspend(user: User) { +export async function doPostUnsuspend(user: User): Promise { publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false }); if (Users.isLocalUser(user)) { diff --git a/packages/backend/src/services/update-hashtag.ts b/packages/backend/src/services/update-hashtag.ts index 9f549c3f4..7a77cb124 100644 --- a/packages/backend/src/services/update-hashtag.ts +++ b/packages/backend/src/services/update-hashtag.ts @@ -16,7 +16,7 @@ export async function updateUsertags(user: User, tags: string[]): Promise await updateHashtag(user, tag, true, true); } - for (const tag of (user.tags || []).filter(x => !tags.includes(x))) { + for (const tag of user.tags.filter(x => !tags.includes(x))) { await updateHashtag(user, tag, true, false); } } diff --git a/packages/backend/src/services/user-cache.ts b/packages/backend/src/services/user-cache.ts index f7f58dba5..1266ef5f0 100644 --- a/packages/backend/src/services/user-cache.ts +++ b/packages/backend/src/services/user-cache.ts @@ -1,19 +1,20 @@ -import { CacheableLocalUser, CacheableUser, ILocalUser } from '@/models/entities/user.js'; +import { IsNull } from 'typeorm'; +import { CacheableLocalUser, ILocalUser, User } from '@/models/entities/user.js'; import { Users } from '@/models/index.js'; import { Cache } from '@/misc/cache.js'; import { subscriber } from '@/db/redis.js'; -export const userByIdCache = new Cache( +export const userByIdCache = new Cache( Infinity, - (id) => Users.findOneBy({ id }).then(x => x ?? undefined), + async (id) => await Users.findOneBy({ id }) ?? undefined, ); export const localUserByNativeTokenCache = new Cache( Infinity, - (token) => Users.findOneBy({ token }).then(x => x ?? undefined), + async (token) => await Users.findOneBy({ token, host: IsNull() }) as ILocalUser | null ?? undefined, ); -export const uriPersonCache = new Cache( +export const uriPersonCache = new Cache( Infinity, - (uri) => Users.findOneBy({ uri }).then(x => x ?? undefined), + async (uri) => await Users.findOneBy({ uri }) ?? undefined, ); subscriber.on('message', async (_, data) => { @@ -29,7 +30,7 @@ subscriber.on('message', async (_, data) => { 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) { + if (v.value.id === user.id) { uriPersonCache.set(k, user); } } diff --git a/packages/backend/test/activitypub.ts b/packages/backend/test/activitypub.ts index f4ae27e5e..6ce9c7c8e 100644 --- a/packages/backend/test/activitypub.ts +++ b/packages/backend/test/activitypub.ts @@ -1,10 +1,27 @@ 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'; + +function rndstr(length): string { + const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + const chars_len = 62; + + let str = ''; + + for (let i = 0; i < length; i++) { + let rand = Math.floor(Math.random() * chars_len); + if (rand === chars_len) { + rand = chars_len - 1; + } + str += chars.charAt(rand); + } + + return str; +} + describe('ActivityPub', () => { before(async () => { //await initTestDb(); @@ -13,7 +30,7 @@ describe('ActivityPub', () => { describe('Parse minimum object', () => { const host = 'https://host1.test'; - const preferredUsername = `${rndstr('A-Z', 4)}${rndstr('a-z', 4)}`; + const preferredUsername = `${rndstr(8)}`; const actorId = `${host}/users/${preferredUsername.toLowerCase()}`; const actor = { @@ -27,7 +44,7 @@ describe('ActivityPub', () => { const post = { '@context': 'https://www.w3.org/ns/activitystreams', - id: `${host}/users/${rndstr('0-9a-z', 8)}`, + id: `${host}/users/${rndstr(8)}`, type: 'Note', attributedTo: actor.id, to: 'https://www.w3.org/ns/activitystreams#Public', @@ -66,10 +83,10 @@ describe('ActivityPub', () => { describe('Truncate long name', () => { const host = 'https://host1.test'; - const preferredUsername = `${rndstr('A-Z', 4)}${rndstr('a-z', 4)}`; + const preferredUsername = `${rndstr(8)}`; const actorId = `${host}/users/${preferredUsername.toLowerCase()}`; - const name = rndstr('0-9a-z', 129); + const name = rndstr(129); const actor = { '@context': 'https://www.w3.org/ns/activitystreams', diff --git a/packages/client/package.json b/packages/client/package.json index 0644e1da6..5ae21e151 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -5,7 +5,7 @@ "scripts": { "watch": "vite build --watch --mode development", "build": "vite build", - "lint": "eslint src --ext .ts,.vue" + "lint": "tsc --noEmit && eslint src --ext .ts,.vue" }, "dependencies": { "@discordapp/twemoji": "14.0.2", @@ -18,7 +18,6 @@ "abort-controller": "3.0.0", "autobind-decorator": "2.4.0", "autosize": "5.0.1", - "autwh": "0.1.0", "blurhash": "1.1.5", "broadcast-channel": "4.13.0", "browser-image-resizer": "2.4.1", @@ -51,7 +50,6 @@ "punycode": "2.1.1", "qrcode": "1.5.1", "reflect-metadata": "0.1.13", - "rndstr": "1.0.0", "rollup": "2.75.7", "sass": "1.53.0", "seedrandom": "3.0.5", @@ -66,7 +64,7 @@ "tsc-alias": "1.7.0", "tsconfig-paths": "4.1.0", "twemoji-parser": "14.0.0", - "typescript": "^4.9.3", + "typescript": "^4.9.4", "uuid": "8.3.2", "v-debounce": "0.1.2", "vanilla-tilt": "1.7.2", @@ -87,7 +85,6 @@ "@types/katex": "0.14.0", "@types/matter-js": "0.17.7", "@types/mocha": "9.1.1", - "@types/oauth": "0.9.1", "@types/punycode": "2.1.0", "@types/qrcode": "1.5.0", "@types/seedrandom": "3.0.2", @@ -96,11 +93,11 @@ "@types/uuid": "8.3.4", "@types/websocket": "1.0.5", "@types/ws": "8.5.3", - "@typescript-eslint/eslint-plugin": "^5.44.0", - "@typescript-eslint/parser": "^5.44.0", + "@typescript-eslint/eslint-plugin": "^5.46.1", + "@typescript-eslint/parser": "^5.46.1", "cross-env": "7.0.3", "cypress": "10.3.0", - "eslint": "^8.28.0", + "eslint": "^8.29.0", "eslint-plugin-import": "^2.26.0", "eslint-plugin-vue": "^9.8.0", "start-server-and-test": "1.14.0" diff --git a/packages/client/src/components/abuse-report-window.vue b/packages/client/src/components/abuse-report-window.vue index ccb07dff1..4ea98605c 100644 --- a/packages/client/src/components/abuse-report-window.vue +++ b/packages/client/src/components/abuse-report-window.vue @@ -10,10 +10,10 @@
- + - +
{{ i18n.ts.send }} @@ -26,7 +26,7 @@ import { ref } from 'vue'; import * as foundkey from 'foundkey-js'; import XWindow from '@/components/ui/window.vue'; -import MkTextarea from '@/components/form/textarea.vue'; +import FormTextarea from '@/components/form/textarea.vue'; import MkButton from '@/components/ui/button.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; diff --git a/packages/client/src/components/abuse-report.vue b/packages/client/src/components/abuse-report.vue index ac285a544..c723a7b63 100644 --- a/packages/client/src/components/abuse-report.vue +++ b/packages/client/src/components/abuse-report.vue @@ -32,10 +32,10 @@
- + {{ i18n.ts.forwardReport }} - + {{ i18n.ts.abuseMarkAsResolved }}
@@ -44,7 +44,7 @@ - - diff --git a/packages/client/src/components/form/switch.vue b/packages/client/src/components/form/switch.vue index 31ed71a91..ebd0b7aae 100644 --- a/packages/client/src/components/form/switch.vue +++ b/packages/client/src/components/form/switch.vue @@ -13,7 +13,7 @@
- +

@@ -50,6 +50,7 @@ const toggle = () => { position: relative; display: flex; transition: all 0.2s ease; + margin: var(--margin) 0; > * { user-select: none; diff --git a/packages/client/src/components/global/error.vue b/packages/client/src/components/global/error.vue index 740404a3d..74420145a 100644 --- a/packages/client/src/components/global/error.vue +++ b/packages/client/src/components/global/error.vue @@ -1,7 +1,7 @@