diff --git a/.config/docker_example.yml b/.config/docker_example.yml index d93cc8b70e..af0a90dc95 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -51,6 +51,23 @@ db: #extra: # ssl: true +dbReplications: false + +# You can configure any number of replicas here +#dbSlaves: +# - +# host: +# port: +# db: +# user: +# pass: +# - +# host: +# port: +# db: +# user: +# pass: + # ┌─────────────────────┐ #───┘ Redis configuration └───────────────────────────────────── diff --git a/.config/example.yml b/.config/example.yml index b61ed14809..57e2b56b78 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -51,6 +51,23 @@ db: #extra: # ssl: true +dbReplications: false + +# You can configure any number of replicas here +#dbSlaves: +# - +# host: +# port: +# db: +# user: +# pass: +# - +# host: +# port: +# db: +# user: +# pass: + # ┌─────────────────────┐ #───┘ Redis configuration └───────────────────────────────────── diff --git a/.devcontainer/devcontainer.yml b/.devcontainer/devcontainer.yml index 1350e70157..2af306e3da 100644 --- a/.devcontainer/devcontainer.yml +++ b/.devcontainer/devcontainer.yml @@ -51,6 +51,23 @@ db: #extra: # ssl: true +dbReplications: false + +# You can configure any number of replicas here +#dbSlaves: +# - +# host: +# port: +# db: +# user: +# pass: +# - +# host: +# port: +# db: +# user: +# pass: + # ┌─────────────────────┐ #───┘ Redis configuration └───────────────────────────────────── diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index 3a62bf2378..6792674d9f 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -44,7 +44,11 @@ jobs: if: github.ref != 'refs/heads/master' id: chromatic run: | - CHROMATIC_PARAMETER="$(node packages/frontend/.storybook/changes.js $(git diff-tree --no-commit-id --name-only -r ${{ github.event.before }} HEAD | xargs))" + DIFF="${{ github.event.before }} HEAD" + if [ "$DIFF" = "0000000000000000000000000000000000000000 HEAD" ]; then + DIFF="HEAD" + fi + CHROMATIC_PARAMETER="$(node packages/frontend/.storybook/changes.js $(git diff-tree --no-commit-id --name-only -r $(echo "$DIFF") | xargs))" if [ "$CHROMATIC_PARAMETER" = " --skip" ]; then echo "skip=true" >> $GITHUB_OUTPUT fi diff --git a/CHANGELOG.md b/CHANGELOG.md index e701d84bbf..5e6fa2cf4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,37 +5,60 @@ - ### Client -- +- ### Server - --> -## 13.x.x (unreleased) +## 13.11.1 + +### General +- チャンネルの投稿を過去までさかのぼれるように + +### Client +- PWA時の絵文字ピッカーの位置をホームバーに重ならないように調整 +- リスト管理の画面でリストが無限に読み込まれる問題を修正 +- 自分のクリップが無限に読み込まれる問題を修正 +- チャンネルのお気に入りが無限に読み込まれる問題を修正 +- さがすのローカルユーザー(ピンどめ)が無限に生成される問題を修正 +- チャンネルを新規作成できない問題を修正 +- ユーザープレビューが表示されない問題を修正 + +### Server +- 通知読み込みでエラーが発生する場合がある問題を修正 +- リアクションできないことがある問題を修正 +- IDをaid以外に設定している場合の問題を修正 +- 連合しているインスタンスについて予期せず配送が全て停止されることがある問題を修正 + +## 13.11.0 ### NOTE - このバージョンからRedis 7.xが必要です。 -- アップデートを行うと全ての通知はリセットされます。 +- アップデートを行うと全ての通知およびアンテナのノートはリセットされます。 ### General - チャンネルをお気に入りに登録できるように + - タイムラインのアンテナ選択などでは、フォローしているアンテナの代わりにお気に入りしたアンテナが表示されるようになっています。チャンネルをお気に入りに登録するには、当該チャンネルのページ→概要→⭐️のボタンを押します。 - チャンネルにノートをピン留めできるように ### Client +- 投稿フォームのデザインを改善 - 検索ページでURLを入力した際に照会したときと同等の挙動をするように - ノートのリアクションを大きく表示するオプションを追加 - ギャラリー一覧にメディア表示と同じように NSFW 設定を反映するように(ホバーで表示) - オブジェクトストレージの設定画面を分かりやすく +- 広告・お知らせが新規登録時に増殖しないように - 「にゃああああああああああああああ!!!!!!!!!!!!」 (`isCat`) 有効時にアバターに表示される猫耳について挙動を変更 - 「UIにぼかし効果を使用」 (`useBlurEffect`) で次の挙動が有効になります - 猫耳のアバター内部部分をぼかしでマスク表示してより猫耳っぽく見えるように - - 猫耳の色がアバター上部のピクセルから決定されます(無効化時はアバター全体の平均色) - - 左耳は上からおよそ 10%, 左からおよそ 20% の位置で決定します - - 右耳は上からおよそ 10%, 左からおよそ 80% の位置で決定します - 「UIのアニメーションを減らす」 (`reduceAnimation`) で猫耳を撫でられなくなります +- Add Minimizing ("folding") of windows ### Server +- PostgreSQLのレプリケーション対応 + - 設定ファイルの `dbReplications` および `dbSlaves` にて設定できます - イベント用Redisを別サーバーに分離できるように - ジョブキュー用Redisを別サーバーに分離できるように - サーバーの全体的なパフォーマンスを向上 diff --git a/README.md b/README.md index fdc8c1d8d3..2aae4bb865 100644 --- a/README.md +++ b/README.md @@ -61,10 +61,18 @@ Misskey Documentation can be found at [Misskey Hub](https://misskey-hub.net/), s ## Thanks -Chromatic +Chromatic Thanks to [Chromatic](https://www.chromatic.com/) for providing the visual testing platform that helps us review UI changes and catch visual regressions. -Docker +Codecov + +Thanks to [Codecov](https://about.codecov.io/for/open-source/) for providing the code coverage platform that helps us improve our test coverage. + +Crowdin + +Thanks to [Crowdin](https://crowdin.com/) for providing the localization platform that helps us translate Misskey into many languages. + +Docker Thanks to [Docker](https://hub.docker.com/) for providing the container platform that helps us run Misskey in production. diff --git a/chart/files/default.yml b/chart/files/default.yml index 1d8e5b490a..1888669245 100644 --- a/chart/files/default.yml +++ b/chart/files/default.yml @@ -72,6 +72,23 @@ db: #extra: # ssl: true +dbReplications: false + +# You can configure any number of replicas here +#dbSlaves: +# - +# host: +# port: +# db: +# user: +# pass: +# - +# host: +# port: +# db: +# user: +# pass: + # ┌─────────────────────┐ #───┘ Redis configuration └───────────────────────────────────── diff --git a/locales/ar-SA.yml b/locales/ar-SA.yml index c2910b90cd..4b9062051b 100644 --- a/locales/ar-SA.yml +++ b/locales/ar-SA.yml @@ -1277,4 +1277,3 @@ _deck: _webhookSettings: name: "الإسم" active: "مفعّل" - diff --git a/locales/bn-BD.yml b/locales/bn-BD.yml index 40af5a3326..734943960c 100644 --- a/locales/bn-BD.yml +++ b/locales/bn-BD.yml @@ -1356,4 +1356,3 @@ _deck: _webhookSettings: name: "নাম" active: "চালু" - diff --git a/locales/ca-ES.yml b/locales/ca-ES.yml index bc9e662493..2b1168f780 100644 --- a/locales/ca-ES.yml +++ b/locales/ca-ES.yml @@ -460,4 +460,3 @@ _deck: list: "Llistes" mentions: "Mencions" direct: "Publicacions directes" - diff --git a/locales/cs-CZ.yml b/locales/cs-CZ.yml index 4b59192474..19815c6f37 100644 --- a/locales/cs-CZ.yml +++ b/locales/cs-CZ.yml @@ -779,4 +779,3 @@ _deck: _webhookSettings: name: "Jméno" active: "Zapnuto" - diff --git a/locales/da-DK.yml b/locales/da-DK.yml index d1fbec9f67..08c15ed092 100644 --- a/locales/da-DK.yml +++ b/locales/da-DK.yml @@ -1,3 +1,2 @@ --- _lang_: "Dansk" - diff --git a/locales/de-DE.yml b/locales/de-DE.yml index df01f39b3f..ef1cea6e72 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -67,7 +67,7 @@ import: "Import" export: "Export" files: "Dateien" download: "Herunterladen" -driveFileDeleteConfirm: "Möchtest du die Datei „{name}“ wirklich löschen? Notizen mit dieser Datei werden ebenso verschwinden." +driveFileDeleteConfirm: "Möchtest du die Datei „{name}“ wirklich löschen? Sie wird in allen Inhalten, die sie verwenden, auch verschwinden." unfollowConfirm: "Möchtest du {name} wirklich nicht mehr folgen?" exportRequested: "Du hast einen Export angefragt. Dies kann etwas Zeit in Anspruch nehmen. Sobald der Export abgeschlossen ist, wird er deiner Drive hinzugefügt." importRequested: "Du hast einen Import angefragt. Dies kann etwas Zeit in Anspruch nehmen." @@ -196,7 +196,7 @@ instanceInfo: "Instanzinformationen" statistics: "Statistiken" clearQueue: "Warteschlange leeren" clearQueueConfirmTitle: "Möchtest du die Warteschlange wirklich leeren?" -clearQueueConfirmText: "Hierdurch werden jegliche noch nicht gesendete Notizen nicht förderiert. Normalerweise wird dies nicht benötigt." +clearQueueConfirmText: "Hierdurch werden jegliche noch nicht gesendete Notizen nicht föderiert. Normalerweise wird dies nicht benötigt." clearCachedFiles: "Cache leeren" clearCachedFilesConfirm: "Sollen alle im Cache gespeicherten Dateien von anderen Instanzen wirklich gelöscht werden?" blockedInstances: "Blockierte Instanzen" @@ -500,7 +500,7 @@ objectStoragePrefixDesc: "Dateien werden in Ordnern unter diesem Prefix gespeich objectStorageEndpoint: "Endpoint" objectStorageEndpointDesc: "Im Falle von S3 leerlassen, für andere Anbieter den relevanten Endpoint im Format „“ oder „:“ angeben." objectStorageRegion: "Region" -objectStorageRegionDesc: "Gib eine Region wie z.B. „xx-east-1“ an. Falls dein Anbieter nicht zwischen Regionen unterscheidet, lass dieses Feld leer oder gib „us-east-1“ an." +objectStorageRegionDesc: "Gib eine Region wie z.B. „xx-east-1“ an. Falls dein Anbieter nicht zwischen Regionen unterscheidet, gib „us-east-1“ an. Lasse es leer bei Verwendung von AWS Konfigurationsdateien oder Umgebungsvariablen." objectStorageUseSSL: "SSL verwenden" objectStorageUseSSLDesc: "Deaktiviere dies, falls du für API-Verbindungen kein HTTPS verwenden wirst" objectStorageUseProxy: "Über Proxy verbinden" @@ -920,6 +920,7 @@ pushNotificationNotSupported: "Entweder dein Browser oder deine Instanz unterst sendPushNotificationReadMessage: "Push-Benachrichtigungen löschen, sobald die relevanten Benachrichtigungen oder Nachrichten gelesen wurden" sendPushNotificationReadMessageCaption: "Eine Push-Benachrichtigungen mit dem Inhalt \"{emptyPushNotificationMessage}\" wird kurz eingeblendet. Dies kann gegebenenfalls den Batterieverbrauch deines Gerätes erhöhen." windowMaximize: "Maximieren" +windowMinimize: "Minimieren" windowRestore: "Wiederherstellen" caption: "Beschreibung" loggedInAsBot: "Momentan als Bot angemeldet" @@ -988,6 +989,16 @@ enableChartsForFederatedInstances: "Diagramme für fremde Instanzen erstellen" showClipButtonInNoteFooter: "\"Clip\" zum Notizmenu hinzufügen" largeNoteReactions: "Reaktionen vergrößert anzeigen" noteIdOrUrl: "Notiz-ID oder URL" +accountMigration: "Konto-Umzug" +accountMoved: "Dieser Benutzer ist zu einem neuen Konto umgezogen:" +_accountMigration: + moveTo: "Dieses Konto zu einem neuen umziehen" + moveToLabel: "Umzugsziel:" + moveAccountDescription: "Ein Umzug kann nicht rückgängig gemacht werden. Stelle zuerst sicher, dass du auf dem Umzugsziel einen Alias erstellt hast. Gib dann das Umzugsziel in folgendem Format ein: @person@instance.com" + moveFrom: "Von einem anderen Konto zu diesem umziehen" + moveFromLabel: "Umzugsursprung:" + moveFromDescription: "Stelle sicher, dass du auf dem Umzugsursprungskonto einen Alias zu diesem Konto erstellt hast, falls du die Follower des Ursprungskontos übertragen möchtest. Dies muss vor dem Umzug geschehen! Gib dann das Ursprungskonto in folgendem Format an: @person@instance.com" + migrationConfirm: "Dieses Konto wirklich zu {account} umziehen? Sobald der Umzug beginnt, kann er nicht rückgängig gemacht werden, und dieses Konto nicht wieder im ursprünglichen Zustand verwendet werden.\n\nÜberprüfe zusätzlich, dass du auf dem Umzugsziel einen Alias eingerichtet hast." _achievements: earnedAt: "Freigeschaltet am" _types: @@ -1260,7 +1271,7 @@ _role: gtlAvailable: "Kann auf die globale Chronik zugreifen" ltlAvailable: "Kann auf die lokale Chronik zugreifen" canPublicNote: "Kann öffentliche Notizen erstellen" - canInvite: "Kann Einladungscodes für diese Instanz erstellen" + canInvite: "Erstellung von Einladungscodes für diese Instanz" canManageCustomEmojis: "Benutzerdefinierte Emojis verwalten" driveCapacity: "Drive-Kapazität" pinMax: "Maximale Anzahl an angehefteten Notizen" @@ -1413,7 +1424,7 @@ _instanceMute: instanceMuteDescription: "Schaltet alle Notizen/Renotes stumm, die von den gelisteten Instanzen stammen, inklusive Antworten von Benutzern an einen Benutzer einer stummgeschalteten Instanz." instanceMuteDescription2: "Instanzen getrennt durch Zeilenumbrüchen angeben" title: "Blendet Notizen von stummgeschalteten Instanzen aus." - heading: "Liste der stummzuschaltenden Instanzen" + heading: "Stummzuschaltende Instanzen" _theme: explore: "Farbschemata erforschen" install: "Farbschemata installieren" @@ -1685,7 +1696,7 @@ _visibility: followersDescription: "Nur für Follower sichtbar" specified: "Direkt" specifiedDescription: "Nur für bestimmte Benutzer sichtbar" - disableFederation: "Deförderiert" + disableFederation: "Deföderieren" disableFederationDescription: "Nicht an andere Instanzen übertragen" _postForm: replyPlaceholder: "Dieser Notiz antworten …" @@ -1898,4 +1909,3 @@ _webhookSettings: renote: "Wenn du ein Renote erhältst" reaction: "Wenn du eine Reaktion erhältst" mention: "Wenn du erwähnt wirst" - diff --git a/locales/el-GR.yml b/locales/el-GR.yml index 634e36c29e..32964e9982 100644 --- a/locales/el-GR.yml +++ b/locales/el-GR.yml @@ -394,4 +394,3 @@ _deck: mentions: "Επισημάνσεις" _webhookSettings: name: "Όνομα" - diff --git a/locales/en-US.yml b/locales/en-US.yml index 0a6272ee47..1137199739 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -920,6 +920,7 @@ pushNotificationNotSupported: "Your browser or instance does not support push no sendPushNotificationReadMessage: "Delete push notifications once the relevant notifications or messages have been read" sendPushNotificationReadMessageCaption: "A notification containing the text \"{emptyPushNotificationMessage}\" will be displayed for a short time. This may increase the battery usage of your device, if applicable." windowMaximize: "Maximize" +windowMinimize: "Minimize" windowRestore: "Restore" caption: "Caption" loggedInAsBot: "Currently logged in as bot" @@ -988,6 +989,16 @@ enableChartsForFederatedInstances: "Generate remote instance data charts" showClipButtonInNoteFooter: "Add \"Clip\" to note action menu" largeNoteReactions: "Enlargen displayed reactions" noteIdOrUrl: "Note ID or URL" +accountMigration: "Account Migration" +accountMoved: "This user has moved to a new account:" +_accountMigration: + moveTo: "Migrate this account to a different one" + moveToLabel: "Account to move to:" + moveAccountDescription: "This action is irreversible. First, create an alias for this account on the account you wish to move to. Then, enter the account to move to in the following format: @person@instance.com" + moveFrom: "Migrate another account to this one" + moveFromLabel: "Account to move from:" + moveFromDescription: "Create an alias for the account to move from on this account if you wish to transfer its followers. This has to be done before the transfer! Then, enter the account to move to in the following format: @person@instance.com" + migrationConfirm: "Really migrate this account to {account}? Once started, this process cannot be stopped or taken back, and you will not be able to use this account in its original state anymore.\n\nAlso, confirm you've created an alias at the account to migrate to." _achievements: earnedAt: "Unlocked at" _types: @@ -1685,7 +1696,7 @@ _visibility: followersDescription: "Make visible to your followers only" specified: "Direct" specifiedDescription: "Make visible for specified users only" - disableFederation: "Unfederated" + disableFederation: "Defederate" disableFederationDescription: "Don't transmit to other instances" _postForm: replyPlaceholder: "Reply to this note..." @@ -1898,4 +1909,3 @@ _webhookSettings: renote: "When renoted" reaction: "When receiving a reaction" mention: "When being mentioned" - diff --git a/locales/es-ES.yml b/locales/es-ES.yml index 8649101741..29cf9b3ec9 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -506,6 +506,7 @@ objectStorageUseSSLDesc: "Desactive esto si no va a usar HTTPS para la conexión objectStorageUseProxy: "Conectarse a través de Proxy" objectStorageUseProxyDesc: "Desactive esto si no va a usar Proxy para la conexión de Almacenamiento de objetos" objectStorageSetPublicRead: "Seleccionar \"public-read\" al subir " +s3ForcePathStyleDesc: "Si s3ForcePathStyle esta habilitado el nombre del bucket debe ser especificado como parte de la URL en lugar del nombre de host en la URL. Puede ser necesario activar esta opción cuando se utilice, por ejemplo, Minio en un servidor propio." serverLogs: "Registros del servidor" deleteAll: "Eliminar todos" showFixedPostForm: "Mostrar el formulario de las entradas encima de la línea de tiempo" @@ -919,6 +920,7 @@ pushNotificationNotSupported: "El navegador o la instancia no admiten notificaci sendPushNotificationReadMessage: "Eliminar las notificaciones push después de leer las notificaciones y los mensajes" sendPushNotificationReadMessageCaption: "La notificación \"{emptyPushNotificationMessage}\" aparecerá momentáneamente. Esto puede aumentar el consumo de batería del dispositivo." windowMaximize: "Maximizar" +windowMinimize: "Minimizar" windowRestore: "Regresar" caption: "Pie de foto" loggedInAsBot: "Inicio sesión como cuenta bot." @@ -960,6 +962,9 @@ copyErrorInfo: "Copiar detalles del error" joinThisServer: "Registrarse en esta instancia" exploreOtherServers: "Buscar otra instancia" letsLookAtTimeline: "Mirar la línea de tiempo local" +disableFederationConfirm: "¿Estas seguro que quieres desactivar la federación?" +disableFederationConfirmWarn: "Aunque no exista federación los posts no serán marcados como privados. En la mayoría de los casos, no es necesario hacer los posts no federar." +disableFederationOk: "Desactivar." invitationRequiredToRegister: "Esta instancia está configurada sólo por invitación, tienes que ingresar un código de invitación válido." emailNotSupported: "Esta instancia no soporta el envío de correo electrónico" postToTheChannel: "Publicar en el canal" @@ -979,6 +984,21 @@ drivecleaner: "Limpiador del Drive" retryAllQueuesNow: "Reintentar inmediatamente todas las colas" retryAllQueuesConfirmTitle: "Desea ¿reintentar inmediatamente todas las colas?" retryAllQueuesConfirmText: "La carga del servidor está incrementándose temporalmente " +enableChartsForRemoteUser: "Generar gráficas de usuarios remotos." +enableChartsForFederatedInstances: "Generar gráficos de servidores remotos" +showClipButtonInNoteFooter: "Añadir \"Clip\" al menú de notas" +largeNoteReactions: "Agrandar las reacciones de las notas" +noteIdOrUrl: "ID o URL de la nota" +accountMigration: "Migración de cuenta" +accountMoved: "Este usuario se ha mudado a una nueva cuenta:" +_accountMigration: + moveTo: "Mover esta cuenta a una nueva" + moveToLabel: "Cuenta destino:" + moveAccountDescription: "Esta operación no puede deshacerse. En primer lugar, asegúrese de haber creado un alias para esta cuenta en la cuenta a la que se va a trasladar. Después de crear el alias, introduzca la cuenta a la que se está trasladando de la siguiente manera: @person@instance.com" + moveFrom: "Trasladar de otra cuenta a ésta" + moveFromLabel: "Cuenta desde la que se realiza el traslado:" + moveFromDescription: "Si quieres transferir seguidores de otra cuenta a esta cuenta y trasladarlos, tendrás que crear un alias aquí. Asegúrate de crearlo antes de realizar el traslado. Introduce la cuenta desde la que estás moviendo los seguidores así: @person@instance.com" + migrationConfirm: "¿Estás seguro de que quieres mover esta cuenta a {account}? Una vez trasladada, no podrás deshacer el traslado y no podrás volver a utilizar la cuenta original.\n\nAdemás, compruebe que ha configurado un alias en el destino del traslado." _achievements: earnedAt: "Desbloqueado el" _types: @@ -1275,6 +1295,8 @@ _role: followersMoreThanOrEq: "Tiene X o más seguidores" followingLessThanOrEq: "Sigue X o menos cuentas" followingMoreThanOrEq: "Sigue X o más cuentas" + notesLessThanOrEq: "El número de notas es inferior o igual a" + notesMoreThanOrEq: "El número de notas es superior o igual a" and: "Condicional AND" or: "Condicional OR" not: "Condicional NOT" @@ -1874,6 +1896,16 @@ _drivecleaner: orderBySizeDesc: "Más grandes" orderByCreatedAtAsc: "Más antiguos" _webhookSettings: + createWebhook: "Crear Webhook" name: "Nombre" + secret: "Secreto" + events: "Eventos de webhook" active: "Activado" - + _events: + follow: "Cuando se sigue a alguien" + followed: "Cuando se es seguido" + note: "Cuando se publica una nota" + reply: "Cuando se recibe una respuesta" + renote: "Cuando reciba un \"re-note\"" + reaction: "Cuando se recibe una reacción" + mention: "Cuando hay una mención" diff --git a/locales/fr-FR.yml b/locales/fr-FR.yml index 11573e0ce0..e0767fdc09 100644 --- a/locales/fr-FR.yml +++ b/locales/fr-FR.yml @@ -1470,4 +1470,3 @@ _deck: _webhookSettings: name: "Nom" active: "Activé" - diff --git a/locales/hr-HR.yml b/locales/hr-HR.yml index cd21505a47..ed97d539c0 100644 --- a/locales/hr-HR.yml +++ b/locales/hr-HR.yml @@ -1,2 +1 @@ --- - diff --git a/locales/ht-HT.yml b/locales/ht-HT.yml index cd21505a47..ed97d539c0 100644 --- a/locales/ht-HT.yml +++ b/locales/ht-HT.yml @@ -1,2 +1 @@ --- - diff --git a/locales/id-ID.yml b/locales/id-ID.yml index e5a057477a..1272da9b3e 100644 --- a/locales/id-ID.yml +++ b/locales/id-ID.yml @@ -1806,4 +1806,3 @@ _deck: _webhookSettings: name: "Nama" active: "Aktif" - diff --git a/locales/it-IT.yml b/locales/it-IT.yml index 41865d7bf1..f9b65488bb 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -170,7 +170,7 @@ proxyAccountDescription: "Un profilo proxy funziona come follower per i profili host: "Server remoto" selectUser: "Seleziona profilo" recipient: "Destinatario" -annotation: "Annotazione" +annotation: "Annotazione preventiva" federation: "Federazione" instances: "Istanza" registeredAt: "Registrato presso" @@ -506,6 +506,7 @@ objectStorageUseSSLDesc: "Disabilita quest'opzione se non utilizzi HTTPS per le objectStorageUseProxy: "Usa proxy" objectStorageUseProxyDesc: "Disabilita quest'opzione se non usi proxy per la connessione API." objectStorageSetPublicRead: "Imposta \"visibilità pubblica\" al momento di caricare" +s3ForcePathStyleDesc: "L'attivazione di s3ForcePathStyle impone di specificare il nome del bucket come parte del percorso nell'URL anziché del nome host. Potrebbe tornare utile quando si utilizzano applicazioni come Minio." serverLogs: "Log del server" deleteAll: "Cancella cronologia" showFixedPostForm: "Visualizzare la finestra di pubblicazione in cima alla timeline" @@ -564,7 +565,7 @@ invisibleNote: "Nota invisibile" enableInfiniteScroll: "Abilita scorrimento infinito" visibility: "Visibilità" poll: "Sondaggio" -useCw: "Nascondere media" +useCw: "Content Warning" enablePlayer: "Visualizza" disablePlayer: "Chiudi" expandTweet: "Espandi tweet" @@ -579,7 +580,7 @@ plugins: "Estensioni" preferencesBackups: "Backup delle impostazioni" deck: "Deck" undeck: "Esci dal deck" -useBlurEffectForModal: "Utilizza effetto sfocatura per i modali" +useBlurEffectForModal: "Utilizza effetto sfocatura per le finestre modali" useFullReactionPicker: "Usa la totalità del pannello di reazioni" width: "Larghezza" height: "Altezza" @@ -814,7 +815,7 @@ translatedFrom: "Tradotto da {x}" accountDeletionInProgress: "È in corso l'eliminazione del profilo" usernameInfo: "Un nome per identificare univocamente il tuo profilo sull'istanza. Puoi utilizzare caratteri alfanumerici maiuscoli, minuscoli e il trattino basso (_). Non potrai cambiare nome utente in seguito." aiChanMode: "Modalità Ai" -keepCw: "Mantieni il CW" +keepCw: "Mantieni il Content Warning" pubSub: "Publish/Subscribe del profilo" lastCommunication: "La comunicazione più recente" resolved: "Risolto" @@ -919,6 +920,7 @@ pushNotificationNotSupported: "Il client o il server non supporta le notifiche p sendPushNotificationReadMessage: "Elimina le notifiche push dopo la relativa lettura" sendPushNotificationReadMessageCaption: "Se possibile, verrà mostrata brevemente una notifica con il testo \"{emptyPushNotificationMessage}\". Potrebbe influire negativamente sulla durata della batteria." windowMaximize: "Ingrandisci" +windowMinimize: "Contrai finestra" windowRestore: "Ripristina" caption: "Didascalia" loggedInAsBot: "Connessione come Bot" @@ -960,6 +962,9 @@ copyErrorInfo: "Copia le informazioni sull'errore" joinThisServer: "Registrati su questa istanza" exploreOtherServers: "Trova altre istanze" letsLookAtTimeline: "Sbircia la timeline" +disableFederationConfirm: "Vuoi davvero disattivare la federazione?" +disableFederationConfirmWarn: "Anche se defederate, le Note continueranno ad essere pubbliche, se non diversamente specificato. Di solito, non è necessario far questo." +disableFederationOk: "Disabilita federazione" invitationRequiredToRegister: "L'accesso a questa istanza è solo ad invito. Può registrarsi solo chi ha un codice fornito dall'amministrazione." emailNotSupported: "L'istanza non supporta l'invio di email" postToTheChannel: "Pubblica nel canale" @@ -984,6 +989,16 @@ enableChartsForFederatedInstances: "Abilita i grafici per le istanze federate" showClipButtonInNoteFooter: "Aggiungi il bottone Clip tra le azioni delle Note" largeNoteReactions: "Ingrandisci le reazioni" noteIdOrUrl: "ID della Nota o URL" +accountMigration: "Migrazione del profilo" +accountMoved: "Questo profilo ha migrato altrove:" +_accountMigration: + moveTo: "Migrare questo profilo verso un un altro" + moveToLabel: "Profilo verso cui migrare" + moveAccountDescription: "Questa attività è irreversibile! Innanzitutto, assicurati di aver creato, nella istanza di destinazione, un alias con l'indirizzo di questo profilo. Successivamente, indica qui il profilo di destinazione in questo modo: @persona@istanza.it" + moveFrom: "Migra un altro profilo dentro a questo" + moveFromLabel: "Profilo da cui migrare:" + moveFromDescription: "Se desideri spostare i profili follower da un altro profilo a questo, devi prima creare un alias qui. Assicurati averlo creato PRIMA di eseguire l'attività! Inserisci l'indirizzo del profilo mittente in questo modo: @persona@istanza.it" + migrationConfirm: "Vuoi davvero migrare questo profilo su {account}? L'azione è irreversibile e non potrai più utilizzare questo profilo nel suo stato originale.\nInoltre, assicurati di aver già creato un alias sull'account a cui ti stai trasferendo." _achievements: earnedAt: "Data di conseguimento" _types: @@ -1676,12 +1691,12 @@ _visibility: public: "Pubblica" publicDescription: "Visibile per tutti sul Fediverso" home: "Home" - homeDescription: "Visibile solo sulla timeline \"Home\"" + homeDescription: "Visibile solo sulla timeline locale" followers: "Follower" - followersDescription: "Visibile solo per i tuoi follower" + followersDescription: "Visibile solo ai tuoi follower" specified: "Nota diretta" specifiedDescription: "Visibile solo ai profili menzionati" - disableFederation: "Interrompi la federazione" + disableFederation: "Federazione disabilitata" disableFederationDescription: "Non spedire attività alle altre istanze remote" _postForm: replyPlaceholder: "Rispondi a questa nota..." @@ -1894,4 +1909,3 @@ _webhookSettings: renote: "Quando la Nota è Rinotata" reaction: "Quando ricevo una reazione" mention: "Quando mi menzionano" - diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 66b591760c..32bf47c209 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -920,6 +920,7 @@ pushNotificationNotSupported: "ブラウザかサーバーがプッシュ通知 sendPushNotificationReadMessage: "通知やメッセージが既読になったらプッシュ通知を削除する" sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」という通知が一瞬表示されるようになります。端末の電池消費量が増加する可能性があります。" windowMaximize: "最大化" +windowMinimize: "最小化" windowRestore: "元に戻す" caption: "キャプション" loggedInAsBot: "Botアカウントでログイン中" @@ -988,6 +989,17 @@ enableChartsForFederatedInstances: "リモートサーバーのチャートを showClipButtonInNoteFooter: "ノートのアクションにクリップを追加" largeNoteReactions: "ノートのリアクションを大きく表示" noteIdOrUrl: "ノートIDまたはURL" +accountMigration: "アカウントの引っ越し" +accountMoved: "このユーザーは新しいアカウントに引っ越しました:" + +_accountMigration: + moveTo: "このアカウントを新しいアカウントに引っ越す" + moveToLabel: "引っ越し先のアカウント:" + moveAccountDescription: "この操作は取り消せません。まずは引っ越し先のアカウントでこのアカウントに対しエイリアスを作成したことを確認してください。エイリアス作成後、引っ越し先のアカウントをこのように入力してください:@person@instance.com" + moveFrom: "別のアカウントからこのアカウントに引っ越す" + moveFromLabel: "引っ越し元のアカウント:" + moveFromDescription: "別のアカウントからこのアカウントにフォロワーを引き継いで引っ越したい場合、ここでエイリアスを作成しておく必要があります。必ず引っ越しを実行する前に作成してください!引っ越し元のアカウントをこのように入力してください:@person@instance.com" + migrationConfirm: "本当にこのアカウントを {account} に引っ越しますか?一度引っ越しを行うと取り消せず、二度とこのアカウントを元の状態で使用できなくなります。\nまた、引っ越し先のアカウントでエイリアスを作成したことを確認してください。" _achievements: earnedAt: "獲得日時" @@ -1954,4 +1966,3 @@ _webhookSettings: renote: "Renoteされたとき" reaction: "リアクションがあったとき" mention: "メンションされたとき" - diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index 5e9bbde7d1..81f99aace2 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -920,6 +920,7 @@ pushNotificationNotSupported: "ブラウザかサーバーがプッシュ通知 sendPushNotificationReadMessage: "通知やメッセージが既読になったらプッシュ通知を消すで" sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」っていう表示が一瞬表示されるようになるで。端末の電池使用量が増える可能性があるで。" windowMaximize: "最大化" +windowMinimize: "最小化" windowRestore: "元に戻す" caption: "キャプション" loggedInAsBot: "Botアカウントでログイン中やで" @@ -988,6 +989,16 @@ enableChartsForFederatedInstances: "リモートサーバーのチャートを showClipButtonInNoteFooter: "ノートのアクションにクリップを追加" largeNoteReactions: "ノートのリアクションを大きする" noteIdOrUrl: "ノートIDかURL" +accountMigration: "アカウントのお引っ越し" +accountMoved: "このユーザーはさらのアカウントに引っ越したで:" +_accountMigration: + moveTo: "このアカウントをさらのアカウントに引っ越すで" + moveToLabel: "引っ越し先のアカウント:" + moveAccountDescription: "この操作は戻されへんで。まず引っ越し先のアカウントでこのアカウントへのエイリアスが作れたか確認してきなはれや。エイリアスができてたら、引っ越し先のアカウントをこんな風に入力してくれへんか?:@person@instance.com" + moveFrom: "別のアカウントからこのアカウントに引っ越す" + moveFromLabel: "引っ越し元のアカウント:" + moveFromDescription: "別のアカウントからこのアカウントにフォロワーを引き継いで引っ越したかったら、ここでエイリアスを作っとく必要があるで。必ずお引っ越しを実行する前に作っとかなあかんで!引っ越し元のアカウントをこんな風に入力してくれへんか?:@person@instance.com" + migrationConfirm: "ほんまにこのアカウントを {account} に引っ越すんか?一回引っ越してもうたら取り消されへんし、二度とこのアカウントを元に戻されへんくなるで。\nそれと、引っ越し先のアカウントでエイリアスが作れたかちゃ~んと確認しーや?" _achievements: earnedAt: "貰った日ぃ" _types: @@ -1898,4 +1909,3 @@ _webhookSettings: renote: "Renoteされるとき~!" reaction: "リアクションがあるとき~!" mention: "メンションがあるとき~!" - diff --git a/locales/jbo-EN.yml b/locales/jbo-EN.yml index cd21505a47..ed97d539c0 100644 --- a/locales/jbo-EN.yml +++ b/locales/jbo-EN.yml @@ -1,2 +1 @@ --- - diff --git a/locales/kab-KAB.yml b/locales/kab-KAB.yml index 8b43041e4c..18fd8f5a58 100644 --- a/locales/kab-KAB.yml +++ b/locales/kab-KAB.yml @@ -103,4 +103,3 @@ _deck: _columns: notifications: "Ilɣuyen" list: "Tibdarin" - diff --git a/locales/kn-IN.yml b/locales/kn-IN.yml index 63a75302a1..ef66f3fbd2 100644 --- a/locales/kn-IN.yml +++ b/locales/kn-IN.yml @@ -83,4 +83,3 @@ _deck: notifications: "ಅಧಿಸೂಚನೆಗಳು" tl: "ಸಮಯಸಾಲು" mentions: "ಹೆಸರಿಸಿದ" - diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index dc4aacec33..3ff00482d6 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -1850,4 +1850,3 @@ _dialog: _webhookSettings: name: "이름" active: "활성화" - diff --git a/locales/lo-LA.yml b/locales/lo-LA.yml index 5736fa67a7..9c1a48c67c 100644 --- a/locales/lo-LA.yml +++ b/locales/lo-LA.yml @@ -368,4 +368,3 @@ _deck: list: "ລາຍການ" channel: "ຊ່ອງ" mentions: "ກ່າວເຖິງ" - diff --git a/locales/nl-NL.yml b/locales/nl-NL.yml index efbb83c70f..31c28a66e3 100644 --- a/locales/nl-NL.yml +++ b/locales/nl-NL.yml @@ -485,4 +485,3 @@ _deck: mentions: "Vermeldingen" _webhookSettings: name: "Naam" - diff --git a/locales/no-NO.yml b/locales/no-NO.yml index 36a0a2e0e3..83e189b9cf 100644 --- a/locales/no-NO.yml +++ b/locales/no-NO.yml @@ -1,3 +1,2 @@ --- _lang_: "Norsk Bokmål" - diff --git a/locales/pl-PL.yml b/locales/pl-PL.yml index cc71e8777f..517e8431f9 100644 --- a/locales/pl-PL.yml +++ b/locales/pl-PL.yml @@ -1379,4 +1379,3 @@ _deck: _webhookSettings: name: "Nazwa" active: "Właczono" - diff --git a/locales/pt-PT.yml b/locales/pt-PT.yml index 870ad50150..8a7232fa2c 100644 --- a/locales/pt-PT.yml +++ b/locales/pt-PT.yml @@ -557,4 +557,3 @@ _deck: direct: "Notas diretas" _webhookSettings: name: "Nome" - diff --git a/locales/ro-RO.yml b/locales/ro-RO.yml index 89f8afac9a..bbf0160de9 100644 --- a/locales/ro-RO.yml +++ b/locales/ro-RO.yml @@ -703,4 +703,3 @@ _deck: mentions: "Mențiuni" _webhookSettings: name: "Nume" - diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index cde3224c9f..8a09d30306 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -1838,4 +1838,3 @@ _dialog: _webhookSettings: name: "Название" active: "Вкл." - diff --git a/locales/si-LK.yml b/locales/si-LK.yml index cd21505a47..ed97d539c0 100644 --- a/locales/si-LK.yml +++ b/locales/si-LK.yml @@ -1,2 +1 @@ --- - diff --git a/locales/sk-SK.yml b/locales/sk-SK.yml index ff6075b703..7c74060158 100644 --- a/locales/sk-SK.yml +++ b/locales/sk-SK.yml @@ -1477,4 +1477,3 @@ _deck: _webhookSettings: name: "Názov" active: "Zapnuté" - diff --git a/locales/sv-SE.yml b/locales/sv-SE.yml index 6ea5f77c21..6369adb454 100644 --- a/locales/sv-SE.yml +++ b/locales/sv-SE.yml @@ -343,9 +343,16 @@ recentlyRegisteredUsers: "Nyligen registrerade användare" userList: "Listor" aboutMisskey: "Om Misskey" administrator: "Administratör" +passwordLessLogin: "Lösenordsfri inloggning" +passwordLessLoginDescription: "Tillåter lösenordsfri inloggning med endast en säkerhetsnyckel eller en passkey." +resetPassword: "Återställ Lösenord" newPasswordIs: "Det nya lösenordet är \"{password}\"" share: "Dela" enable: "Aktivera" +weakPassword: "Svagt Lösenord" +normalPassword: "Medel Lösenord" +strongPassword: "Starkt Lösenord" +signinFailed: "Kan inte logga in. Det angivna användarnamnet eller lösenordet är felaktigt." serviceworkerInfo: "Måste vara aktiverad för pushnotiser." enableInfiniteScroll: "Ladda mer automatiskt" enablePlayer: "Öppna videospelare" @@ -354,11 +361,14 @@ enableEmail: "Aktivera epost-utskick" smtpHost: "Värd" smtpUser: "Användarnamn" smtpPass: "Lösenord" +emptyToDisableSmtpAuth: "Lämna användarnamn och lösenord tomt för att avaktivera SMTP verifiering" clearCache: "Rensa cache" +onlineUsersCount: "{n} användare är online" enabled: "Aktiverad" user: "Användare" global: "Global" squareAvatars: "Visa fyrkantiga profilbilder" +incorrectPassword: "Fel lösenord." searchByGoogle: "Sök" file: "Filer" enableAutoSensitive: "Automatisk NSFW markering" @@ -368,6 +378,15 @@ subscribePushNotification: "Aktivera pushnotiser" unsubscribePushNotification: "Avaktivera pushnotiser" pushNotificationAlreadySubscribed: "Pushnotiser är redan aktiverade" pushNotificationNotSupported: "Din webbläsare eller instans har inte stöd för pushnotiser" +windowMaximize: "Maximera" +windowMinimize: "Minimera" +windowRestore: "Återställ" +resetPasswordConfirm: "Återställ verkligen ditt lösenord?" +_achievements: + _types: + _open3windows: + title: "Flera Fönster" + description: "Ha minst 3 fönster öppna samtidigt" _email: _follow: title: "följde dig" @@ -384,6 +403,7 @@ _sfx: chat: "Chatt" antenna: "Antenner" _2fa: + passwordToTOTP: "Skriv in ditt lösenord" renewTOTPCancel: "Nej tack" _antennaSources: all: "Alla noter" @@ -444,4 +464,3 @@ _deck: mentions: "Omnämningar" _webhookSettings: active: "Aktiverad" - diff --git a/locales/th-TH.yml b/locales/th-TH.yml index b4bf5c79f5..8d4a41bf34 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -1868,4 +1868,3 @@ _drivecleaner: _webhookSettings: name: "ชื่อ" active: "เปิดใช้งาน" - diff --git a/locales/tr-TR.yml b/locales/tr-TR.yml index 0f53dbafcb..7bd8188a48 100644 --- a/locales/tr-TR.yml +++ b/locales/tr-TR.yml @@ -60,4 +60,3 @@ _deck: _columns: notifications: "Bildirim" tl: "Zaman çizelgesi" - diff --git a/locales/ug-CN.yml b/locales/ug-CN.yml index 5b825d7bf3..65ef841259 100644 --- a/locales/ug-CN.yml +++ b/locales/ug-CN.yml @@ -2,4 +2,3 @@ _lang_: "ياپونچە" search: "ئىزدەش" searchByGoogle: "ئىزدەش" - diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index 7b2ee6d891..895d2e1f12 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -1641,4 +1641,3 @@ _deck: _webhookSettings: name: "Ім'я" active: "Увімкнено" - diff --git a/locales/vi-VN.yml b/locales/vi-VN.yml index f814454732..ebd046b6d4 100644 --- a/locales/vi-VN.yml +++ b/locales/vi-VN.yml @@ -1707,4 +1707,3 @@ _dialog: _webhookSettings: name: "Tên" active: "Đã bật" - diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 5f45827e27..d9df760186 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -60,7 +60,7 @@ youGotNewFollower: "你有新的关注者" receiveFollowRequest: "您收到了关注请求" followRequestAccepted: "您的关注请求被通过了" mention: "提及" -mentions: "提及" +mentions: "提到我的" directNotes: "私信" importAndExport: "导入和导出" import: "导入" @@ -142,13 +142,13 @@ customEmojis: "自定义表情符号" emoji: "表情符号" emojis: "表情符号" emojiName: "表情符号名称" -emojiUrl: "表情符号地址" +emojiUrl: "emoji 地址" addEmoji: "添加表情符号" settingGuide: "推荐配置" cacheRemoteFiles: "缓存远程文件" cacheRemoteFilesDescription: "当禁用此设定时远程文件将直接从远程服务器载入。禁用后会减小储存空间需求,但是会增加流量,因为缩略图不会被生成。" flagAsBot: "这是一个机器人账号" -flagAsBotDescription: "如果此帐户由程序控制,请启用此项。启用后,此标志可以帮助其他开发人员防止机器人之间产生无限互动的行为,并让Misskey的内部系统将此帐户识别为机器人。" +flagAsBotDescription: "如果此账户由程序控制,请启用此项。启用后,此标志可以帮助其他开发人员防止机器人之间产生无限互动的行为,并让Misskey的内部系统将此账户识别为机器人。" flagAsCat: "将这个账户设定为一只猫" flagAsCatDescription: "如果您想表明此帐户是一只猫,请打开此标志。\n开启后,会在您的头像上出现猫耳朵,并将你的帖子中的「na」替换为「nya」,日文同理。" flagShowTimelineReplies: "在时间线上显示帖子的回复" @@ -920,6 +920,7 @@ pushNotificationNotSupported: "浏览器或服务器不支持推送通知消息" sendPushNotificationReadMessage: "删除已读推送通知消息" sendPushNotificationReadMessageCaption: "“{emptyPushNotificationMessage}”的通知消息将会显示。您终端设备的电池消耗可能会增加。" windowMaximize: "最大化" +windowMinimize: "最小化" windowRestore: "还原" caption: "标题" loggedInAsBot: "以Bot账户登录" @@ -988,6 +989,16 @@ enableChartsForFederatedInstances: "生成远程服务器的图表" showClipButtonInNoteFooter: "在贴文下方显示便签按钮" largeNoteReactions: "使用大图标来显示回应" noteIdOrUrl: "帖子ID或URL" +accountMigration: "账户迁移" +accountMoved: "此用户已迁移账户" +_accountMigration: + moveTo: "把这个账户迁移到新的账户" + moveToLabel: "迁移后的账户" + moveAccountDescription: "此操作无法取消。请先确认您已在迁移后的账户上,为此账户创造了别名。创造别名后,请如以下输入您的迁移后的账户:@person@instance.com" + moveFrom: "从别的账号迁移到此账户" + moveFromLabel: "迁移前的账户" + moveFromDescription: "如果迁移时需要继承其他账户的关注者,请在此创造别名。此操作需要在实行迁移之前完成!请如已下输入需要迁移的账户:@person@instance.com" + migrationConfirm: "确定要把此账户迁移到{account}吗?一旦确定后,此操作无法取消,此账户也无法以原来的状态使用。\n同时,请确认迁移后的账户,已创造别名。" _achievements: earnedAt: "达成时间" _types: @@ -1898,4 +1909,3 @@ _webhookSettings: renote: "被转发时" reaction: "被回应时" mention: "被提及时" - diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index d14dc49307..48934463d0 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -984,6 +984,10 @@ enableChartsForFederatedInstances: "生成遠端伺服器的圖表" showClipButtonInNoteFooter: "將摘錄添加至貼文" largeNoteReactions: "將貼文的反應放大顯示" noteIdOrUrl: "貼文ID或URL" +accountMigration: "遷移帳戶" +_accountMigration: + moveTo: "將這個帳戶遷移至新的帳戶" + moveToLabel: "要遷移的帳戶:" _achievements: earnedAt: "獲得日期" _types: @@ -1894,4 +1898,3 @@ _webhookSettings: renote: "當被轉發時" reaction: "當獲得反應時" mention: "當被提到時" - diff --git a/package.json b/package.json index d1c081c86d..89733ea179 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "13.11.0-beta.6", + "version": "13.11.1", "codename": "nasubi", "repository": { "type": "git", diff --git a/packages/backend/migration/1680931179228-account-move.js b/packages/backend/migration/1680931179228-account-move.js new file mode 100644 index 0000000000..821318d1bc --- /dev/null +++ b/packages/backend/migration/1680931179228-account-move.js @@ -0,0 +1,17 @@ +export class AccountMove1680931179228 { + name = 'AccountMove1680931179228' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "movedToUri" character varying(512)`); + await queryRunner.query(`COMMENT ON COLUMN "user"."movedToUri" IS 'The URI of the new account of the User'`); + await queryRunner.query(`ALTER TABLE "user" ADD "alsoKnownAs" text`); + await queryRunner.query(`COMMENT ON COLUMN "user"."alsoKnownAs" IS 'URIs the user is known as too'`); + } + + async down(queryRunner) { + await queryRunner.query(`COMMENT ON COLUMN "user"."alsoKnownAs" IS 'URIs the user is known as too'`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "alsoKnownAs"`); + await queryRunner.query(`COMMENT ON COLUMN "user"."movedToUri" IS 'The URI of the new account of the User'`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "movedToUri"`); + } +} diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index fd2b83cf2a..e4f7601fa9 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -25,6 +25,14 @@ export type Source = { disableCache?: boolean; extra?: { [x: string]: string }; }; + dbReplications?: boolean; + dbSlaves?: { + host: string; + port: number; + db: string; + user: string; + pass: string; + }[]; redis: { host: string; port: number; diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts new file mode 100644 index 0000000000..3f2a19b771 --- /dev/null +++ b/packages/backend/src/core/AccountMoveService.ts @@ -0,0 +1,114 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull } from 'typeorm'; + +import { bindThis } from '@/decorators.js'; +import { DI } from '@/di-symbols.js'; +import type { LocalUser } from '@/models/entities/User.js'; +import { User } from '@/models/entities/User.js'; +import type { FollowingsRepository, UsersRepository } from '@/models/index.js'; + +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { AccountUpdateService } from '@/core/AccountUpdateService.js'; +import { RelayService } from '@/core/RelayService.js'; + +@Injectable() +export class AccountMoveService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + private userEntityService: UserEntityService, + private apRendererService: ApRendererService, + private apDeliverManagerService: ApDeliverManagerService, + private globalEventService: GlobalEventService, + private userFollowingService: UserFollowingService, + private accountUpdateService: AccountUpdateService, + private relayService: RelayService, + ) { + } + + /** + * Move a local account to a remote account. + * + * After delivering Move activity, its local followers unfollow the old account and then follow the new one. + */ + @bindThis + public async moveToRemote(src: LocalUser, dst: User): Promise { + // Make sure that the destination is a remote account. + if (this.userEntityService.isLocalUser(dst)) throw new Error('move destiantion is not remote'); + if (!dst.uri) throw new Error('destination uri is empty'); + + // add movedToUri to indicate that the user has moved + const update = {} as Partial; + update.alsoKnownAs = src.alsoKnownAs?.concat([dst.uri]) ?? [dst.uri]; + update.movedToUri = dst.uri; + await this.usersRepository.update(src.id, update); + + const srcPerson = await this.apRendererService.renderPerson(src); + const updateAct = this.apRendererService.addContext(this.apRendererService.renderUpdate(srcPerson, src)); + await this.apDeliverManagerService.deliverToFollowers(src, updateAct); + this.relayService.deliverToRelays(src, updateAct); + + // Deliver Move activity to the followers of the old account + const moveAct = this.apRendererService.addContext(this.apRendererService.renderMove(src, dst)); + await this.apDeliverManagerService.deliverToFollowers(src, moveAct); + + // Publish meUpdated event + const iObj = await this.userEntityService.pack(src.id, src, { detail: true, includeSecrets: true }); + this.globalEventService.publishMainStream(src.id, 'meUpdated', iObj); + + // follow the new account and unfollow the old one + const followings = await this.followingsRepository.find({ + relations: { + follower: true, + }, + where: { + followeeId: src.id, + followerHost: IsNull(), // follower is local + }, + }); + for (const following of followings) { + if (!following.follower) continue; + try { + await this.userFollowingService.follow(following.follower, dst); + await this.userFollowingService.unfollow(following.follower, src); + } catch { + /* empty */ + } + } + + return iObj; + } + + /** + * Create an alias of an old remote account. + * + * The user's new profile will be published to the followers. + */ + @bindThis + public async createAlias(me: LocalUser, updates: Partial): Promise { + await this.usersRepository.update(me.id, updates); + + // Publish meUpdated event + const iObj = await this.userEntityService.pack(me.id, me, { + detail: true, + includeSecrets: true, + }); + this.globalEventService.publishMainStream(me.id, 'meUpdated', iObj); + + if (me.isLocked === false) { + await this.userFollowingService.acceptAllFollowRequests(me); + } + + this.accountUpdateService.publishToFollowers(me.id); + + return iObj; + } +} diff --git a/packages/backend/src/core/AccountUpdateService.ts b/packages/backend/src/core/AccountUpdateService.ts index d8ba7b169d..b146fc66be 100644 --- a/packages/backend/src/core/AccountUpdateService.ts +++ b/packages/backend/src/core/AccountUpdateService.ts @@ -29,7 +29,7 @@ export class AccountUpdateService { public async publishToFollowers(userId: User['id']) { const user = await this.usersRepository.findOneBy({ id: userId }); if (user == null) throw new Error('user not found'); - + // フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信 if (this.userEntityService.isLocalUser(user)) { const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderPerson(user), user)); diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index ea6e229610..8775536e4a 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { AccountMoveService } from './AccountMoveService.js'; import { AccountUpdateService } from './AccountUpdateService.js'; import { AiService } from './AiService.js'; import { AntennaService } from './AntennaService.js'; @@ -119,6 +120,7 @@ import type { Provider } from '@nestjs/common'; //#region 文字列ベースでのinjection用(循環参照対応のため) const $LoggerService: Provider = { provide: 'LoggerService', useExisting: LoggerService }; +const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService }; const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService }; const $AiService: Provider = { provide: 'AiService', useExisting: AiService }; const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService }; @@ -242,6 +244,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ], providers: [ LoggerService, + AccountMoveService, AccountUpdateService, AiService, AntennaService, @@ -359,6 +362,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting //#region 文字列ベースでのinjection用(循環参照対応のため) $LoggerService, + $AccountMoveService, $AccountUpdateService, $AiService, $AntennaService, @@ -477,6 +481,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting exports: [ QueueModule, LoggerService, + AccountMoveService, AccountUpdateService, AiService, AntennaService, @@ -593,6 +598,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting //#region 文字列ベースでのinjection用(循環参照対応のため) $LoggerService, + $AccountMoveService, $AccountUpdateService, $AiService, $AntennaService, diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index 604a94707f..dc365986fe 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -44,7 +44,11 @@ export class CustomEmojiService { memoryCacheLifetime: 1000 * 60 * 3, // 3m fetcher: () => this.emojisRepository.find({ where: { host: IsNull() } }).then(emojis => new Map(emojis.map(emoji => [emoji.name, emoji]))), toRedisConverter: (value) => JSON.stringify(value.values()), - fromRedisConverter: (value) => new Map(JSON.parse(value).map((x: Emoji) => [x.name, x])), // TODO: Date型の変換 + fromRedisConverter: (value) => { + // 原因不明だが配列以外が入ってくることがあるため + if (!Array.isArray(JSON.parse(value))) return undefined; + return new Map(JSON.parse(value).map((x: Emoji) => [x.name, x])); + }, // TODO: Date型の変換 }); } diff --git a/packages/backend/src/core/FederatedInstanceService.ts b/packages/backend/src/core/FederatedInstanceService.ts index 1d0c87280f..56660ae0d0 100644 --- a/packages/backend/src/core/FederatedInstanceService.ts +++ b/packages/backend/src/core/FederatedInstanceService.ts @@ -29,6 +29,7 @@ export class FederatedInstanceService { toRedisConverter: (value) => JSON.stringify(value), fromRedisConverter: (value) => { const parsed = JSON.parse(value); + if (parsed == null) return null; return { ...parsed, firstRetrievedAt: new Date(parsed.firstRetrievedAt), diff --git a/packages/backend/src/core/IdService.ts b/packages/backend/src/core/IdService.ts index 94084ad84f..8aa6ccfc4e 100644 --- a/packages/backend/src/core/IdService.ts +++ b/packages/backend/src/core/IdService.ts @@ -3,10 +3,11 @@ import { ulid } from 'ulid'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { genAid, parseAid } from '@/misc/id/aid.js'; -import { genMeid } from '@/misc/id/meid.js'; -import { genMeidg } from '@/misc/id/meidg.js'; +import { genMeid, parseMeid } from '@/misc/id/meid.js'; +import { genMeidg, parseMeidg } from '@/misc/id/meidg.js'; import { genObjectId } from '@/misc/id/object-id.js'; import { bindThis } from '@/decorators.js'; +import { parseUlid } from '@/misc/id/ulid.js'; @Injectable() export class IdService { @@ -37,11 +38,10 @@ export class IdService { public parse(id: string): { date: Date; } { switch (this.method) { case 'aid': return parseAid(id); - // TODO - //case 'meid': - //case 'meidg': - //case 'ulid': - //case 'objectid': + case 'objectid': + case 'meid': return parseMeid(id); + case 'meidg': return parseMeidg(id); + case 'ulid': return parseUlid(id); default: throw new Error('unrecognized id generation method'); } } diff --git a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts index 70a6d32fe2..62a2a33a19 100644 --- a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts +++ b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts @@ -186,7 +186,7 @@ class DeliverManager { for (const following of followers) { const inbox = following.followerSharedInbox ?? following.followerInbox; - inboxes.set(inbox, following.followerSharedInbox === null); + inboxes.set(inbox, following.followerSharedInbox != null); } } diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 055bffe731..3fca0bb1fd 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { In } from 'typeorm'; +import { In, IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; @@ -22,7 +22,7 @@ import { QueueService } from '@/core/QueueService.js'; import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import type { RemoteUser } from '@/models/entities/User.js'; -import { getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; +import { getApHrefNullable, getApId, getApIds, getApType, getOneApHrefNullable, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; import { ApNoteService } from './models/ApNoteService.js'; import { ApLoggerService } from './ApLoggerService.js'; import { ApDbResolverService } from './ApDbResolverService.js'; @@ -31,7 +31,7 @@ import { ApAudienceService } from './ApAudienceService.js'; import { ApPersonService } from './models/ApPersonService.js'; import { ApQuestionService } from './models/ApQuestionService.js'; import type { Resolver } from './ApResolverService.js'; -import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate } from './type.js'; +import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate, IMove } from './type.js'; @Injectable() export class ApInboxService { @@ -80,7 +80,7 @@ export class ApInboxService { ) { this.logger = this.apLoggerService.logger; } - + @bindThis public async performActivity(actor: RemoteUser, activity: IObject) { if (isCollectionOrOrderedCollection(activity)) { @@ -139,6 +139,8 @@ export class ApInboxService { await this.block(actor, activity); } else if (isFlag(activity)) { await this.flag(actor, activity); + } else if (isMove(activity)) { + //await this.move(actor, activity); } else { this.logger.warn(`unrecognized activity type: ${activity.type}`); } @@ -147,15 +149,15 @@ export class ApInboxService { @bindThis private async follow(actor: RemoteUser, activity: IFollow): Promise { const followee = await this.apDbResolverService.getUserFromApId(activity.object); - + if (followee == null) { return 'skip: followee not found'; } - + if (followee.host != null) { return 'skip: フォローしようとしているユーザーはローカルユーザーではありません'; } - + await this.userFollowingService.follow(actor, followee, activity.id); return 'ok'; } @@ -183,16 +185,16 @@ export class ApInboxService { const uri = activity.id ?? activity; this.logger.info(`Accept: ${uri}`); - + const resolver = this.apResolverService.createResolver(); - + const object = await resolver.resolve(activity.object).catch(err => { this.logger.error(`Resolution failed: ${err}`); throw err; }); - + if (isFollow(object)) return await this.acceptFollow(actor, object); - + return `skip: Unknown Accept type: ${getApType(object)}`; } @@ -225,18 +227,18 @@ export class ApInboxService { if ('actor' in activity && actor.uri !== activity.actor) { throw new Error('invalid actor'); } - + if (activity.target == null) { throw new Error('target is null'); } - + if (activity.target === actor.featured) { const note = await this.apNoteService.resolveNote(activity.object); if (note == null) throw new Error('note not found'); await this.notePiningService.addPinned(actor, note.id); return; } - + throw new Error(`unknown target: ${activity.target}`); } @@ -405,10 +407,10 @@ export class ApInboxService { if ('actor' in activity && actor.uri !== activity.actor) { throw new Error('invalid actor'); } - + // 削除対象objectのtype let formerType: string | undefined; - + if (typeof activity.object === 'string') { // typeが不明だけど、どうせ消えてるのでremote resolveしない formerType = undefined; @@ -420,19 +422,19 @@ export class ApInboxService { formerType = toSingle(object.type); } } - + const uri = getApId(activity.object); - + // type不明でもactorとobjectが同じならばそれはPersonに違いない if (!formerType && actor.uri === uri) { formerType = 'Person'; } - + // それでもなかったらおそらくNote if (!formerType) { formerType = 'Note'; } - + if (validPost.includes(formerType)) { return await this.deleteNote(actor, uri); } else if (validActor.includes(formerType)) { @@ -445,44 +447,44 @@ export class ApInboxService { @bindThis private async deleteActor(actor: RemoteUser, uri: string): Promise { this.logger.info(`Deleting the Actor: ${uri}`); - + if (actor.uri !== uri) { return `skip: delete actor ${actor.uri} !== ${uri}`; } - + const user = await this.usersRepository.findOneBy({ id: actor.id }); if (user == null) { return 'skip: actor not found'; } else if (user.isDeleted) { return 'skip: already deleted'; } - + const job = await this.queueService.createDeleteAccountJob(actor); - + await this.usersRepository.update(actor.id, { isDeleted: true, }); - + return `ok: queued ${job.name} ${job.id}`; } @bindThis private async deleteNote(actor: RemoteUser, uri: string): Promise { this.logger.info(`Deleting the Note: ${uri}`); - + const unlock = await this.appLockService.getApLock(uri); - + try { const note = await this.apDbResolverService.getNoteFromApId(uri); - + if (note == null) { return 'message not found'; } - + if (note.userId !== actor.id) { return '投稿を削除しようとしているユーザーは投稿の作成者ではありません'; } - + await this.noteDeleteService.delete(actor, note); return 'ok: note deleted'; } finally { @@ -536,23 +538,23 @@ export class ApInboxService { @bindThis private async rejectFollow(actor: RemoteUser, activity: IFollow): Promise { // ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある - + const follower = await this.apDbResolverService.getUserFromApId(activity.actor); - + if (follower == null) { return 'skip: follower not found'; } - + if (!this.userEntityService.isLocalUser(follower)) { return 'skip: follower is not a local user'; } - + // relay const match = activity.id?.match(/follow-relay\/(\w+)/); if (match) { return await this.relayService.relayRejected(match[1]); } - + await this.userFollowingService.remoteReject(actor, follower); return 'ok'; } @@ -562,18 +564,18 @@ export class ApInboxService { if ('actor' in activity && actor.uri !== activity.actor) { throw new Error('invalid actor'); } - + if (activity.target == null) { throw new Error('target is null'); } - + if (activity.target === actor.featured) { const note = await this.apNoteService.resolveNote(activity.object); if (note == null) throw new Error('note not found'); await this.notePiningService.removePinned(actor, note.id); return; } - + throw new Error(`unknown target: ${activity.target}`); } @@ -582,24 +584,24 @@ export class ApInboxService { if ('actor' in activity && actor.uri !== activity.actor) { throw new Error('invalid actor'); } - + const uri = activity.id ?? activity; - + this.logger.info(`Undo: ${uri}`); - + const resolver = this.apResolverService.createResolver(); - + const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); throw e; }); - + if (isFollow(object)) return await this.undoFollow(actor, object); if (isBlock(object)) return await this.undoBlock(actor, object); if (isLike(object)) return await this.undoLike(actor, object); if (isAnnounce(object)) return await this.undoAnnounce(actor, object); if (isAccept(object)) return await this.undoAccept(actor, object); - + return `skip: unknown object type ${getApType(object)}`; } @@ -609,17 +611,17 @@ export class ApInboxService { if (follower == null) { return 'skip: follower not found'; } - + const following = await this.followingsRepository.findOneBy({ followerId: follower.id, followeeId: actor.id, }); - + if (following) { await this.userFollowingService.unfollow(follower, actor); return 'ok: unfollowed'; } - + return 'skip: フォローされていない'; } @@ -708,16 +710,16 @@ export class ApInboxService { if ('actor' in activity && actor.uri !== activity.actor) { return 'skip: invalid actor'; } - + this.logger.debug('Update'); - + const resolver = this.apResolverService.createResolver(); - + const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); throw e; }); - + if (isActor(object)) { await this.apPersonService.updatePerson(actor.uri!, resolver, object); return 'ok: Person updated'; @@ -728,4 +730,59 @@ export class ApInboxService { return `skip: Unknown type: ${getApType(object)}`; } } + + @bindThis + private async move(actor: RemoteUser, activity: IMove): Promise { + // fetch the new and old accounts + const targetUri = getApHrefNullable(activity.target); + if (!targetUri) return 'skip: invalid activity target'; + let new_acc = await this.apPersonService.resolvePerson(targetUri); + let old_acc = await this.apPersonService.resolvePerson(actor.uri); + + // update them if they're remote + if (new_acc.uri) await this.apPersonService.updatePerson(new_acc.uri); + if (old_acc.uri) await this.apPersonService.updatePerson(old_acc.uri); + + // retrieve updated users + new_acc = await this.apPersonService.resolvePerson(targetUri); + old_acc = await this.apPersonService.resolvePerson(actor.uri); + + // check if alsoKnownAs of the new account is valid + let isValidMove = true; + if (old_acc.uri) { + if (!new_acc.alsoKnownAs?.includes(old_acc.uri)) { + isValidMove = false; + } + } else if (!new_acc.alsoKnownAs?.includes(old_acc.id)) { + isValidMove = false; + } + if (!isValidMove) { + return 'skip: accounts invalid'; + } + + // add target uri to movedToUri in order to indicate that the user has moved + await this.usersRepository.update(old_acc.id, { movedToUri: targetUri }); + + // follow the new account and unfollow the old one + const followings = await this.followingsRepository.find({ + relations: { + follower: true, + }, + where: { + followeeId: old_acc.id, + followerHost: IsNull(), // follower is local + }, + }); + for (const following of followings) { + if (!following.follower) continue; + try { + await this.userFollowingService.follow(following.follower, new_acc); + await this.userFollowingService.unfollow(following.follower, old_acc); + } catch { + /* empty */ + } + } + + return 'ok'; + } } diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index b250b796d6..0b22aa9bcf 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -25,7 +25,7 @@ import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { isNotNull } from '@/misc/is-not-null.js'; import { LdSignatureService } from './LdSignatureService.js'; import { ApMfmService } from './ApMfmService.js'; -import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js'; +import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js'; import type { IIdentifier } from './models/identifier.js'; @Injectable() @@ -292,6 +292,22 @@ export class ApRendererService { }; } + @bindThis + public renderMove( + src: { id: User['id']; host: User['host']; uri: User['host'] }, + dst: { id: User['id']; host: User['host']; uri: User['host'] }, + ): IMove { + const actor = this.userEntityService.isLocalUser(src) ? `${this.config.url}/users/${src.id}` : src.uri!; + const target = this.userEntityService.isLocalUser(dst) ? `${this.config.url}/users/${dst.id}` : dst.uri!; + return { + id: `${this.config.url}/moves/${src.id}/${dst.id}`, + actor, + type: 'Move', + object: actor, + target, + }; + } + @bindThis public async renderNote(note: Note, dive = true): Promise { const getPromisedFiles = async (ids: string[]) => { @@ -498,6 +514,14 @@ export class ApRendererService { attachment: attachment.length ? attachment : undefined, } as any; + if (user.movedToUri) { + person.movedTo = user.movedToUri; + } + + if (user.alsoKnownAs) { + person.alsoKnownAs = user.alsoKnownAs; + } + if (profile.birthday) { person['vcard:bday'] = profile.birthday; } diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 664e7eb4ea..21797cfcb7 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -281,6 +281,8 @@ export class ApPersonService implements OnModuleInit { lastFetchedAt: new Date(), name: truncate(person.name, nameLength), isLocked: !!person.manuallyApprovesFollowers, + movedToUri: person.movedTo, + alsoKnownAs: person.alsoKnownAs, isExplorable: !!person.discoverable, username: person.preferredUsername, usernameLower: person.preferredUsername!.toLowerCase(), @@ -473,6 +475,8 @@ export class ApPersonService implements OnModuleInit { isBot: getApType(object) === 'Service', isCat: (person as any).isCat === true, isLocked: !!person.manuallyApprovesFollowers, + movedToUri: person.movedTo ?? null, + alsoKnownAs: person.alsoKnownAs ?? null, isExplorable: !!person.discoverable, } as Partial; diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 8851946330..625135da6c 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -157,6 +157,8 @@ export interface IActor extends IObject { name?: string; preferredUsername?: string; manuallyApprovesFollowers?: boolean; + movedTo?: string; + alsoKnownAs?: string[]; discoverable?: boolean; inbox: string; sharedInbox?: string; // 後方互換性のため @@ -300,6 +302,11 @@ export interface IFlag extends IActivity { type: 'Flag'; } +export interface IMove extends IActivity { + type: 'Move'; + target: IObject | string; +} + export const isCreate = (object: IObject): object is ICreate => getApType(object) === 'Create'; export const isDelete = (object: IObject): object is IDelete => getApType(object) === 'Delete'; export const isUpdate = (object: IObject): object is IUpdate => getApType(object) === 'Update'; @@ -314,3 +321,4 @@ export const isLike = (object: IObject): object is ILike => getApType(object) == export const isAnnounce = (object: IObject): object is IAnnounce => getApType(object) === 'Announce'; export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block'; export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag'; +export const isMove = (object: IObject): object is IMove => getApType(object) === 'Move'; diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index cbe94451cc..e02f7535d4 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -15,6 +15,7 @@ import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, PagesRepository, UserProfile, RenoteMutingsRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; +import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import type { OnModuleInit } from '@nestjs/common'; import type { AntennaService } from '../AntennaService.js'; @@ -25,7 +26,7 @@ import type { PageEntityService } from './PageEntityService.js'; 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'> : @@ -47,6 +48,7 @@ function isRemoteUser(user: User | { host: User['host'] }): boolean { @Injectable() export class UserEntityService implements OnModuleInit { + private apPersonService: ApPersonService; private noteEntityService: NoteEntityService; private driveFileEntityService: DriveFileEntityService; private pageEntityService: PageEntityService; @@ -122,6 +124,7 @@ export class UserEntityService implements OnModuleInit { } onModuleInit() { + this.apPersonService = this.moduleRef.get('ApPersonService'); this.noteEntityService = this.moduleRef.get('NoteEntityService'); this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); this.pageEntityService = this.moduleRef.get('PageEntityService'); @@ -237,7 +240,7 @@ export class UserEntityService implements OnModuleInit { @bindThis public async getHasUnreadNotification(userId: User['id']): Promise { const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`); - + const latestNotificationIdsRes = await this.redisClient.xrevrange( `notificationTimeline:${userId}`, '+', @@ -363,6 +366,8 @@ export class UserEntityService implements OnModuleInit { ...(opts.detail ? { url: profile!.url, uri: user.uri, + movedToUri: user.movedToUri ? await this.apPersonService.resolvePerson(user.movedToUri) : null, + alsoKnownAs: user.alsoKnownAs, createdAt: user.createdAt.toISOString(), updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null, lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null, diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index d35414acf7..a4abd4f878 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -8,7 +8,7 @@ export class RedisKVCache { private memoryCache: MemoryKVCache; private fetcher: (key: string) => Promise; private toRedisConverter: (value: T) => string; - private fromRedisConverter: (value: string) => T; + private fromRedisConverter: (value: string) => T | undefined; // undefined means no cache constructor(redisClient: RedisKVCache['redisClient'], name: RedisKVCache['name'], opts: { lifetime: RedisKVCache['lifetime']; @@ -92,7 +92,7 @@ export class RedisSingleCache { private memoryCache: MemorySingleCache; private fetcher: () => Promise; private toRedisConverter: (value: T) => string; - private fromRedisConverter: (value: string) => T; + private fromRedisConverter: (value: string) => T | undefined; // undefined means no cache constructor(redisClient: RedisSingleCache['redisClient'], name: RedisSingleCache['name'], opts: { lifetime: RedisSingleCache['lifetime']; diff --git a/packages/backend/src/misc/id/aid.ts b/packages/backend/src/misc/id/aid.ts index 93a9929aa7..9e206ee98f 100644 --- a/packages/backend/src/misc/id/aid.ts +++ b/packages/backend/src/misc/id/aid.ts @@ -3,6 +3,8 @@ import * as crypto from 'node:crypto'; +export const aidRegExp = /^[0-9a-z]{10}$/; + const TIME2000 = 946684800000; let counter = crypto.randomBytes(2).readUInt16LE(0); diff --git a/packages/backend/src/misc/id/meid.ts b/packages/backend/src/misc/id/meid.ts index 30bbdf1698..337416b059 100644 --- a/packages/backend/src/misc/id/meid.ts +++ b/packages/backend/src/misc/id/meid.ts @@ -1,5 +1,8 @@ const CHARS = '0123456789abcdef'; +// same as object-id +export const meidRegExp = /^[0-9a-f]{24}$/; + function getTime(time: number) { if (time < 0) time = 0; if (time === 0) { @@ -24,3 +27,9 @@ function getRandom() { export function genMeid(date: Date): string { return getTime(date.getTime()) + getRandom(); } + +export function parseMeid(id: string): { date: Date; } { + return { + date: new Date(parseInt(id.slice(0, 12), 16) - 0x800000000000), + }; +} diff --git a/packages/backend/src/misc/id/meidg.ts b/packages/backend/src/misc/id/meidg.ts index d4aaaea1ba..19d0bc1fd2 100644 --- a/packages/backend/src/misc/id/meidg.ts +++ b/packages/backend/src/misc/id/meidg.ts @@ -3,6 +3,7 @@ const CHARS = '0123456789abcdef'; // 4bit Fixed hex value 'g' // 44bit UNIX Time ms in Hex // 48bit Random value in Hex +export const meidgRegExp = /^g[0-9a-f]{23}$/; function getTime(time: number) { if (time < 0) time = 0; @@ -26,3 +27,9 @@ function getRandom() { export function genMeidg(date: Date): string { return 'g' + getTime(date.getTime()) + getRandom(); } + +export function parseMeidg(id: string): { date: Date; } { + return { + date: new Date(parseInt(id.slice(1, 12), 16)), + }; +} diff --git a/packages/backend/src/misc/id/object-id.ts b/packages/backend/src/misc/id/object-id.ts index 392ea43301..aec3447bd7 100644 --- a/packages/backend/src/misc/id/object-id.ts +++ b/packages/backend/src/misc/id/object-id.ts @@ -1,5 +1,8 @@ const CHARS = '0123456789abcdef'; +// same as meid +export const objectIdRegExp = /^[0-9a-f]{24}$/; + function getTime(time: number) { if (time < 0) time = 0; if (time === 0) { @@ -24,3 +27,9 @@ function getRandom() { export function genObjectId(date: Date): string { return getTime(date.getTime()) + getRandom(); } + +export function parseObjectId(id: string): { date: Date; } { + return { + date: new Date(parseInt(id.slice(0, 8), 16) * 1000), + }; +} diff --git a/packages/backend/src/misc/id/ulid.ts b/packages/backend/src/misc/id/ulid.ts new file mode 100644 index 0000000000..e8aa752890 --- /dev/null +++ b/packages/backend/src/misc/id/ulid.ts @@ -0,0 +1,14 @@ +// Crockford's Base32 +// https://github.com/ulid/spec#encoding +const CHARS = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + +export const ulidRegExp = /^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$/; + +export function parseUlid(id: string): { date: Date; } { + const timestamp = id.slice(0, 10); + let time = 0; + for (let i = 0; i < 10; i++) { + time = time * 32 + CHARS.indexOf(timestamp[i]); + } + return { date: new Date(time) }; +} diff --git a/packages/backend/src/models/entities/User.ts b/packages/backend/src/models/entities/User.ts index ca53c57d5a..04dfa21107 100644 --- a/packages/backend/src/models/entities/User.ts +++ b/packages/backend/src/models/entities/User.ts @@ -68,6 +68,19 @@ export class User { }) public followingCount: number; + @Column('varchar', { + length: 512, + nullable: true, + comment: 'The URI of the new account of the User', + }) + public movedToUri: string | null; + + @Column('simple-array', { + nullable: true, + comment: 'URIs the user is known as too', + }) + public alsoKnownAs: string[] | null; + @Column('integer', { default: 0, comment: 'The count of notes.', diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index e388a77a5e..7d40979e3d 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -72,6 +72,18 @@ export const packedUserDetailedNotMeOnlySchema = { format: 'uri', nullable: true, optional: false, }, + movedToUri: { + type: 'string', + format: 'uri', + nullable: true, + optional: false, + }, + alsoKnownAs: { + type: 'array', + format: 'uri', + nullable: true, + optional: false, + }, createdAt: { type: 'string', nullable: false, optional: false, diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index efeca46b49..bb21ed827e 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -200,6 +200,22 @@ export function createPostgresDataSource(config: Config) { statement_timeout: 1000 * 10, ...config.db.extra, }, + replication: config.dbReplications ? { + master: { + host: config.db.host, + port: config.db.port, + username: config.db.user, + password: config.db.pass, + database: config.db.db, + }, + slaves: config.dbSlaves!.map(rep => ({ + host: rep.host, + port: rep.port, + username: rep.user, + password: rep.pass, + database: rep.db, + })), + } : undefined, synchronize: process.env.NODE_ENV === 'test', dropSchema: process.env.NODE_ENV === 'test', cache: !config.db.disableCache && process.env.NODE_ENV !== 'test' ? { // dbをcloseしても何故かredisのコネクションが内部的に残り続けるようで、テストの際に支障が出るため無効にする(キャッシュも含めてテストしたいため本当は有効にしたいが...) diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index cab2477414..5a53b3faf7 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -220,6 +220,8 @@ import * as ep___i_signinHistory from './endpoints/i/signin-history.js'; import * as ep___i_unpin from './endpoints/i/unpin.js'; import * as ep___i_updateEmail from './endpoints/i/update-email.js'; import * as ep___i_update from './endpoints/i/update.js'; +import * as ep___i_move from './endpoints/i/move.js'; +import * as ep___i_knownAs from './endpoints/i/known-as.js'; import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js'; import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js'; import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js'; @@ -551,6 +553,8 @@ const $i_signinHistory: Provider = { provide: 'ep:i/signin-history', useClass: e const $i_unpin: Provider = { provide: 'ep:i/unpin', useClass: ep___i_unpin.default }; const $i_updateEmail: Provider = { provide: 'ep:i/update-email', useClass: ep___i_updateEmail.default }; const $i_update: Provider = { provide: 'ep:i/update', useClass: ep___i_update.default }; +const $i_move: Provider = { provide: 'ep:i/move', useClass: ep___i_move.default }; +const $i_knownAs: Provider = { provide: 'ep:i/known-as', useClass: ep___i_knownAs.default }; const $i_webhooks_create: Provider = { provide: 'ep:i/webhooks/create', useClass: ep___i_webhooks_create.default }; const $i_webhooks_list: Provider = { provide: 'ep:i/webhooks/list', useClass: ep___i_webhooks_list.default }; const $i_webhooks_show: Provider = { provide: 'ep:i/webhooks/show', useClass: ep___i_webhooks_show.default }; @@ -886,6 +890,8 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_unpin, $i_updateEmail, $i_update, + $i_move, + $i_knownAs, $i_webhooks_create, $i_webhooks_list, $i_webhooks_show, @@ -1215,6 +1221,8 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_unpin, $i_updateEmail, $i_update, + $i_move, + $i_knownAs, $i_webhooks_create, $i_webhooks_list, $i_webhooks_show, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index e33c2349cd..fd268c7912 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -220,6 +220,8 @@ import * as ep___i_signinHistory from './endpoints/i/signin-history.js'; import * as ep___i_unpin from './endpoints/i/unpin.js'; import * as ep___i_updateEmail from './endpoints/i/update-email.js'; import * as ep___i_update from './endpoints/i/update.js'; +import * as ep___i_move from './endpoints/i/move.js'; +import * as ep___i_knownAs from './endpoints/i/known-as.js'; import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js'; import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js'; import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js'; @@ -549,6 +551,8 @@ const eps = [ ['i/unpin', ep___i_unpin], ['i/update-email', ep___i_updateEmail], ['i/update', ep___i_update], + //['i/move', ep___i_move], + //['i/known-as', ep___i_knownAs], ['i/webhooks/create', ep___i_webhooks_create], ['i/webhooks/list', ep___i_webhooks_list], ['i/webhooks/show', ep___i_webhooks_show], diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index dfc2a582dc..2491d14235 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import Redis from 'ioredis'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { ChannelsRepository, NotesRepository } from '@/models/index.js'; +import type { ChannelsRepository, Note, NotesRepository } from '@/models/index.js'; import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; @@ -73,42 +73,67 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchChannel); } - const noteIdsRes = await this.redisClient.xrevrange( - `channelTimeline:${channel.id}`, - ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', - '-', - 'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1 + let timeline: Note[] = []; - if (noteIdsRes.length === 0) { - return []; + const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 + let noteIdsRes: [string, string[]][] = []; + + if (!ps.sinceId && !ps.sinceDate) { + noteIdsRes = await this.redisClient.xrevrange( + `channelTimeline:${channel.id}`, + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', + '-', + 'COUNT', limit); } - const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); + // redis から取得していないとき・取得数が足りないとき + if (noteIdsRes.length < limit) { + //#region Construct query + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('note.channelId = :channelId', { channelId: channel.id }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('note.channel', 'channel'); - if (noteIds.length === 0) { - return []; + if (me) { + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateMutedNoteQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + } + //#endregion + + timeline = await query.take(ps.limit).getMany(); + } else { + const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); + + if (noteIds.length === 0) { + return []; + } + + //#region Construct query + const query = this.notesRepository.createQueryBuilder('note') + .where('note.id IN (:...noteIds)', { noteIds: noteIds }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('note.channel', 'channel'); + + if (me) { + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateMutedNoteQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + } + //#endregion + + timeline = await query.getMany(); + timeline.sort((a, b) => a.id > b.id ? -1 : 1); } - //#region Construct query - const query = this.notesRepository.createQueryBuilder('note') - .where('note.id IN (:...noteIds)', { noteIds: noteIds }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel'); - - if (me) { - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateMutedNoteQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - } - //#endregion - - const timeline = await query.getMany(); - timeline.sort((a, b) => a.id > b.id ? -1 : 1); - if (me) this.activeUsersChart.read(me); return await this.noteEntityService.packMany(timeline, me); diff --git a/packages/backend/src/server/api/endpoints/i/known-as.ts b/packages/backend/src/server/api/endpoints/i/known-as.ts new file mode 100644 index 0000000000..964704d82b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/known-as.ts @@ -0,0 +1,92 @@ +import { Injectable } from '@nestjs/common'; +import ms from 'ms'; + +import { User } from '@/models/entities/User.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; + +import { AccountMoveService } from '@/core/AccountMoveService.js'; +import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ApiLoggerService } from '@/server/api/ApiLoggerService.js'; + +export const meta = { + tags: ['users'], + + secure: true, + requireCredential: true, + + limit: { + duration: ms('1day'), + max: 30, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5', + }, + notRemote: { + message: 'User is not remote. You can only migrate from other instances.', + code: 'NOT_REMOTE', + id: '4362f8dc-731f-4ad8-a694-be2a88922a24', + }, + uriNull: { + message: 'User ActivityPup URI is null.', + code: 'URI_NULL', + id: 'bf326f31-d430-4f97-9933-5d61e4d48a23', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + alsoKnownAs: { type: 'string' }, + }, + required: ['alsoKnownAs'], +} as const; + +@Injectable() +export default class extends Endpoint { + constructor( + private userEntityService: UserEntityService, + private remoteUserResolveService: RemoteUserResolveService, + private apiLoggerService: ApiLoggerService, + private accountMoveService: AccountMoveService, + ) { + super(meta, paramDef, async (ps, me) => { + // Check parameter + if (!ps.alsoKnownAs) throw new ApiError(meta.errors.noSuchUser); + + let unfiltered = ps.alsoKnownAs; + const updates = {} as Partial; + + if (!unfiltered) { + updates.alsoKnownAs = null; + } else { + // Parse user's input into the old account + if (unfiltered.startsWith('acct:')) unfiltered = unfiltered.substring(5); + if (unfiltered.startsWith('@')) unfiltered = unfiltered.substring(1); + if (!unfiltered.includes('@')) throw new ApiError(meta.errors.notRemote); + + const userAddress = unfiltered.split('@'); + // Retrieve the old account + const knownAs = await this.remoteUserResolveService.resolveUser(userAddress[0], userAddress[1]).catch((e) => { + this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`); + throw new ApiError(meta.errors.noSuchUser); + }); + + const toUrl: string | null = knownAs.uri; + if (!toUrl) throw new ApiError(meta.errors.uriNull); + // Only allow moving from a remote account + if (this.userEntityService.isLocalUser(knownAs)) throw new ApiError(meta.errors.notRemote); + + updates.alsoKnownAs = updates.alsoKnownAs?.concat([toUrl]) ?? [toUrl]; + } + + return await this.accountMoveService.createAlias(me, updates); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/move.ts b/packages/backend/src/server/api/endpoints/i/move.ts new file mode 100644 index 0000000000..ac76e1f620 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/move.ts @@ -0,0 +1,140 @@ +import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; + +import type { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; + +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; + +import { AccountMoveService } from '@/core/AccountMoveService.js'; +import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ApiLoggerService } from '@/server/api/ApiLoggerService.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; + +export const meta = { + tags: ['users'], + + secure: true, + requireCredential: true, + limit: { + duration: ms('1day'), + max: 5, + }, + + errors: { + noSuchMoveTarget: { + message: 'No such move target.', + code: 'NO_SUCH_MOVE_TARGET', + id: 'b5c90186-4ab0-49c8-9bba-a1f76c202ba4', + }, + remoteAccountForbids: { + message: + 'Remote account doesn\'t have proper \'Known As\' alias. Did you remember to set it?', + code: 'REMOTE_ACCOUNT_FORBIDS', + id: 'b5c90186-4ab0-49c8-9bba-a1f766282ba4', + }, + notRemote: { + message: 'User is not remote. You can only migrate to other instances.', + code: 'NOT_REMOTE', + id: '4362f8dc-731f-4ad8-a694-be2a88922a24', + }, + rootForbidden: { + message: 'The root can\'t migrate.', + code: 'NOT_ROOT_FORBIDDEN', + id: '4362e8dc-731f-4ad8-a694-be2a88922a24', + }, + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5', + }, + uriNull: { + message: 'User ActivityPup URI is null.', + code: 'URI_NULL', + id: 'bf326f31-d430-4f97-9933-5d61e4d48a23', + }, + localUriNull: { + message: 'Local User ActivityPup URI is null.', + code: 'URI_NULL', + id: '95ba11b9-90e8-43a5-ba16-7acc1ab32e71', + }, + alreadyMoved: { + message: 'Account was already moved to another account.', + code: 'ALREADY_MOVED', + id: 'b234a14e-9ebe-4581-8000-074b3c215962', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + moveToAccount: { type: 'string' }, + }, + required: ['moveToAccount'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.config) + private config: Config, + + private userEntityService: UserEntityService, + private remoteUserResolveService: RemoteUserResolveService, + private apiLoggerService: ApiLoggerService, + private accountMoveService: AccountMoveService, + private getterService: GetterService, + private apPersonService: ApPersonService, + ) { + super(meta, paramDef, async (ps, me) => { + // check parameter + if (!ps.moveToAccount) throw new ApiError(meta.errors.noSuchMoveTarget); + // abort if user is the root + if (me.isRoot) throw new ApiError(meta.errors.rootForbidden); + // abort if user has already moved + if (me.movedToUri) throw new ApiError(meta.errors.alreadyMoved); + + let unfiltered = ps.moveToAccount; + if (!unfiltered) throw new ApiError(meta.errors.noSuchMoveTarget); + + // parse user's input into the destination account + if (unfiltered.startsWith('acct:')) unfiltered = unfiltered.substring(5); + if (unfiltered.startsWith('@')) unfiltered = unfiltered.substring(1); + if (!unfiltered.includes('@')) throw new ApiError(meta.errors.notRemote); + + const userAddress = unfiltered.split('@'); + // retrieve the destination account + let moveTo = await this.remoteUserResolveService.resolveUser(userAddress[0], userAddress[1]).catch((e) => { + this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`); + throw new ApiError(meta.errors.noSuchMoveTarget); + }); + const remoteMoveTo = await this.getterService.getRemoteUser(moveTo.id); + if (!remoteMoveTo.uri) throw new ApiError(meta.errors.uriNull); + + // update local db + await this.apPersonService.updatePerson(remoteMoveTo.uri); + // retrieve updated user + moveTo = await this.apPersonService.resolvePerson(remoteMoveTo.uri); + // only allow moving to a remote account + if (this.userEntityService.isLocalUser(moveTo)) throw new ApiError(meta.errors.notRemote); + + let allowed = false; + + const fromUrl = `${this.config.url}/users/${me.id}`; + // make sure that the user has indicated the old account as an alias + moveTo.alsoKnownAs?.forEach((elem) => { + if (fromUrl.includes(elem)) allowed = true; + }); + + // abort if unintended + if (!(allowed && moveTo.uri && fromUrl)) throw new ApiError(meta.errors.remoteAccountForbids); + + return await this.accountMoveService.moveToRemote(me, moveTo); + }); + } +} diff --git a/packages/backend/test/docker-compose.yml b/packages/backend/test/docker-compose.yml index 5f95bec4c0..da6c01dda1 100644 --- a/packages/backend/test/docker-compose.yml +++ b/packages/backend/test/docker-compose.yml @@ -2,7 +2,7 @@ version: "3" services: redistest: - image: redis:6 + image: redis:7 ports: - "127.0.0.1:56312:6379" diff --git a/packages/backend/test/e2e/streaming.ts b/packages/backend/test/e2e/streaming.ts index b832117b37..d1394ef7a8 100644 --- a/packages/backend/test/e2e/streaming.ts +++ b/packages/backend/test/e2e/streaming.ts @@ -391,6 +391,8 @@ describe('Streaming', () => { }); }); + // XXX: QueryFailedError: duplicate key value violates unique constraint "IDX_347fec870eafea7b26c8a73bac" + /* describe('Hashtag Timeline', () => { test('指定したハッシュタグの投稿が流れる', () => new Promise(async done => { const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { @@ -410,45 +412,43 @@ describe('Streaming', () => { }); })); - // XXX: QueryFailedError: duplicate key value violates unique constraint "IDX_347fec870eafea7b26c8a73bac" + test('指定したハッシュタグの投稿が流れる (AND)', () => new Promise(async done => { + let fooCount = 0; + let barCount = 0; + let fooBarCount = 0; - // test('指定したハッシュタグの投稿が流れる (AND)', () => new Promise(async done => { - // let fooCount = 0; - // let barCount = 0; - // let fooBarCount = 0; + const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { + if (type === 'note') { + if (body.text === '#foo') fooCount++; + if (body.text === '#bar') barCount++; + if (body.text === '#foo #bar') fooBarCount++; + } + }, { + q: [ + ['foo', 'bar'], + ], + }); - // const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { - // if (type === 'note') { - // if (body.text === '#foo') fooCount++; - // if (body.text === '#bar') barCount++; - // if (body.text === '#foo #bar') fooBarCount++; - // } - // }, { - // q: [ - // ['foo', 'bar'], - // ], - // }); + post(chitose, { + text: '#foo', + }); - // post(chitose, { - // text: '#foo', - // }); + post(chitose, { + text: '#bar', + }); - // post(chitose, { - // text: '#bar', - // }); + post(chitose, { + text: '#foo #bar', + }); - // post(chitose, { - // text: '#foo #bar', - // }); - - // setTimeout(() => { - // assert.strictEqual(fooCount, 0); - // assert.strictEqual(barCount, 0); - // assert.strictEqual(fooBarCount, 1); - // ws.close(); - // done(); - // }, 3000); - // })); + setTimeout(() => { + assert.strictEqual(fooCount, 0); + assert.strictEqual(barCount, 0); + assert.strictEqual(fooBarCount, 1); + ws.close(); + done(); + }, 3000); + })); test('指定したハッシュタグの投稿が流れる (OR)', () => new Promise(async done => { let fooCount = 0; @@ -549,5 +549,6 @@ describe('Streaming', () => { }, 3000); })); }); + */ }); }); diff --git a/packages/backend/test/unit/misc/id.ts b/packages/backend/test/unit/misc/id.ts new file mode 100644 index 0000000000..ecd0e60a31 --- /dev/null +++ b/packages/backend/test/unit/misc/id.ts @@ -0,0 +1,44 @@ +import { aidRegExp, genAid, parseAid } from '@/misc/id/aid.js'; +import { genMeid, meidRegExp, parseMeid } from '@/misc/id/meid.js'; +import { genMeidg, meidgRegExp, parseMeidg } from '@/misc/id/meidg.js'; +import { genObjectId, objectIdRegExp, parseObjectId } from '@/misc/id/object-id.js'; +import { ulidRegExp, parseUlid } from '@/misc/id/ulid.js'; +import { ulid } from 'ulid'; +import { describe, test, expect } from '@jest/globals'; + +describe('misc:id', () => { + test('aid', () => { + const date = new Date(); + const gotAid = genAid(date); + expect(gotAid).toMatch(aidRegExp); + expect(parseAid(gotAid).date.getTime()).toBe(date.getTime()); + }); + + test('meid', () => { + const date = new Date(); + const gotMeid = genMeid(date); + expect(gotMeid).toMatch(meidRegExp); + expect(parseMeid(gotMeid).date.getTime()).toBe(date.getTime()); + }); + + test('meidg', () => { + const date = new Date(); + const gotMeidg = genMeidg(date); + expect(gotMeidg).toMatch(meidgRegExp); + expect(parseMeidg(gotMeidg).date.getTime()).toBe(date.getTime()); + }); + + test('objectid', () => { + const date = new Date(); + const gotObjectId = genObjectId(date); + expect(gotObjectId).toMatch(objectIdRegExp); + expect(Math.floor(parseObjectId(gotObjectId).date.getTime() / 1000)).toBe(Math.floor(date.getTime() / 1000)); + }); + + test('ulid', () => { + const date = new Date(); + const gotUlid = ulid(date.getTime()); + expect(gotUlid).toMatch(ulidRegExp); + expect(parseUlid(gotUlid).date.getTime()).toBe(date.getTime()); + }); +}); diff --git a/packages/frontend/.storybook/preview-head.html b/packages/frontend/.storybook/preview-head.html index 097825bf65..ab694f64fb 100644 --- a/packages/frontend/.storybook/preview-head.html +++ b/packages/frontend/.storybook/preview-head.html @@ -1,6 +1,12 @@ + + diff --git a/packages/frontend/src/components/MkAccountMoved.vue b/packages/frontend/src/components/MkAccountMoved.vue new file mode 100644 index 0000000000..fd472de6c1 --- /dev/null +++ b/packages/frontend/src/components/MkAccountMoved.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 7649eb54ea..93c1f89199 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -17,8 +17,8 @@ @@ -32,11 +32,11 @@
- {{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }} - {{ cancelText ?? i18n.ts.cancel }} + {{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }} + {{ cancelText ?? i18n.ts.cancel }}
- {{ action.text }} + {{ action.text }}
diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index a5a39108d6..0ae182ce32 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -439,6 +439,7 @@ defineExpose({ &.asDrawer { width: 100% !important; + padding: 12px 0 max(env(safe-area-inset-bottom, 0px), 12px) 0; > .emojis { ::v-deep(section) { diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue index 30b5391e9a..687abed632 100644 --- a/packages/frontend/src/components/MkWindow.vue +++ b/packages/frontend/src/components/MkWindow.vue @@ -11,15 +11,21 @@
- + - + + + - +
@@ -27,7 +33,7 @@
-