Compare commits
188 Commits
13.13.0-be
...
oauth2oriz
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aa350ced4 | ||
|
|
93364cb922 | ||
|
|
1f38d624c0 | ||
|
|
deb9ba146f | ||
|
|
833df85457 | ||
|
|
d340860b8b | ||
|
|
d1534ec64e | ||
|
|
16a73dea26 | ||
|
|
d0d9b4b19c | ||
|
|
ca7c3c6063 | ||
|
|
cb2089981a | ||
|
|
daa18efc99 | ||
|
|
0b3fd09bb0 | ||
|
|
1567a2ea3e | ||
|
|
ecdd1c115a | ||
|
|
d7e0e9feca | ||
|
|
7ed8fbbba3 | ||
|
|
5db1126db6 | ||
|
|
628377187a | ||
|
|
b57d40ed09 | ||
|
|
1755c75647 | ||
|
|
c55d9784fe | ||
|
|
52e7bdd817 | ||
|
|
260ac0ecfc | ||
|
|
b81e6eeff9 | ||
|
|
15f859d562 | ||
|
|
b938bc7c52 | ||
|
|
20efdc78e2 | ||
|
|
aa87fb2f50 | ||
|
|
95dd66a0ba | ||
|
|
c83628e5d0 | ||
|
|
d0245b59bc | ||
|
|
4c12a9d882 | ||
|
|
d245306d90 | ||
|
|
0d2041f5aa | ||
|
|
b5df8ca0fd | ||
|
|
3b8b9a658a | ||
|
|
413fa63093 | ||
|
|
347a4a0b93 | ||
|
|
bfe6e5abb8 | ||
|
|
78c6bb1cc2 | ||
|
|
9a5fa00f9a | ||
|
|
967989c5f8 | ||
|
|
c25836bc1a | ||
|
|
9022971fb9 | ||
|
|
cb5cfd4296 | ||
|
|
cbaae2201f | ||
|
|
2c6379649a | ||
|
|
150a6f80d0 | ||
|
|
c0f63234d7 | ||
|
|
9c29880f8b | ||
|
|
2b23120664 | ||
|
|
b6f6819b76 | ||
|
|
77ad8c0ac6 | ||
|
|
92f3ae2d9c | ||
|
|
94ea15d2d7 | ||
|
|
8e7fc1ed98 | ||
|
|
937e9be34e | ||
|
|
027c5734a4 | ||
|
|
a688bd1061 | ||
|
|
87dbe5e9fb | ||
|
|
f6d9cf1ef1 | ||
|
|
333d6a9283 | ||
|
|
deb4429e3a | ||
|
|
6385ca9b0d | ||
|
|
515af3176a | ||
|
|
0cc9d5aa32 | ||
|
|
401575a903 | ||
|
|
88fd7f2758 | ||
|
|
5034e6cd69 | ||
|
|
2f566e4173 | ||
|
|
179640af30 | ||
|
|
098d0670a3 | ||
|
|
71f62b9d89 | ||
|
|
82c9820ac8 | ||
|
|
39526d0225 | ||
|
|
049dbfeb66 | ||
|
|
8ea1288234 | ||
|
|
a55d3f7382 | ||
|
|
f5a6509663 | ||
|
|
a4fb17620c | ||
|
|
0621e94c7d | ||
|
|
1b1f82a2e2 | ||
|
|
d48172e9d1 | ||
|
|
58a898dfe0 | ||
|
|
d23ad8b511 | ||
|
|
8099bc24e1 | ||
|
|
ef354e94f2 | ||
|
|
7bb8c71543 | ||
|
|
a2c0573f84 | ||
|
|
5d922e3084 | ||
|
|
f0b5860b9c | ||
|
|
fd4c43786a | ||
|
|
60cc7f62e6 | ||
|
|
dc27ba6f03 | ||
|
|
3fe1c862f6 | ||
|
|
33a2c0b59e | ||
|
|
e8c5117b2d | ||
|
|
e2261b63e9 | ||
|
|
8c7bcdf998 | ||
|
|
f5dfb64a52 | ||
|
|
fa7fd9ce25 | ||
|
|
63971f1cd8 | ||
|
|
b1313fbca8 | ||
|
|
f1b0c54f6e | ||
|
|
94c09f1441 | ||
|
|
46222d0258 | ||
|
|
c59a30ec09 | ||
|
|
f3a16bcd6d | ||
|
|
f69627939b | ||
|
|
e8420ad90b | ||
|
|
6182a1cb2c | ||
|
|
308ab8f177 | ||
|
|
359fbd78c1 | ||
|
|
dd733ec1d0 | ||
|
|
5e680500e5 | ||
|
|
0465e74521 | ||
|
|
34a32a8334 | ||
|
|
3941c73db0 | ||
|
|
703f3a8e37 | ||
|
|
6032c2be1c | ||
|
|
13870c63b0 | ||
|
|
88083925ce | ||
|
|
95b2148bfe | ||
|
|
8ccf954065 | ||
|
|
e456c6a9c1 | ||
|
|
68c5426fd5 | ||
|
|
29856a9129 | ||
|
|
f4943bc5bb | ||
|
|
aeb8955ca2 | ||
|
|
981e6f996e | ||
|
|
8263cc0094 | ||
|
|
fa051a2a5f | ||
|
|
ce252143c3 | ||
|
|
259be258aa | ||
|
|
2d5bb40ad0 | ||
|
|
db7fb1c688 | ||
|
|
6b0685a25c | ||
|
|
618d07158a | ||
|
|
a63a417fd4 | ||
|
|
4443dba717 | ||
|
|
565c502bbf | ||
|
|
9e716fd813 | ||
|
|
34e1b52b38 | ||
|
|
0cb3c7481c | ||
|
|
db623dda22 | ||
|
|
1177528c25 | ||
|
|
a7abf941c0 | ||
|
|
5230ec883e | ||
|
|
482b00df77 | ||
|
|
91d790bbb6 | ||
|
|
40295ae57d | ||
|
|
3857cd589f | ||
|
|
9eaca966a4 | ||
|
|
8bdf0dd135 | ||
|
|
eedc8049db | ||
|
|
23f272cc7d | ||
|
|
15450b18e8 | ||
|
|
50aeaf7498 | ||
|
|
861bfa06a8 | ||
|
|
3b30ee3130 | ||
|
|
f6830885d7 | ||
|
|
ff56511638 | ||
|
|
337dd97b49 | ||
|
|
146e89edd0 | ||
|
|
cd82748889 | ||
|
|
2c0b10b0ee | ||
|
|
6c09361ec6 | ||
|
|
3089a86c8e | ||
|
|
ec2f05d4f7 | ||
|
|
31a8129cb9 | ||
|
|
8cc6c2c864 | ||
|
|
a4de927df8 | ||
|
|
a2c77a0944 | ||
|
|
d8a564c6a0 | ||
|
|
ff94b64c91 | ||
|
|
1cc616b86c | ||
|
|
6addf9002c | ||
|
|
6dd219b6c7 | ||
|
|
a535142e82 | ||
|
|
9521519cb8 | ||
|
|
e11f82c300 | ||
|
|
aba0755880 | ||
|
|
821bb1c476 | ||
|
|
14da0a65f7 | ||
|
|
8e5d31eb5c | ||
|
|
f8f3304164 | ||
|
|
d7efdd7123 |
24
.github/ISSUE_TEMPLATE/01_bug-report.md
vendored
24
.github/ISSUE_TEMPLATE/01_bug-report.md
vendored
@@ -39,8 +39,22 @@ Please include errors from the developer console and/or server log files if you
|
|||||||
<!-- Tell us where on the platform it happens -->
|
<!-- Tell us where on the platform it happens -->
|
||||||
<!-- DO NOT WRITE "latest". Please provide the specific version. -->
|
<!-- DO NOT WRITE "latest". Please provide the specific version. -->
|
||||||
|
|
||||||
Misskey version:
|
### 💻 Frontend
|
||||||
PostgreSQL version:
|
* Model and OS of the device(s):
|
||||||
Redis version:
|
<!-- Example: MacBook Pro (14inch, 2021), macOS Ventura 13.4 -->
|
||||||
Your OS:
|
* Browser:
|
||||||
Your browser:
|
<!-- Example: Chrome 113.0.5672.126 -->
|
||||||
|
* Server URL:
|
||||||
|
<!-- Example: misskey.io -->
|
||||||
|
* Misskey:
|
||||||
|
13.x.x
|
||||||
|
|
||||||
|
### 🛰 Backend (for server admin)
|
||||||
|
<!-- If you are using a managed service, put that after the version. -->
|
||||||
|
|
||||||
|
* Installation Method or Hosting Service: <!-- Example: docker compose, k8s/docker, systemd, "Misskey install shell script", development environment -->
|
||||||
|
* Misskey: 13.x.x
|
||||||
|
* Node: 18.x.x
|
||||||
|
* PostgreSQL: 15.x.x
|
||||||
|
* Redis: 7.x.x
|
||||||
|
* OS and Architecture: <!-- Example: Ubuntu 22.04.2 LTS aarch64 -->
|
||||||
|
|||||||
37
CHANGELOG.md
37
CHANGELOG.md
@@ -12,6 +12,32 @@
|
|||||||
|
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
## 13.x.x (unreleased)
|
||||||
|
|
||||||
|
### Client
|
||||||
|
- Fix: サーバーメトリクスが90度傾いている
|
||||||
|
|
||||||
|
## 13.13.2
|
||||||
|
|
||||||
|
### General
|
||||||
|
- エラー時や項目が存在しないときなどのアイコン画像をサーバー管理者が設定できるように
|
||||||
|
- ロールが付与されているユーザーリストを非公開にできるように
|
||||||
|
- サーバーの負荷が非常に高いため、ユーザー統計表示機能を削除しました
|
||||||
|
|
||||||
|
### Client
|
||||||
|
- Fix: タブがバックグラウンドでもstreamが切断されないように
|
||||||
|
|
||||||
|
### Server
|
||||||
|
- Fix: キャッシュが溜まり続けないように
|
||||||
|
|
||||||
|
## 13.13.1
|
||||||
|
|
||||||
|
### Client
|
||||||
|
- Fix: タブがアクティブな間はstreamが切断されないように
|
||||||
|
|
||||||
|
### Server
|
||||||
|
- Fix: api/metaで`TypeError: JSON5.parse is not a function`エラーが発生する問題を修正
|
||||||
|
|
||||||
## 13.13.0
|
## 13.13.0
|
||||||
|
|
||||||
### General
|
### General
|
||||||
@@ -32,16 +58,23 @@
|
|||||||
- ハッシュタグのノート一覧ページから、そのハッシュタグで投稿するボタンを追加
|
- ハッシュタグのノート一覧ページから、そのハッシュタグで投稿するボタンを追加
|
||||||
- アカウント初期設定ウィザードに戻るボタンを追加
|
- アカウント初期設定ウィザードに戻るボタンを追加
|
||||||
- アカウントの初期設定ウィザードにあとでボタンを追加
|
- アカウントの初期設定ウィザードにあとでボタンを追加
|
||||||
|
- サーバーにカスタム絵文字の種類が多い場合のパフォーマンスの改善
|
||||||
- Fix: URLプレビューで情報が取得できなかった際の挙動を修正
|
- Fix: URLプレビューで情報が取得できなかった際の挙動を修正
|
||||||
- Fix: Safari、Firefoxでの新規登録時、パスワードマネージャーにメールアドレスが登録されていた挙動を修正
|
- Fix: Safari、Firefoxでの新規登録時、パスワードマネージャーにメールアドレスが登録されていた挙動を修正
|
||||||
- Fix: ロールタイムラインが無効でも投稿が流れてしまう問題の修正
|
- Fix: ロールタイムラインが無効でも投稿が流れてしまう問題の修正
|
||||||
- Fix: ロールタイムラインにて全ての投稿が流れてしまう問題の修正
|
- Fix: ロールタイムラインにて全ての投稿が流れてしまう問題の修正
|
||||||
- Fix: 「アクセストークンの管理」画面でアプリの情報が表示されない問題の修正
|
- Fix: 「アクセストークンの管理」画面でアプリの情報が表示されない問題の修正
|
||||||
|
- Fix: Firefoxにおける絵文字ピッカーのTabキーフォーカス問題の修正
|
||||||
|
- Fix: フォローボタンがテーマのカラースキームによって視認性が悪くなる問題を修正
|
||||||
|
- 新しいプロパティ `fgOnWhite` が追加されました
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
- bullをbull-mqにアップグレードし、ジョブキューのパフォーマンスを改善
|
- bullをbull-mqにアップグレードし、ジョブキューのパフォーマンスを改善
|
||||||
- ストリーミングのパフォーマンスを改善
|
- ストリーミングのパフォーマンスを改善
|
||||||
|
- Fix: 無効化されたアンテナにアクセスがあった際に再度有効化するように
|
||||||
- Fix: お知らせの画像URLを空にできない問題を修正
|
- Fix: お知らせの画像URLを空にできない問題を修正
|
||||||
|
- Fix: i/notificationsのsinceIdが機能しない問題を修正
|
||||||
|
- Fix: pageのピン留めを解除することができない問題を修正
|
||||||
|
|
||||||
## 13.12.2
|
## 13.12.2
|
||||||
|
|
||||||
@@ -81,11 +114,12 @@ Meilisearchの設定に`index`が必要になりました。値はMisskeyサー
|
|||||||
## 13.12.0
|
## 13.12.0
|
||||||
|
|
||||||
### NOTE
|
### NOTE
|
||||||
- Node.js 18.6.0以上が必要になりました
|
- Node.js 18.16.0以上が必要になりました
|
||||||
|
|
||||||
### General
|
### General
|
||||||
- アカウントの引っ越し(フォロワー引き継ぎ)に対応
|
- アカウントの引っ越し(フォロワー引き継ぎ)に対応
|
||||||
- Meilisearchを全文検索に使用できるようになりました
|
- Meilisearchを全文検索に使用できるようになりました
|
||||||
|
* 「フォロワーのみ」の投稿は検索結果に表示されません。
|
||||||
- 新規登録前に簡潔なルールをユーザーに表示できる、サーバールール機能を追加
|
- 新規登録前に簡潔なルールをユーザーに表示できる、サーバールール機能を追加
|
||||||
- ユーザーへの自分用メモ機能
|
- ユーザーへの自分用メモ機能
|
||||||
* ユーザーに対して、自分だけが見られるメモを追加できるようになりました。
|
* ユーザーに対して、自分だけが見られるメモを追加できるようになりました。
|
||||||
@@ -366,6 +400,7 @@ Meilisearchの設定に`index`が必要になりました。値はMisskeyサー
|
|||||||
- アンテナでCWも検索対象にするように
|
- アンテナでCWも検索対象にするように
|
||||||
- ノートの操作部をホバー時のみ表示するオプションを追加
|
- ノートの操作部をホバー時のみ表示するオプションを追加
|
||||||
- サウンドを追加
|
- サウンドを追加
|
||||||
|
- enhance(client): MFMのx2, scale, positionが含まれていたらノートをたたむように
|
||||||
- サーバーのパフォーマンスを改善
|
- サーバーのパフォーマンスを改善
|
||||||
|
|
||||||
### Bugfixes
|
### Bugfixes
|
||||||
|
|||||||
@@ -169,25 +169,20 @@ describe('After user signed in', () => {
|
|||||||
cy.get('[data-cy-user-setup-user-description] textarea').type('ほげ');
|
cy.get('[data-cy-user-setup-user-description] textarea').type('ほげ');
|
||||||
// TODO: アイコン設定テスト
|
// TODO: アイコン設定テスト
|
||||||
|
|
||||||
cy.get('[data-cy-user-setup-back]').click();
|
|
||||||
cy.get('[data-cy-user-setup-continue]').click();
|
cy.get('[data-cy-user-setup-continue]').click();
|
||||||
|
|
||||||
// プライバシー設定
|
// プライバシー設定
|
||||||
|
|
||||||
cy.get('[data-cy-user-setup-back]').click();
|
|
||||||
cy.get('[data-cy-user-setup-continue]').click();
|
cy.get('[data-cy-user-setup-continue]').click();
|
||||||
|
|
||||||
// フォローはスキップ
|
// フォローはスキップ
|
||||||
|
|
||||||
cy.get('[data-cy-user-setup-back]').click();
|
|
||||||
cy.get('[data-cy-user-setup-continue]').click();
|
cy.get('[data-cy-user-setup-continue]').click();
|
||||||
|
|
||||||
// プッシュ通知設定はスキップ
|
// プッシュ通知設定はスキップ
|
||||||
|
|
||||||
cy.get('[data-cy-user-setup-back]').click();
|
|
||||||
cy.get('[data-cy-user-setup-continue]').click();
|
cy.get('[data-cy-user-setup-continue]').click();
|
||||||
|
|
||||||
cy.get('[data-cy-user-setup-back]').click();
|
|
||||||
cy.get('[data-cy-user-setup-continue]').click();
|
cy.get('[data-cy-user-setup-continue]').click();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import './commands'
|
|||||||
|
|
||||||
Cypress.on('uncaught:exception', (err, runnable) => {
|
Cypress.on('uncaught:exception', (err, runnable) => {
|
||||||
if ([
|
if ([
|
||||||
|
'The source image cannot be decoded',
|
||||||
|
|
||||||
// Chrome
|
// Chrome
|
||||||
'ResizeObserver loop limit exceeded',
|
'ResizeObserver loop limit exceeded',
|
||||||
|
|
||||||
|
|||||||
@@ -267,8 +267,8 @@ start: "البداية"
|
|||||||
home: "الرئيسي"
|
home: "الرئيسي"
|
||||||
remoteUserCaution: "هذه المعلومات قد لا تكون مكتملة بما أن المستخدم من مثيل بعيد."
|
remoteUserCaution: "هذه المعلومات قد لا تكون مكتملة بما أن المستخدم من مثيل بعيد."
|
||||||
activity: "النشاط"
|
activity: "النشاط"
|
||||||
images: "الصور"
|
images: "صور"
|
||||||
image: "الصور"
|
image: "صور"
|
||||||
birthday: "تاريخ الميلاد"
|
birthday: "تاريخ الميلاد"
|
||||||
yearsOld: "{age} سنة"
|
yearsOld: "{age} سنة"
|
||||||
registeredDate: "انضم في"
|
registeredDate: "انضم في"
|
||||||
@@ -1331,7 +1331,7 @@ _pages:
|
|||||||
text: "نص"
|
text: "نص"
|
||||||
textarea: "حقل نصي"
|
textarea: "حقل نصي"
|
||||||
section: "قسم"
|
section: "قسم"
|
||||||
image: "الصور"
|
image: "صور"
|
||||||
button: "زرّ"
|
button: "زرّ"
|
||||||
note: "ملاحظة مضمّنة"
|
note: "ملاحظة مضمّنة"
|
||||||
_note:
|
_note:
|
||||||
|
|||||||
@@ -991,7 +991,7 @@ postToTheChannel: "In Kanal senden"
|
|||||||
cannotBeChangedLater: "Kann später nicht mehr geändert werden."
|
cannotBeChangedLater: "Kann später nicht mehr geändert werden."
|
||||||
reactionAcceptance: "Reaktionsannahme"
|
reactionAcceptance: "Reaktionsannahme"
|
||||||
likeOnly: "Nur \"Gefällt mir\""
|
likeOnly: "Nur \"Gefällt mir\""
|
||||||
likeOnlyForRemote: "Nur \"Gefällt mir\" für fremde Instanzen"
|
likeOnlyForRemote: "Alle (Nur \"Gefällt mir\" für fremde Instanzen)"
|
||||||
nonSensitiveOnly: "Keine Sensitiven"
|
nonSensitiveOnly: "Keine Sensitiven"
|
||||||
nonSensitiveOnlyForLocalLikeOnlyForRemote: "Keine Sensitiven (Nur \"Gefällt mir\" von fremden Instanzen)"
|
nonSensitiveOnlyForLocalLikeOnlyForRemote: "Keine Sensitiven (Nur \"Gefällt mir\" von fremden Instanzen)"
|
||||||
rolesAssignedToMe: "Mir zugewiesene Rollen"
|
rolesAssignedToMe: "Mir zugewiesene Rollen"
|
||||||
@@ -1060,6 +1060,9 @@ cancelReactionConfirm: "Möchtest du deine Reaktion wirklich löschen?"
|
|||||||
changeReactionConfirm: "Möchtest du deine Reaktion wirklich ändern?"
|
changeReactionConfirm: "Möchtest du deine Reaktion wirklich ändern?"
|
||||||
later: "Später"
|
later: "Später"
|
||||||
goToMisskey: "Zu Misskey"
|
goToMisskey: "Zu Misskey"
|
||||||
|
additionalEmojiDictionary: "Zusätzliche Emoji-Wörterbücher"
|
||||||
|
installed: "Installiert"
|
||||||
|
branding: "Branding"
|
||||||
_initialAccountSetting:
|
_initialAccountSetting:
|
||||||
accountCreated: "Dein Konto wurde erfolgreich erstellt!"
|
accountCreated: "Dein Konto wurde erfolgreich erstellt!"
|
||||||
letsStartAccountSetup: "Lass uns nun dein Konto einrichten."
|
letsStartAccountSetup: "Lass uns nun dein Konto einrichten."
|
||||||
@@ -1091,7 +1094,7 @@ _accountMigration:
|
|||||||
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."
|
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."
|
||||||
movedAndCannotBeUndone: "\nDieses Konto wurde migriert.\nDiese Aktion ist unwiderruflich."
|
movedAndCannotBeUndone: "\nDieses Konto wurde migriert.\nDiese Aktion ist unwiderruflich."
|
||||||
postMigrationNote: "Dieses Konto wird 24 Stunden nach Abschluss der Migration allen Konten, denen es derzeit folgt, nicht mehr folgen.\n\nSowohl die Anzahl der Follower als auch die der Konten, denen dieses Konto folgt, wird dann auf Null gesetzt. Um zu vermeiden, dass Follower dieses Kontos dessen Beiträge, welche nur für Follower bestimmt sind, nicht mehr sehen können, werden sie diesem Konto jedoch weiterhin folgen."
|
postMigrationNote: "Dieses Konto wird 24 Stunden nach Abschluss der Migration allen Konten, denen es derzeit folgt, nicht mehr folgen.\n\nSowohl die Anzahl der Follower als auch die der Konten, denen dieses Konto folgt, wird dann auf Null gesetzt. Um zu vermeiden, dass Follower dieses Kontos dessen Beiträge, welche nur für Follower bestimmt sind, nicht mehr sehen können, werden sie diesem Konto jedoch weiterhin folgen."
|
||||||
movedTo: "Umzugsziel:"
|
movedTo: "Neues Konto:"
|
||||||
_achievements:
|
_achievements:
|
||||||
earnedAt: "Freigeschaltet am"
|
earnedAt: "Freigeschaltet am"
|
||||||
_types:
|
_types:
|
||||||
@@ -1345,7 +1348,7 @@ _role:
|
|||||||
condition: "Bedingung"
|
condition: "Bedingung"
|
||||||
isConditionalRole: "Dies ist eine konditionale Rolle."
|
isConditionalRole: "Dies ist eine konditionale Rolle."
|
||||||
isPublic: "Öffentliche Rolle"
|
isPublic: "Öffentliche Rolle"
|
||||||
descriptionOfIsPublic: "Ist dies aktiviert, so kann jeder die Liste der Benutzer, die dieser Rolle zugewiesen sind, einsehen. Zusätzlich wird diese Rolle im Profil zugewiesener Benutzer angezeigt."
|
descriptionOfIsPublic: "Diese Rolle wird im Profil zugewiesener Benutzer angezeigt."
|
||||||
options: "Optionen"
|
options: "Optionen"
|
||||||
policies: "Richtlinien"
|
policies: "Richtlinien"
|
||||||
baseRole: "Rollenvorlage"
|
baseRole: "Rollenvorlage"
|
||||||
@@ -1354,8 +1357,8 @@ _role:
|
|||||||
iconUrl: "Icon-URL"
|
iconUrl: "Icon-URL"
|
||||||
asBadge: "Als Abzeichen anzeigen"
|
asBadge: "Als Abzeichen anzeigen"
|
||||||
descriptionOfAsBadge: "Ist dies aktiviert, so wird das Icon dieser Rolle an der Seite der Namen von Benutzern mit dieser Rolle angezeigt."
|
descriptionOfAsBadge: "Ist dies aktiviert, so wird das Icon dieser Rolle an der Seite der Namen von Benutzern mit dieser Rolle angezeigt."
|
||||||
isExplorable: "Rollenchronik veröffentlichen"
|
isExplorable: "Benutzerliste veröffentlichen"
|
||||||
descriptionOfIsExplorable: "Ist dies aktiviert, so ist die Rollenchronik dieser Rolle frei zugänglich. Die Chronik von Rollen, welche nicht öffentlich sind, wird auch bei Aktivierung nicht veröffentlicht."
|
descriptionOfIsExplorable: "Ist dies aktiviert, so ist die Chronik dieser Rolle, sowie eine Liste der Benutzer mit dieser Rolle, frei zugänglich."
|
||||||
displayOrder: "Position"
|
displayOrder: "Position"
|
||||||
descriptionOfDisplayOrder: "Je höher die Nummer, desto höher die UI-Position."
|
descriptionOfDisplayOrder: "Je höher die Nummer, desto höher die UI-Position."
|
||||||
canEditMembersByModerator: "Moderatoren können Benutzern diese Rolle zuweisen"
|
canEditMembersByModerator: "Moderatoren können Benutzern diese Rolle zuweisen"
|
||||||
|
|||||||
@@ -991,7 +991,7 @@ postToTheChannel: "Post to channel"
|
|||||||
cannotBeChangedLater: "This cannot be changed later."
|
cannotBeChangedLater: "This cannot be changed later."
|
||||||
reactionAcceptance: "Reaction Acceptance"
|
reactionAcceptance: "Reaction Acceptance"
|
||||||
likeOnly: "Only likes"
|
likeOnly: "Only likes"
|
||||||
likeOnlyForRemote: "Only likes for remote instances"
|
likeOnlyForRemote: "All (Only likes for remote instances)"
|
||||||
nonSensitiveOnly: "Non-sensitive only"
|
nonSensitiveOnly: "Non-sensitive only"
|
||||||
nonSensitiveOnlyForLocalLikeOnlyForRemote: "Non-sensitive only (Only likes from remote)"
|
nonSensitiveOnlyForLocalLikeOnlyForRemote: "Non-sensitive only (Only likes from remote)"
|
||||||
rolesAssignedToMe: "Roles assigned to me"
|
rolesAssignedToMe: "Roles assigned to me"
|
||||||
@@ -1060,6 +1060,9 @@ cancelReactionConfirm: "Really delete your reaction?"
|
|||||||
changeReactionConfirm: "Really change your reaction?"
|
changeReactionConfirm: "Really change your reaction?"
|
||||||
later: "Later"
|
later: "Later"
|
||||||
goToMisskey: "To Misskey"
|
goToMisskey: "To Misskey"
|
||||||
|
additionalEmojiDictionary: "Additional emoji dictionaries"
|
||||||
|
installed: "Installed"
|
||||||
|
branding: "Branding"
|
||||||
_initialAccountSetting:
|
_initialAccountSetting:
|
||||||
accountCreated: "Your account was successfully created!"
|
accountCreated: "Your account was successfully created!"
|
||||||
letsStartAccountSetup: "For starters, let's set up your profile."
|
letsStartAccountSetup: "For starters, let's set up your profile."
|
||||||
@@ -1091,7 +1094,7 @@ _accountMigration:
|
|||||||
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."
|
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."
|
||||||
movedAndCannotBeUndone: "\nThis account has been migrated.\nMigration cannot be reversed."
|
movedAndCannotBeUndone: "\nThis account has been migrated.\nMigration cannot be reversed."
|
||||||
postMigrationNote: "This account will unfollow all accounts it is currently following 24 hours after migration finishes.\nBoth the number of follows and followers will then become zero. To avoid your followers from being unable to see followers only posts of this account, they will however continue following this account."
|
postMigrationNote: "This account will unfollow all accounts it is currently following 24 hours after migration finishes.\nBoth the number of follows and followers will then become zero. To avoid your followers from being unable to see followers only posts of this account, they will however continue following this account."
|
||||||
movedTo: "Account to move to:"
|
movedTo: "New account:"
|
||||||
_achievements:
|
_achievements:
|
||||||
earnedAt: "Unlocked at"
|
earnedAt: "Unlocked at"
|
||||||
_types:
|
_types:
|
||||||
@@ -1345,7 +1348,7 @@ _role:
|
|||||||
condition: "Condition"
|
condition: "Condition"
|
||||||
isConditionalRole: "This is a conditional role."
|
isConditionalRole: "This is a conditional role."
|
||||||
isPublic: "Public role"
|
isPublic: "Public role"
|
||||||
descriptionOfIsPublic: "Anyone will be able to view a list of users assigned to this role. In addition, this role will be displayed in the profiles of assigned users."
|
descriptionOfIsPublic: "This role will be displayed in the profiles of assigned users."
|
||||||
options: "Options"
|
options: "Options"
|
||||||
policies: "Policies"
|
policies: "Policies"
|
||||||
baseRole: "Role template"
|
baseRole: "Role template"
|
||||||
@@ -1354,8 +1357,8 @@ _role:
|
|||||||
iconUrl: "Icon URL"
|
iconUrl: "Icon URL"
|
||||||
asBadge: "Show as badge"
|
asBadge: "Show as badge"
|
||||||
descriptionOfAsBadge: "This role's icon will be displayed next to the username of users with this role if turned on."
|
descriptionOfAsBadge: "This role's icon will be displayed next to the username of users with this role if turned on."
|
||||||
isExplorable: "Role timeline is public"
|
isExplorable: "Make role explorable"
|
||||||
descriptionOfIsExplorable: "This role's timeline will become publicly accessible if enabled. Timelines of non-public roles will not be made public even if set."
|
descriptionOfIsExplorable: "This role's timeline and the list of users with this will be made public if enabled."
|
||||||
displayOrder: "Position"
|
displayOrder: "Position"
|
||||||
descriptionOfDisplayOrder: "The higher the number, the higher its UI position."
|
descriptionOfDisplayOrder: "The higher the number, the higher its UI position."
|
||||||
canEditMembersByModerator: "Allow moderators to edit the list of members for this role"
|
canEditMembersByModerator: "Allow moderators to edit the list of members for this role"
|
||||||
|
|||||||
3
locales/index.d.ts
vendored
3
locales/index.d.ts
vendored
@@ -1063,6 +1063,9 @@ export interface Locale {
|
|||||||
"changeReactionConfirm": string;
|
"changeReactionConfirm": string;
|
||||||
"later": string;
|
"later": string;
|
||||||
"goToMisskey": string;
|
"goToMisskey": string;
|
||||||
|
"additionalEmojiDictionary": string;
|
||||||
|
"installed": string;
|
||||||
|
"branding": string;
|
||||||
"_initialAccountSetting": {
|
"_initialAccountSetting": {
|
||||||
"accountCreated": string;
|
"accountCreated": string;
|
||||||
"letsStartAccountSetup": string;
|
"letsStartAccountSetup": string;
|
||||||
|
|||||||
@@ -1060,6 +1060,9 @@ cancelReactionConfirm: "リアクションを取り消しますか?"
|
|||||||
changeReactionConfirm: "リアクションを変更しますか?"
|
changeReactionConfirm: "リアクションを変更しますか?"
|
||||||
later: "あとで"
|
later: "あとで"
|
||||||
goToMisskey: "Misskeyへ"
|
goToMisskey: "Misskeyへ"
|
||||||
|
additionalEmojiDictionary: "絵文字の追加辞書"
|
||||||
|
installed: "インストール済み"
|
||||||
|
branding: "ブランディング"
|
||||||
|
|
||||||
_initialAccountSetting:
|
_initialAccountSetting:
|
||||||
accountCreated: "アカウントの作成が完了しました!"
|
accountCreated: "アカウントの作成が完了しました!"
|
||||||
@@ -1349,8 +1352,8 @@ _role:
|
|||||||
conditional: "コンディショナル"
|
conditional: "コンディショナル"
|
||||||
condition: "条件"
|
condition: "条件"
|
||||||
isConditionalRole: "これはコンディショナルロールです。"
|
isConditionalRole: "これはコンディショナルロールです。"
|
||||||
isPublic: "ロールを公開"
|
isPublic: "公開ロール"
|
||||||
descriptionOfIsPublic: "ロールにアサインされたユーザーを誰でも見ることができます。また、ユーザーのプロフィールでこのロールが表示されます。"
|
descriptionOfIsPublic: "ユーザーのプロフィールでこのロールが表示されます。"
|
||||||
options: "オプション"
|
options: "オプション"
|
||||||
policies: "ポリシー"
|
policies: "ポリシー"
|
||||||
baseRole: "ベースロール"
|
baseRole: "ベースロール"
|
||||||
@@ -1359,8 +1362,8 @@ _role:
|
|||||||
iconUrl: "アイコン画像のURL"
|
iconUrl: "アイコン画像のURL"
|
||||||
asBadge: "バッジとして表示"
|
asBadge: "バッジとして表示"
|
||||||
descriptionOfAsBadge: "オンにすると、ユーザー名の横にロールのアイコンが表示されます。"
|
descriptionOfAsBadge: "オンにすると、ユーザー名の横にロールのアイコンが表示されます。"
|
||||||
isExplorable: "ロールタイムラインを公開"
|
isExplorable: "ユーザーを見つけやすくする"
|
||||||
descriptionOfIsExplorable: "オンにすると、ロールのタイムラインを公開します。ロールの公開がオフの場合、タイムラインの公開はされません。"
|
descriptionOfIsExplorable: "オンにすると、「みつける」でメンバー一覧が公開されるほか、ロールのタイムラインが利用可能になります。"
|
||||||
displayOrder: "表示順"
|
displayOrder: "表示順"
|
||||||
descriptionOfDisplayOrder: "数値が大きいほどUI上で先頭に表示されます。"
|
descriptionOfDisplayOrder: "数値が大きいほどUI上で先頭に表示されます。"
|
||||||
canEditMembersByModerator: "モデレーターのメンバー編集を許可"
|
canEditMembersByModerator: "モデレーターのメンバー編集を許可"
|
||||||
|
|||||||
@@ -47,11 +47,13 @@ copyContent: "内容をコピー"
|
|||||||
copyLink: "リンクをコピー"
|
copyLink: "リンクをコピー"
|
||||||
delete: "ほかす"
|
delete: "ほかす"
|
||||||
deleteAndEdit: "ほかして直す"
|
deleteAndEdit: "ほかして直す"
|
||||||
deleteAndEditConfirm: "このノートをほかしてもっかい直す?このノートへのリアクション、Renote、返信も全部消えるんやけどそれでもええん?"
|
deleteAndEditConfirm: "このノートをほかしてもっかい直す?このノートへのツッコミ、Renote、返信も全部消えるんやけどそれでもええん?"
|
||||||
addToList: "リストに入れたる"
|
addToList: "リストに入れたる"
|
||||||
sendMessage: "メッセージを送る"
|
sendMessage: "メッセージを送る"
|
||||||
copyRSS: "RSSをコピー"
|
copyRSS: "RSSをコピー"
|
||||||
copyUsername: "ユーザー名をコピー"
|
copyUsername: "ユーザー名をコピー"
|
||||||
|
copyUserId: "ユーザーIDをコピー"
|
||||||
|
copyNoteId: "ノートIDをコピー"
|
||||||
searchUser: "ユーザーを検索"
|
searchUser: "ユーザーを検索"
|
||||||
reply: "返事"
|
reply: "返事"
|
||||||
loadMore: "まだまだあるで!"
|
loadMore: "まだまだあるで!"
|
||||||
@@ -790,6 +792,7 @@ noMaintainerInformationWarning: "管理者情報が設定されてへんで"
|
|||||||
noBotProtectionWarning: "Botプロテクションが設定されてへんで。"
|
noBotProtectionWarning: "Botプロテクションが設定されてへんで。"
|
||||||
configure: "設定する"
|
configure: "設定する"
|
||||||
postToGallery: "ギャラリーへ投稿"
|
postToGallery: "ギャラリーへ投稿"
|
||||||
|
postToHashtag: "このハッシュタグで投稿"
|
||||||
gallery: "ギャラリー"
|
gallery: "ギャラリー"
|
||||||
recentPosts: "最近の投稿"
|
recentPosts: "最近の投稿"
|
||||||
popularPosts: "人気の投稿"
|
popularPosts: "人気の投稿"
|
||||||
@@ -823,6 +826,7 @@ translatedFrom: "{x}から翻訳するで"
|
|||||||
accountDeletionInProgress: "アカウント削除しとるで待っとってなー"
|
accountDeletionInProgress: "アカウント削除しとるで待っとってなー"
|
||||||
usernameInfo: "サーバー上であんたのアカウントをあんたやと分かるようにするための名前やで。アルファベット(a~z, A~Z)、数字(0~9)、それとアンダーバー(_)が使って考えてな。この名前は後から変更することはできへんからちゃんと考えるんやで。"
|
usernameInfo: "サーバー上であんたのアカウントをあんたやと分かるようにするための名前やで。アルファベット(a~z, A~Z)、数字(0~9)、それとアンダーバー(_)が使って考えてな。この名前は後から変更することはできへんからちゃんと考えるんやで。"
|
||||||
aiChanMode: "藍モードやで"
|
aiChanMode: "藍モードやで"
|
||||||
|
devMode: "開発者モード"
|
||||||
keepCw: "CWを維持するで"
|
keepCw: "CWを維持するで"
|
||||||
pubSub: "Pub/Subのアカウント"
|
pubSub: "Pub/Subのアカウント"
|
||||||
lastCommunication: "直近の通信"
|
lastCommunication: "直近の通信"
|
||||||
@@ -832,6 +836,8 @@ breakFollow: "フォロワーを解除するで"
|
|||||||
breakFollowConfirm: "フォロワー解除してもええか?"
|
breakFollowConfirm: "フォロワー解除してもええか?"
|
||||||
itsOn: "オンになっとるよ"
|
itsOn: "オンになっとるよ"
|
||||||
itsOff: "オフになってるで"
|
itsOff: "オフになってるで"
|
||||||
|
on: "オン"
|
||||||
|
off: "オフ"
|
||||||
emailRequiredForSignup: "アカウント登録にメールアドレスを必須にするで"
|
emailRequiredForSignup: "アカウント登録にメールアドレスを必須にするで"
|
||||||
unread: "未読"
|
unread: "未読"
|
||||||
filter: "フィルタ"
|
filter: "フィルタ"
|
||||||
@@ -986,6 +992,8 @@ cannotBeChangedLater: "後からは変えられへんで。"
|
|||||||
reactionAcceptance: "ツッコミの受け入れ"
|
reactionAcceptance: "ツッコミの受け入れ"
|
||||||
likeOnly: "いいねだけ"
|
likeOnly: "いいねだけ"
|
||||||
likeOnlyForRemote: "リモートからはいいねだけな"
|
likeOnlyForRemote: "リモートからはいいねだけな"
|
||||||
|
nonSensitiveOnly: "センシティブじゃないやつだけ"
|
||||||
|
nonSensitiveOnlyForLocalLikeOnlyForRemote: "センシティブじゃないやつだけ (リモートはいいねだけ)"
|
||||||
rolesAssignedToMe: "自分に割り当てられたロール"
|
rolesAssignedToMe: "自分に割り当てられたロール"
|
||||||
resetPasswordConfirm: "パスワード作り直すんでええな?"
|
resetPasswordConfirm: "パスワード作り直すんでええな?"
|
||||||
sensitiveWords: "けったいな単語"
|
sensitiveWords: "けったいな単語"
|
||||||
@@ -1043,6 +1051,17 @@ preventAiLearning: "生成AIの学習に使わんといて"
|
|||||||
preventAiLearningDescription: "他の文章生成AIとか画像生成AIに、投稿したノートとか画像なんかを勝手に使わんように頼むで。具体的にはnoaiフラグをHTMLレスポンスに含めるんやけど、これ聞いてくれるんはAIの気分次第やから、使われる可能性もちょっとはあるな。"
|
preventAiLearningDescription: "他の文章生成AIとか画像生成AIに、投稿したノートとか画像なんかを勝手に使わんように頼むで。具体的にはnoaiフラグをHTMLレスポンスに含めるんやけど、これ聞いてくれるんはAIの気分次第やから、使われる可能性もちょっとはあるな。"
|
||||||
options: "オプション"
|
options: "オプション"
|
||||||
specifyUser: "ユーザー指定"
|
specifyUser: "ユーザー指定"
|
||||||
|
failedToPreviewUrl: "プレビューできへん"
|
||||||
|
update: "更新"
|
||||||
|
rolesThatCanBeUsedThisEmojiAsReaction: "ツッコミとして使えるロール"
|
||||||
|
rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "ロールが一個も指定されてへんかったら、誰でもツッコミとして使えるで。"
|
||||||
|
rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "ロールは公開ロールじゃないとアカンで。"
|
||||||
|
cancelReactionConfirm: "ツッコむんをやっぱやめるか?"
|
||||||
|
changeReactionConfirm: "ツッコミを別のに変えるか?"
|
||||||
|
later: "あとで"
|
||||||
|
goToMisskey: "Misskeyへ"
|
||||||
|
additionalEmojiDictionary: "絵文字の追加辞書"
|
||||||
|
installed: "インストール済み"
|
||||||
_initialAccountSetting:
|
_initialAccountSetting:
|
||||||
accountCreated: "アカウント作り終わったで。"
|
accountCreated: "アカウント作り終わったで。"
|
||||||
letsStartAccountSetup: "アカウントの初期設定をしよか。"
|
letsStartAccountSetup: "アカウントの初期設定をしよか。"
|
||||||
@@ -1057,6 +1076,7 @@ _initialAccountSetting:
|
|||||||
haveFun: "{name}、楽しんでな~"
|
haveFun: "{name}、楽しんでな~"
|
||||||
ifYouNeedLearnMore: "{name}(Misskey)の使い方とかをよー知りたいんやったら{link}をみてな。"
|
ifYouNeedLearnMore: "{name}(Misskey)の使い方とかをよー知りたいんやったら{link}をみてな。"
|
||||||
skipAreYouSure: "初期設定飛ばすか?"
|
skipAreYouSure: "初期設定飛ばすか?"
|
||||||
|
laterAreYouSure: "初期設定あとでやり直すん?"
|
||||||
_serverRules:
|
_serverRules:
|
||||||
description: "新規登録前に見せる、サーバーの簡潔なルールを設定すんで。内容は使うための決め事の要約とすることを推奨するわ。"
|
description: "新規登録前に見せる、サーバーの簡潔なルールを設定すんで。内容は使うための決め事の要約とすることを推奨するわ。"
|
||||||
_accountMigration:
|
_accountMigration:
|
||||||
@@ -1614,7 +1634,7 @@ _timelineTutorial:
|
|||||||
step2_2: "最初のノートは、自己紹介とか「{name}始めてみたんや」とかがええと思うで。"
|
step2_2: "最初のノートは、自己紹介とか「{name}始めてみたんや」とかがええと思うで。"
|
||||||
step3_1: "投稿できた?"
|
step3_1: "投稿できた?"
|
||||||
step3_2: "あんたのノートがタイムラインに出てきたら成功や。"
|
step3_2: "あんたのノートがタイムラインに出てきたら成功や。"
|
||||||
step4_1: "ノートには、「リアクション」を付けれるで。"
|
step4_1: "ノートには、「ツッコミ」を付けれるで。"
|
||||||
step4_2: "ツッコむんやったら、ノートの「+」マークを押して、好きな絵文字を選ぶで。"
|
step4_2: "ツッコむんやったら、ノートの「+」マークを押して、好きな絵文字を選ぶで。"
|
||||||
_2fa:
|
_2fa:
|
||||||
alreadyRegistered: "もう設定終わっとるわ。"
|
alreadyRegistered: "もう設定終わっとるわ。"
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ addToList: "리스트에 추가"
|
|||||||
sendMessage: "메시지 보내기"
|
sendMessage: "메시지 보내기"
|
||||||
copyRSS: "RSS 복사"
|
copyRSS: "RSS 복사"
|
||||||
copyUsername: "유저명 복사"
|
copyUsername: "유저명 복사"
|
||||||
|
copyUserId: "유저 ID 복사"
|
||||||
|
copyNoteId: "노트 ID 복사"
|
||||||
searchUser: "사용자 검색"
|
searchUser: "사용자 검색"
|
||||||
reply: "답글"
|
reply: "답글"
|
||||||
loadMore: "더 보기"
|
loadMore: "더 보기"
|
||||||
@@ -505,7 +507,7 @@ objectStoragePrefixDesc: "이 Prefix 의 디렉토리 아래에 파일이 저장
|
|||||||
objectStorageEndpoint: "Endpoint"
|
objectStorageEndpoint: "Endpoint"
|
||||||
objectStorageEndpointDesc: "AWS S3의 경우 공란, 다른 서비스의 경우 각 서비스의 가이드에 맞게 endpoint를 설정해주세요. '<host>' 혹은 '<host>:<port>' 와 같이 지정합니다."
|
objectStorageEndpointDesc: "AWS S3의 경우 공란, 다른 서비스의 경우 각 서비스의 가이드에 맞게 endpoint를 설정해주세요. '<host>' 혹은 '<host>:<port>' 와 같이 지정합니다."
|
||||||
objectStorageRegion: "Region"
|
objectStorageRegion: "Region"
|
||||||
objectStorageRegionDesc: "'xx-east-1'와 같이 region을 지정해주세요. 사용하는 서비스에 region 개념이 없는 경우 'us-east-1'으로 설정해 주세요. AWS 설정 파일 또는 환경 변수를 참조할 경우에는 비워주세요."
|
objectStorageRegionDesc: "'xx-east-1'와 같이 region을 지정해 주세요. 사용하는 서비스에 region 개념이 없는 경우 'us-east-1'으로 설정해 주세요. AWS 설정 파일 또는 환경 변수를 참조할 경우에는 비워주세요."
|
||||||
objectStorageUseSSL: "SSL 사용"
|
objectStorageUseSSL: "SSL 사용"
|
||||||
objectStorageUseSSLDesc: "API 호출시 HTTPS 를 사용하지 않는 경우 OFF 로 설정해 주세요"
|
objectStorageUseSSLDesc: "API 호출시 HTTPS 를 사용하지 않는 경우 OFF 로 설정해 주세요"
|
||||||
objectStorageUseProxy: "연결에 프록시를 사용"
|
objectStorageUseProxy: "연결에 프록시를 사용"
|
||||||
@@ -790,6 +792,7 @@ noMaintainerInformationWarning: "관리자 정보가 설정되어 있지 않습
|
|||||||
noBotProtectionWarning: "Bot 방어가 설정되어 있지 않습니다."
|
noBotProtectionWarning: "Bot 방어가 설정되어 있지 않습니다."
|
||||||
configure: "설정하기"
|
configure: "설정하기"
|
||||||
postToGallery: "갤러리에 업로드"
|
postToGallery: "갤러리에 업로드"
|
||||||
|
postToHashtag: "이 해시태그에 게시"
|
||||||
gallery: "갤러리"
|
gallery: "갤러리"
|
||||||
recentPosts: "최근 포스트"
|
recentPosts: "최근 포스트"
|
||||||
popularPosts: "인기 포스트"
|
popularPosts: "인기 포스트"
|
||||||
@@ -823,6 +826,7 @@ translatedFrom: "{x}에서 번역"
|
|||||||
accountDeletionInProgress: "계정 삭제 작업을 진행하고 있습니다"
|
accountDeletionInProgress: "계정 삭제 작업을 진행하고 있습니다"
|
||||||
usernameInfo: "서버상에서 계정을 식별하기 위한 이름. 알파벳(a~z, A~Z), 숫자(0~9) 및 언더바(_)를 사용할 수 있습니다. 사용자명은 나중에 변경할 수 없습니다."
|
usernameInfo: "서버상에서 계정을 식별하기 위한 이름. 알파벳(a~z, A~Z), 숫자(0~9) 및 언더바(_)를 사용할 수 있습니다. 사용자명은 나중에 변경할 수 없습니다."
|
||||||
aiChanMode: "아이 모드"
|
aiChanMode: "아이 모드"
|
||||||
|
devMode: "개발자 모드"
|
||||||
keepCw: "CW 유지하기"
|
keepCw: "CW 유지하기"
|
||||||
pubSub: "Pub/Sub 계정"
|
pubSub: "Pub/Sub 계정"
|
||||||
lastCommunication: "마지막 통신"
|
lastCommunication: "마지막 통신"
|
||||||
@@ -830,8 +834,10 @@ resolved: "해결됨"
|
|||||||
unresolved: "해결되지 않음"
|
unresolved: "해결되지 않음"
|
||||||
breakFollow: "팔로워 해제"
|
breakFollow: "팔로워 해제"
|
||||||
breakFollowConfirm: "팔로우를 해제하시겠습니까?"
|
breakFollowConfirm: "팔로우를 해제하시겠습니까?"
|
||||||
itsOn: "켜짐"
|
itsOn: "켜져 있습니다"
|
||||||
itsOff: "꺼짐"
|
itsOff: "꺼져 있습니다"
|
||||||
|
on: "켜짐"
|
||||||
|
off: "꺼짐"
|
||||||
emailRequiredForSignup: "가입할 때 이메일 주소 입력을 필수로 하기"
|
emailRequiredForSignup: "가입할 때 이메일 주소 입력을 필수로 하기"
|
||||||
unread: "읽지 않음"
|
unread: "읽지 않음"
|
||||||
filter: "필터"
|
filter: "필터"
|
||||||
@@ -864,7 +870,7 @@ instanceDefaultLightTheme: "서버 기본 라이트 테마"
|
|||||||
instanceDefaultDarkTheme: "서버 기본 다크 테마"
|
instanceDefaultDarkTheme: "서버 기본 다크 테마"
|
||||||
instanceDefaultThemeDescription: "객체 형식의 테마 코드를 입력해 주세요."
|
instanceDefaultThemeDescription: "객체 형식의 테마 코드를 입력해 주세요."
|
||||||
mutePeriod: "뮤트할 기간"
|
mutePeriod: "뮤트할 기간"
|
||||||
period: "투표 기한"
|
period: "기간"
|
||||||
indefinitely: "무기한"
|
indefinitely: "무기한"
|
||||||
tenMinutes: "10분"
|
tenMinutes: "10분"
|
||||||
oneHour: "1시간"
|
oneHour: "1시간"
|
||||||
@@ -986,6 +992,8 @@ cannotBeChangedLater: "나중에 변경할 수 없습니다."
|
|||||||
reactionAcceptance: "리액션 수신"
|
reactionAcceptance: "리액션 수신"
|
||||||
likeOnly: "좋아요만 받기"
|
likeOnly: "좋아요만 받기"
|
||||||
likeOnlyForRemote: "리모트에서는 좋아요만 받기"
|
likeOnlyForRemote: "리모트에서는 좋아요만 받기"
|
||||||
|
nonSensitiveOnly: "열람 주의로 설정되지 않았을 때만 받기"
|
||||||
|
nonSensitiveOnlyForLocalLikeOnlyForRemote: "열람 주의로 설정되지 않았을 때만 받기 (리모트에서는 좋아요만 받기)"
|
||||||
rolesAssignedToMe: "나에게 할당된 역할"
|
rolesAssignedToMe: "나에게 할당된 역할"
|
||||||
resetPasswordConfirm: "비밀번호를 재설정하시겠습니까?"
|
resetPasswordConfirm: "비밀번호를 재설정하시겠습니까?"
|
||||||
sensitiveWords: "민감한 단어"
|
sensitiveWords: "민감한 단어"
|
||||||
@@ -1043,31 +1051,43 @@ preventAiLearning: "기계학습(생성형 AI)으로의 사용을 거부"
|
|||||||
preventAiLearningDescription: "외부의 문장 생성 AI나 이미지 생성 AI에 대해 제출한 노트나 이미지 등의 콘텐츠를 학습의 대상으로 사용하지 않도록 요구합니다. 다만, 이 요구사항을 지킬 의무는 없기 때문에 학습을 완전히 방지하는 것은 아닙니다."
|
preventAiLearningDescription: "외부의 문장 생성 AI나 이미지 생성 AI에 대해 제출한 노트나 이미지 등의 콘텐츠를 학습의 대상으로 사용하지 않도록 요구합니다. 다만, 이 요구사항을 지킬 의무는 없기 때문에 학습을 완전히 방지하는 것은 아닙니다."
|
||||||
options: "옵션"
|
options: "옵션"
|
||||||
specifyUser: "사용자 지정"
|
specifyUser: "사용자 지정"
|
||||||
|
failedToPreviewUrl: "미리 볼 수 없음"
|
||||||
|
update: "업데이트"
|
||||||
|
rolesThatCanBeUsedThisEmojiAsReaction: "이 이모지를 리액션으로 사용할 수 있는 역할"
|
||||||
|
rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "역할을 지정하지 않으면, 누구나 이 이모지를 리액션으로 사용할 수 있습니다."
|
||||||
|
rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "역할은 공개로 설정되어 있어야 합니다."
|
||||||
|
cancelReactionConfirm: "리액션을 취소하시겠습니까?"
|
||||||
|
changeReactionConfirm: "리액션을 변경하시겠습니까?"
|
||||||
|
later: "나중에"
|
||||||
|
goToMisskey: "Misskey로"
|
||||||
|
additionalEmojiDictionary: "이모지 추가 사전"
|
||||||
|
installed: "설치됨"
|
||||||
_initialAccountSetting:
|
_initialAccountSetting:
|
||||||
accountCreated: "계정 생성이 완료되었습니다!"
|
accountCreated: "계정 생성이 완료되었습니다!"
|
||||||
letsStartAccountSetup: "계정의 초기 설정을 진행합니다."
|
letsStartAccountSetup: "계정의 초기 설정을 진행합니다."
|
||||||
letsFillYourProfile: "우선 나의 프로필을 설정해 보아요."
|
letsFillYourProfile: "우선 나의 프로필을 설정해 보아요."
|
||||||
profileSetting: "프로필 설정"
|
profileSetting: "프로필 설정"
|
||||||
privacySetting: "\n프라이버시설정"
|
privacySetting: "프라이버시 설정"
|
||||||
theseSettingsCanEditLater: "이 설정들은 나중에도 변경할 수 있습니다."
|
theseSettingsCanEditLater: "이 설정들은 나중에도 변경할 수 있습니다."
|
||||||
youCanEditMoreSettingsInSettingsPageLater: "이 외에도 '설정' 페이지에서 다양한 설정을 나의 입맛에 맛게 조절할 수 있습니다. 꼭 확인해 보세요!"
|
youCanEditMoreSettingsInSettingsPageLater: "이 외에도 '설정' 페이지에서 다양한 설정을 나의 입맛에 맞게 조절할 수 있습니다. 꼭 확인해 보세요!"
|
||||||
followUsers: "관심사가 맞는 유저를 팔로우하여 타임라인을 가꾸어 봅시다."
|
followUsers: "관심사가 맞는 유저를 팔로우하여 타임라인을 가꾸어 봅시다."
|
||||||
pushNotificationDescription: "푸시 알림을 활성화하면 {name}의 알림을 나의 기기에서 받아볼 수 있게 됩니다."
|
pushNotificationDescription: "푸시 알림을 활성화하면 {name}의 알림을 나의 기기에서 받아볼 수 있게 됩니다."
|
||||||
initialAccountSettingCompleted: "초기 설정을 모두 마쳤습니다!"
|
initialAccountSettingCompleted: "초기 설정을 모두 마쳤습니다!"
|
||||||
haveFun: "{name}와 함께 즐거운 시간 보내세요!"
|
haveFun: "{name}와 함께 즐거운 시간 보내세요!"
|
||||||
ifYouNeedLearnMore: "{name}(Misskey)의 사용 방법에 대해 자세히 알아보려면 {link}를 참고해 주세요."
|
ifYouNeedLearnMore: "{name}(Misskey)의 사용 방법에 대해 자세히 알아보려면 {link}를 참고해 주세요."
|
||||||
skipAreYouSure: "초기 설정을 넘기시겠습니까?"
|
skipAreYouSure: "초기 설정을 중단하시겠습니까?"
|
||||||
|
laterAreYouSure: "초기 설정을 나중에 진행하시겠습니까?"
|
||||||
_serverRules:
|
_serverRules:
|
||||||
description: "회원 가입 이전에 간단하게 표시할 서버 규칙입니다. 이용 약관의 요약으로 구성하는 것을 추천합니다."
|
description: "회원 가입 이전에 간단하게 표시할 서버 규칙입니다. 이용 약관의 요약으로 구성하는 것을 추천합니다."
|
||||||
_accountMigration:
|
_accountMigration:
|
||||||
moveFrom: "다른 계정에서 이 계정으로 이사"
|
moveFrom: "다른 계정에서 이 계정으로 이사"
|
||||||
moveFromSub: "다른 계정에 대한 별칭을 생성"
|
moveFromSub: "다른 계정에 대한 별칭을 생성"
|
||||||
moveFromLabel: "기존 계정:"
|
moveFromLabel: "기존 계정 #{n}"
|
||||||
moveFromDescription: "다른 계정에서 이 계정으로 팔로워를 가져오려면, 우선 여기에서 별칭을 지정해야 합니다. 반드시 이사하기 전에 지정해야 합니다! 기존 계정을 다음과 같은 형식으로 입력해 주십시오: @person@instance.com"
|
moveFromDescription: "다른 계정에서 이 계정으로 팔로워를 가져오려면, 우선 여기에서 별칭을 지정해야 합니다. 반드시 이사하기 전에 지정해야 합니다! 기존 계정을 다음과 같은 형식으로 입력해 주십시오: @person@instance.com"
|
||||||
moveTo: "이 계정에서 다른 계정으로 이사"
|
moveTo: "이 계정에서 다른 계정으로 이사"
|
||||||
moveToLabel: "이사할 계정:"
|
moveToLabel: "이사할 계정:"
|
||||||
moveCannotBeUndone: "한 번 이사하면, 두 번 다시 되돌릴 수 없습니다."
|
moveCannotBeUndone: "한 번 이사하면, 두 번 다시 되돌릴 수 없습니다."
|
||||||
moveAccountDescription: "이 작업은 취소할 수 없습니다. 먼저 이사할 계정에서 이 계정에 대한 별칭을 지정하였는지 다시 한 번 확인해 주십시오. 별칭을 지정한 다음, 이사할 계정을 다음과 같은 형식으로 입력해 주십시오: @person@instance.com"
|
moveAccountDescription: "새 계정으로 이전합니다.\n ・팔로워가 새 계정을 자동으로 팔로우 합니다\n ・이 계정에서 팔로우는 모두 해제됩니다\n ・이 계정으로는 노트 작성 등을 할 수 없게 됩니다\n\n팔로워는 자동으로 이전되지만, 팔로우는 수동으로 진행해야 합니다. 이전하기 전에 이 계정에서 팔로우를 내보내고, 이전 후에는 즉시 이전한 계정에서 가져오기를 진행하십시오.\n리스트・뮤트・차단에 대해서도 마찬가지이므로 수동으로 이전해야 합니다.\n\n(이 설명은 이 서버(Misskey v13.12.0 이후)의 사양입니다. Mastodon 등의 다른 ActivityPub 소프트웨어에서는 작동이 다를 수 있습니다.)"
|
||||||
moveAccountHowTo: "계정을 이사하려면 우선 이사갈 계정에서 이 계정에 대한 별칭을 지정해야 합니다.\n별칭을 작성한 다음, 이사갈 계정을 다음과 같이 입력하십시오:\n@username@server.example.com"
|
moveAccountHowTo: "계정을 이사하려면 우선 이사갈 계정에서 이 계정에 대한 별칭을 지정해야 합니다.\n별칭을 작성한 다음, 이사갈 계정을 다음과 같이 입력하십시오:\n@username@server.example.com"
|
||||||
startMigration: "이사하기"
|
startMigration: "이사하기"
|
||||||
migrationConfirm: "정말로 이 계정을 {account} 으로 이전하시겠습니까? 한 번 이전한 다음에는 취소할 수 없으며, 두 번 다시 원래 상태로 복구할 수 없습니다.\n이사할 계정에서 계정 별칭을 지정하였는지 다시 한 번 확인하십시오."
|
migrationConfirm: "정말로 이 계정을 {account} 으로 이전하시겠습니까? 한 번 이전한 다음에는 취소할 수 없으며, 두 번 다시 원래 상태로 복구할 수 없습니다.\n이사할 계정에서 계정 별칭을 지정하였는지 다시 한 번 확인하십시오."
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
---
|
---
|
||||||
_lang_: "Norsk Bokmål"
|
_lang_: "Norsk Bokmål"
|
||||||
|
headlineMisskey: "Et nettverk forbundet med Notes"
|
||||||
|
introMisskey: "Velkommen! Misskey er en desentralisert mikrobloggtjeneste med åpen kildekode.\nOpprett \"Notes\" for å dele tankene dine med alle rundt deg. 📡\nMed \"reaksjoner\" kan du også raskt gi uttrykk for hva du synes om alles Notes. 👍\nLa oss utforske en ny verden! 🚀"
|
||||||
monthAndDay: "{day}-{month}"
|
monthAndDay: "{day}-{month}"
|
||||||
search: "Søk"
|
search: "Søk"
|
||||||
notifications: "Varsler"
|
notifications: "Varsler"
|
||||||
@@ -10,8 +12,10 @@ fetchingAsApObject: "Henter fra Fediverse..."
|
|||||||
ok: "OK"
|
ok: "OK"
|
||||||
gotIt: "Skjønner"
|
gotIt: "Skjønner"
|
||||||
cancel: "Avbryt"
|
cancel: "Avbryt"
|
||||||
noThankYou: "Avbryt"
|
noThankYou: "Ikke nå"
|
||||||
enterUsername: "Skriv inn brukernavn"
|
enterUsername: "Skriv inn brukernavn"
|
||||||
|
renotedBy: "Renotes av {user}"
|
||||||
|
noNotes: "Ingen Notes"
|
||||||
noNotifications: "Ingen varsler"
|
noNotifications: "Ingen varsler"
|
||||||
instance: "Server"
|
instance: "Server"
|
||||||
settings: "Innstillinger"
|
settings: "Innstillinger"
|
||||||
@@ -21,7 +25,7 @@ otherSettings: "Andre innstillinger"
|
|||||||
openInWindow: "Åpne i vindu"
|
openInWindow: "Åpne i vindu"
|
||||||
profile: "Profil"
|
profile: "Profil"
|
||||||
timeline: "Tidslinje"
|
timeline: "Tidslinje"
|
||||||
noAccountDescription: "Denne brukeren har ikke skrevet sin bio ennå."
|
noAccountDescription: "Denne brukeren har ikke skrevet sin biografi ennå."
|
||||||
login: "Logg inn"
|
login: "Logg inn"
|
||||||
loggingIn: "Logget inn"
|
loggingIn: "Logget inn"
|
||||||
logout: "Logg ut"
|
logout: "Logg ut"
|
||||||
@@ -30,20 +34,21 @@ uploading: "Laster opp"
|
|||||||
save: "Lagre"
|
save: "Lagre"
|
||||||
users: "Brukere"
|
users: "Brukere"
|
||||||
addUser: "Legg til bruker"
|
addUser: "Legg til bruker"
|
||||||
favorite: "Favoritt"
|
favorite: "Legg til i favoritter"
|
||||||
favorites: "Favoritter"
|
favorites: "Favoritter"
|
||||||
unfavorite: "Fjern favoritt"
|
unfavorite: "Fjern fra favoritter"
|
||||||
favorited: "Lagt til i favoritter."
|
favorited: "Lagt til i favoritter."
|
||||||
alreadyFavorited: "Allerede lagt til i favoritter."
|
alreadyFavorited: "Allerede lagt til i favoritter."
|
||||||
cantFavorite: "Kunne ikke legge til i favoritter."
|
cantFavorite: "Kunne ikke legge til i favoritter."
|
||||||
pin: "Fest"
|
pin: "Fest til profil"
|
||||||
unpin: "Opphev festing"
|
unpin: "Fjern fra profil"
|
||||||
copyContent: "Kopier innhold"
|
copyContent: "Kopier innhold"
|
||||||
copyLink: "Kopier lenke"
|
copyLink: "Kopier lenke"
|
||||||
delete: "Slett"
|
delete: "Slett"
|
||||||
deleteAndEdit: "Slett og rediger"
|
deleteAndEdit: "Slett og rediger"
|
||||||
|
deleteAndEditConfirm: "Er du sikker på at du vil slette denne Noten og redigere den? Du vil miste alle reaksjoner, Renotes og svar på den."
|
||||||
addToList: "Legg til i liste"
|
addToList: "Legg til i liste"
|
||||||
sendMessage: "Send melding"
|
sendMessage: "Send en melding"
|
||||||
copyRSS: "Kopier RSS"
|
copyRSS: "Kopier RSS"
|
||||||
copyUsername: "Kopier brukernavn"
|
copyUsername: "Kopier brukernavn"
|
||||||
searchUser: "Søk brukere"
|
searchUser: "Søk brukere"
|
||||||
@@ -63,7 +68,9 @@ unfollowConfirm: "Er du sikker på at du vil slutte å følge {name}?"
|
|||||||
importRequested: "Du har bedt om import. Dette kan ta en stund."
|
importRequested: "Du har bedt om import. Dette kan ta en stund."
|
||||||
lists: "Lister"
|
lists: "Lister"
|
||||||
noLists: "Ingen lister"
|
noLists: "Ingen lister"
|
||||||
following: "Følg"
|
note: "Note"
|
||||||
|
notes: "Notes"
|
||||||
|
following: "Følger"
|
||||||
followers: "Følgere"
|
followers: "Følgere"
|
||||||
followsYou: "Følger deg"
|
followsYou: "Følger deg"
|
||||||
createList: "Opprett liste"
|
createList: "Opprett liste"
|
||||||
@@ -74,14 +81,22 @@ pageLoadError: "Kunne ikke hente side."
|
|||||||
serverIsDead: "Denne serveren svarer ikke. Vennligst vent en stund og prøv igjen."
|
serverIsDead: "Denne serveren svarer ikke. Vennligst vent en stund og prøv igjen."
|
||||||
enterListName: "Skriv inn et navn på listen"
|
enterListName: "Skriv inn et navn på listen"
|
||||||
privacy: "Personvern"
|
privacy: "Personvern"
|
||||||
|
defaultNoteVisibility: "Standard synlighet"
|
||||||
follow: "Følg"
|
follow: "Følg"
|
||||||
followRequest: "Følgeforespørsel"
|
followRequest: "Følgeforespørsel"
|
||||||
followRequests: "Følgeforespørsel"
|
followRequests: "Følgeforespørsel"
|
||||||
unfollow: "Avfølg"
|
unfollow: "Avfølg"
|
||||||
followRequestPending: "Venter på godkjenning"
|
followRequestPending: "Venter på godkjenning"
|
||||||
enterEmoji: "Skriv inn en emoji"
|
enterEmoji: "Skriv inn en emoji"
|
||||||
|
renote: "Renote"
|
||||||
|
renoted: "Renotet."
|
||||||
|
cantRenote: "Dette innlegget kan ikke renotes."
|
||||||
|
cantReRenote: "En Renote kan ikke renotes."
|
||||||
quote: "Sitat"
|
quote: "Sitat"
|
||||||
pinned: "Fest"
|
inChannelRenote: "Renote kun for kanal"
|
||||||
|
inChannelQuote: "Sitat kun for kanal"
|
||||||
|
pinnedNote: "Festet Note"
|
||||||
|
pinned: "Fest til profil"
|
||||||
you: "Du"
|
you: "Du"
|
||||||
clickToShow: "Klikk for å vise"
|
clickToShow: "Klikk for å vise"
|
||||||
add: "Legg til"
|
add: "Legg til"
|
||||||
@@ -89,18 +104,21 @@ reaction: "Reaksjon"
|
|||||||
reactions: "Reaksjoner"
|
reactions: "Reaksjoner"
|
||||||
reactionSetting: "Reaksjoner som vises i reaksjonsvelgeren"
|
reactionSetting: "Reaksjoner som vises i reaksjonsvelgeren"
|
||||||
reactionSettingDescription2: "Dra for å endre rekkefølgen, klikk for å slette, trykk \"+\" for å legge til."
|
reactionSettingDescription2: "Dra for å endre rekkefølgen, klikk for å slette, trykk \"+\" for å legge til."
|
||||||
|
rememberNoteVisibility: "Husk innstillingene for synlighet av Notes"
|
||||||
attachCancel: "Fjern vedlegg"
|
attachCancel: "Fjern vedlegg"
|
||||||
enterFileName: "Skriv inn filnavn"
|
enterFileName: "Skriv inn filnavn"
|
||||||
mute: "Skjul"
|
mute: "Skjul"
|
||||||
unmute: "Vis"
|
unmute: "Vis"
|
||||||
|
renoteMute: "Skjul Renotes"
|
||||||
|
renoteUnmute: "Vis Renotes"
|
||||||
block: "Blokker"
|
block: "Blokker"
|
||||||
unblock: "Opphev blokkering"
|
unblock: "Opphev blokkering"
|
||||||
suspend: "Suspender"
|
suspend: "Suspender"
|
||||||
blockConfirm: "Blokker?"
|
blockConfirm: "Er du sikker på at du vil blokke denne kontoen?"
|
||||||
unblockConfirm: "Er du sikker på at du vil oppheve blokkeringen av denne kontoen?"
|
unblockConfirm: "Er du sikker på at du vil oppheve blokkeringen av denne kontoen?"
|
||||||
suspendConfirm: "Er du sikker på at du vil suspendere denne kontoen?"
|
suspendConfirm: "Er du sikker på at du vil suspendere denne kontoen?"
|
||||||
selectList: "Velg liste"
|
selectList: "Velg en liste"
|
||||||
selectChannel: "Velg kanal"
|
selectChannel: "Velg en kanal"
|
||||||
selectAntenna: "Velg en antenne"
|
selectAntenna: "Velg en antenne"
|
||||||
selectWidget: "Velg en widget"
|
selectWidget: "Velg en widget"
|
||||||
editWidgets: "Rediger widgeter"
|
editWidgets: "Rediger widgeter"
|
||||||
@@ -113,6 +131,7 @@ flagAsBot: "Merk denne kontoen som en bot"
|
|||||||
flagAsBotDescription: "Aktiver dette alternativet hvis denne kontoen styres av et program. Hvis det er aktivert, vil det fungere som et flagg for andre utviklere for å forhindre endeløse interaksjonskjeder med andre roboter og justere Misskeys interne systemer til å behandle denne kontoen som en bot."
|
flagAsBotDescription: "Aktiver dette alternativet hvis denne kontoen styres av et program. Hvis det er aktivert, vil det fungere som et flagg for andre utviklere for å forhindre endeløse interaksjonskjeder med andre roboter og justere Misskeys interne systemer til å behandle denne kontoen som en bot."
|
||||||
flagAsCat: "Merk denne kontoen som en katt"
|
flagAsCat: "Merk denne kontoen som en katt"
|
||||||
flagAsCatDescription: "Aktiver dette alternativet for å merke denne kontoen som en katt."
|
flagAsCatDescription: "Aktiver dette alternativet for å merke denne kontoen som en katt."
|
||||||
|
flagShowTimelineReplies: "Vis svar i tidslinje"
|
||||||
addAccount: "Legg til konto"
|
addAccount: "Legg til konto"
|
||||||
reloadAccountsList: "Last inn kontoliste på nytt"
|
reloadAccountsList: "Last inn kontoliste på nytt"
|
||||||
loginFailed: "Kunne ikke logge inn"
|
loginFailed: "Kunne ikke logge inn"
|
||||||
@@ -120,23 +139,30 @@ general: "Generelt"
|
|||||||
searchWith: "Søk: {q}"
|
searchWith: "Søk: {q}"
|
||||||
youHaveNoLists: "Du har ingen lister"
|
youHaveNoLists: "Du har ingen lister"
|
||||||
followConfirm: "Er du sikker på at du vil følge {name}?"
|
followConfirm: "Er du sikker på at du vil følge {name}?"
|
||||||
selectUser: "Velg bruker"
|
host: "Vert"
|
||||||
|
selectUser: "Velg en bruker"
|
||||||
recipient: "Mottaker"
|
recipient: "Mottaker"
|
||||||
annotation: "Kommentarer"
|
annotation: "Kommentarer"
|
||||||
federation: "Føderasjon"
|
federation: "Føderasjon"
|
||||||
instances: "Server"
|
instances: "Servere"
|
||||||
registeredAt: "Registrerte seg"
|
registeredAt: "Registrerte seg"
|
||||||
|
latestRequestReceivedAt: "Siste forespørsel mottatt"
|
||||||
|
latestStatus: "Siste status"
|
||||||
|
charts: "Diagrammer"
|
||||||
perHour: "Per time"
|
perHour: "Per time"
|
||||||
perDay: "Per dag"
|
perDay: "Per dag"
|
||||||
stopActivityDelivery: "Slutt å sende aktiviteter"
|
stopActivityDelivery: "Slutt å sende aktiviteter"
|
||||||
blockThisInstance: "Blokker denne serveren"
|
blockThisInstance: "Blokker denne serveren"
|
||||||
|
operations: "Operasjoner"
|
||||||
software: "Programvare"
|
software: "Programvare"
|
||||||
version: "Versjon"
|
version: "Versjon"
|
||||||
|
metadata: "Metadata"
|
||||||
withNFiles: "{n} fil(er)"
|
withNFiles: "{n} fil(er)"
|
||||||
network: "Nettverk"
|
network: "Nettverk"
|
||||||
|
instanceInfo: "Serverinformasjon"
|
||||||
statistics: "Statistikk"
|
statistics: "Statistikk"
|
||||||
clearQueue: "Tøm kø"
|
clearQueue: "Tøm kø"
|
||||||
clearQueueConfirmTitle: "Vil du tømme kø?"
|
clearQueueConfirmTitle: "Er du sikker på at du vil tømme køen?"
|
||||||
blockedInstances: "Blokkerte severe"
|
blockedInstances: "Blokkerte severe"
|
||||||
blockedInstancesDescription: "Skriv opp vertsnavnene til serverne du vil blokkere, atskilt med linjeskift. Serverne i listen vil ikke lenger kunne kommunisere med denne serveren."
|
blockedInstancesDescription: "Skriv opp vertsnavnene til serverne du vil blokkere, atskilt med linjeskift. Serverne i listen vil ikke lenger kunne kommunisere med denne serveren."
|
||||||
muteAndBlock: "Skjul og blokker"
|
muteAndBlock: "Skjul og blokker"
|
||||||
@@ -144,9 +170,13 @@ mutedUsers: "Skjulte brukere"
|
|||||||
blockedUsers: "Blokkerte brukere"
|
blockedUsers: "Blokkerte brukere"
|
||||||
noUsers: "Det er ingen brukere"
|
noUsers: "Det er ingen brukere"
|
||||||
editProfile: "Rediger profil"
|
editProfile: "Rediger profil"
|
||||||
|
noteDeleteConfirm: "Er du sikker på at du vil slette denne Noten?"
|
||||||
pinLimitExceeded: "Du kan ikke feste flere."
|
pinLimitExceeded: "Du kan ikke feste flere."
|
||||||
|
intro: "Installasjonen av Misskey er ferdig! Vennligst opprett en administratorkonto."
|
||||||
done: "Ferdig"
|
done: "Ferdig"
|
||||||
noCustomEmojis: "Ingen emoji"
|
default: "Standard"
|
||||||
|
defaultValueIs: "Standard: {value}"
|
||||||
|
noCustomEmojis: "Det er ingen emoji"
|
||||||
noJobs: "Det er ingen jobber"
|
noJobs: "Det er ingen jobber"
|
||||||
blocked: "Blokkert"
|
blocked: "Blokkert"
|
||||||
suspended: "Suspendert"
|
suspended: "Suspendert"
|
||||||
@@ -154,54 +184,72 @@ all: "Alle"
|
|||||||
notResponding: "Svarer ikke"
|
notResponding: "Svarer ikke"
|
||||||
changePassword: "Endre passord"
|
changePassword: "Endre passord"
|
||||||
security: "Sikkerhet"
|
security: "Sikkerhet"
|
||||||
|
retypedNotMatch: "Inngangene stemmer ikke overens."
|
||||||
|
currentPassword: "Nåværende passord"
|
||||||
newPassword: "Nytt passord"
|
newPassword: "Nytt passord"
|
||||||
newPasswordRetype: "Nytt passord (gjenta)"
|
newPasswordRetype: "Nytt passord (gjenta)"
|
||||||
attachFile: "Legg ved filer"
|
attachFile: "Legg ved filer"
|
||||||
more: "Mer!"
|
more: "Mer!"
|
||||||
|
noSuchUser: "Bruker ikke funnet"
|
||||||
announcements: "Kunngjøringer"
|
announcements: "Kunngjøringer"
|
||||||
remove: "Slett"
|
remove: "Slett"
|
||||||
removed: "Slettet"
|
removed: "Vellykket slettet"
|
||||||
removeAreYouSure: "Er du sikker på at du vil fjerne \"{x}\"?"
|
removeAreYouSure: "Er du sikker på at du vil fjerne \"{x}\"?"
|
||||||
deleteAreYouSure: "Er du sikker på at du vil slette \"{x}\"?"
|
deleteAreYouSure: "Er du sikker på at du vil slette \"{x}\"?"
|
||||||
saved: "Lagret"
|
saved: "Lagret"
|
||||||
upload: "Laste opp"
|
upload: "Laste opp"
|
||||||
|
keepOriginalUploading: "Behold originalbildet"
|
||||||
|
fromUrl: "Fra URL"
|
||||||
|
uploadFromUrl: "Last opp fra en URL"
|
||||||
|
uploadFromUrlDescription: "URL til filen du vil laste opp"
|
||||||
explore: "Utforsk"
|
explore: "Utforsk"
|
||||||
messageRead: "Lest"
|
messageRead: "Lest"
|
||||||
agree: "Jeg godtar"
|
nUsersRead: "lest av {n}"
|
||||||
|
agreeTo: "Jeg godtar {0}"
|
||||||
|
agree: "Godta"
|
||||||
|
agreeBelow: "Jeg godtar følgende"
|
||||||
|
basicNotesBeforeCreateAccount: "Viktige merknader"
|
||||||
termsOfService: "Vilkår for bruk"
|
termsOfService: "Vilkår for bruk"
|
||||||
home: "Hjem"
|
home: "Hjem"
|
||||||
activity: "Aktivitet"
|
activity: "Aktivitet"
|
||||||
images: "Bilder"
|
images: "Bilder"
|
||||||
image: "Bilder"
|
image: "Bilde"
|
||||||
birthday: "Bursdag"
|
birthday: "Bursdag"
|
||||||
yearsOld: "{age} år gammel"
|
yearsOld: "{age} år gammel"
|
||||||
|
theme: "Temaer"
|
||||||
light: "Lys"
|
light: "Lys"
|
||||||
dark: "Mørk"
|
dark: "Mørk"
|
||||||
|
lightThemes: "Lyse temaer"
|
||||||
|
darkThemes: "Mørke temaer"
|
||||||
|
syncDeviceDarkMode: "Synkroniser mørkmodus med enhetens innstillinger"
|
||||||
fileName: "Filnavn"
|
fileName: "Filnavn"
|
||||||
selectFile: "Velg fil"
|
selectFile: "Velg en fil"
|
||||||
selectFiles: "Velg fil"
|
selectFiles: "Velg filer"
|
||||||
selectFolder: "Velg mappe"
|
selectFolder: "Velg en mappe"
|
||||||
selectFolders: "Velg mappe"
|
selectFolders: "Velg mapper"
|
||||||
renameFile: "Endre filnavn"
|
renameFile: "Endre filnavn"
|
||||||
folderName: "Mappenavn"
|
folderName: "Mappenavn"
|
||||||
createFolder: "Opprett mappe"
|
createFolder: "Opprett en mappe"
|
||||||
renameFolder: "Endre mappenavn"
|
renameFolder: "Endre mappenavn"
|
||||||
deleteFolder: "Slett mappe"
|
deleteFolder: "Slett denne mappen"
|
||||||
addFile: "Legg til fil"
|
addFile: "Legg til en fil"
|
||||||
emptyFolder: "Denne mappen er tom"
|
emptyFolder: "Denne mappen er tom"
|
||||||
unableToDelete: "Kan ikke slette"
|
unableToDelete: "Kan ikke slette"
|
||||||
|
inputNewFileName: "Skriv inn et nytt filnavn"
|
||||||
|
inputNewDescription: "Skriv inn ny bildetekst"
|
||||||
|
inputNewFolderName: "Skriv inn et nytt mappenavn"
|
||||||
circularReferenceFolder: "Målmappen er en undermappe til mappen du ønsker å flytte."
|
circularReferenceFolder: "Målmappen er en undermappe til mappen du ønsker å flytte."
|
||||||
hasChildFilesOrFolders: "Siden denne mappen ikke er tom, kan den ikke slettes."
|
hasChildFilesOrFolders: "Siden denne mappen ikke er tom, kan den ikke slettes."
|
||||||
copyUrl: "Kopier URL"
|
copyUrl: "Kopier URL"
|
||||||
rename: "Endre navn"
|
rename: "Endre navn"
|
||||||
avatar: "Avatar"
|
avatar: "Avatar"
|
||||||
banner: "Banner"
|
banner: "Banner"
|
||||||
doNothing: "Gjør ingenting"
|
doNothing: "Ignorer"
|
||||||
accept: "Tillatt"
|
accept: "Tillatt"
|
||||||
reject: "Avslå"
|
reject: "Avslå"
|
||||||
instanceName: "Servernavn"
|
instanceName: "Servernavn"
|
||||||
instanceDescription: "Serverbeskrivelse"
|
instanceDescription: "Serverbeskrivelse"
|
||||||
thisYear: "I år"
|
thisYear: "År"
|
||||||
thisMonth: "Måned"
|
thisMonth: "Måned"
|
||||||
today: "I dag"
|
today: "I dag"
|
||||||
dayX: "{day}"
|
dayX: "{day}"
|
||||||
@@ -216,23 +264,35 @@ registration: "Registrer"
|
|||||||
enableRegistration: "Aktiver registrering av nye brukere"
|
enableRegistration: "Aktiver registrering av nye brukere"
|
||||||
invite: "Inviter"
|
invite: "Inviter"
|
||||||
basicInfo: "Grunnleggende informasjon"
|
basicInfo: "Grunnleggende informasjon"
|
||||||
pinnedUsers: "Festete brukrere"
|
pinnedUsers: "Festede brukrere"
|
||||||
pinnedUsersDescription: "Liste over brukernavn atskilt med linjeskift som skal festes i \"Utforsk\" fanen."
|
pinnedUsersDescription: "Liste over brukernavn atskilt med linjeskift som skal festes i \"Utforsk\" fanen."
|
||||||
pinnedPages: "Festete sider"
|
pinnedPages: "Festede sider"
|
||||||
|
pinnedNotes: "Festet Note"
|
||||||
hcaptcha: "hCaptcha"
|
hcaptcha: "hCaptcha"
|
||||||
enableHcaptcha: "Aktiver hCaptcha"
|
enableHcaptcha: "Aktiver hCaptcha"
|
||||||
recaptcha: "reCAPTCHA"
|
recaptcha: "reCAPTCHA"
|
||||||
enableRecaptcha: "Aktiver reCAPTCHA"
|
enableRecaptcha: "Aktiver reCAPTCHA"
|
||||||
turnstile: "Turnstile"
|
turnstile: "Turnstile"
|
||||||
enableTurnstile: "Aktiver Turnstile"
|
enableTurnstile: "Aktiver Turnstile"
|
||||||
|
antennas: "Antenner"
|
||||||
name: "Navn"
|
name: "Navn"
|
||||||
|
antennaSource: "Antennekilde"
|
||||||
|
notifyAntenna: "Varsle om nye Notes"
|
||||||
|
withFileAntenna: "Bare Notes med filer"
|
||||||
|
notesAndReplies: "Notes og svar"
|
||||||
popularUsers: "Populære brukere"
|
popularUsers: "Populære brukere"
|
||||||
exploreUsersCount: "Det finnes {count} brukere"
|
exploreUsersCount: "Det finnes {count} brukere"
|
||||||
|
exploreFediverse: "Utforsk Fediverse"
|
||||||
userList: "Lister"
|
userList: "Lister"
|
||||||
about: "Infomasjon"
|
about: "Informasjon"
|
||||||
aboutMisskey: "Om Misskey"
|
aboutMisskey: "Om Misskey"
|
||||||
|
newPasswordIs: "Det nye passordet er \"{password}\"."
|
||||||
share: "Del"
|
share: "Del"
|
||||||
|
notFound: "Ikke funnet"
|
||||||
|
markAsReadAllNotifications: "Merk alle varsler som lest"
|
||||||
|
markAsReadAllUnreadNotes: "Merk alle Notes som lest"
|
||||||
help: "Hjelp"
|
help: "Hjelp"
|
||||||
|
inputMessageHere: "Skriv inn melding her"
|
||||||
close: "Lukk"
|
close: "Lukk"
|
||||||
invites: "Inviter"
|
invites: "Inviter"
|
||||||
members: "Medlemmer"
|
members: "Medlemmer"
|
||||||
@@ -240,30 +300,46 @@ title: "Tittel"
|
|||||||
text: "Tekst"
|
text: "Tekst"
|
||||||
next: "Neste"
|
next: "Neste"
|
||||||
retype: "Gjenta"
|
retype: "Gjenta"
|
||||||
|
quoteAttached: "Sitat"
|
||||||
|
noMessagesYet: "Ingen meldinger ennå"
|
||||||
|
newMessageExists: "Det er nye meldinger"
|
||||||
|
onlyOneFileCanBeAttached: "Du kan bare legge ved én fil i en melding"
|
||||||
invitations: "Inviter"
|
invitations: "Inviter"
|
||||||
available: "Tilgjengelig"
|
available: "Tilgjengelig"
|
||||||
unavailable: "Utilgjengelig"
|
unavailable: "Utilgjengelig"
|
||||||
tooShort: "For kort"
|
tooShort: "For kort"
|
||||||
tooLong: "For langt"
|
tooLong: "For langt"
|
||||||
|
weakPassword: "Svakt passord"
|
||||||
|
normalPassword: "Gjennomsnittlig passord"
|
||||||
|
strongPassword: "Sterkt passord"
|
||||||
|
signinWith: "Logg inn med {x}"
|
||||||
|
signinFailed: "Kunne ikke logge inn. Det oppgitte brukernavnet eller passordet er feil."
|
||||||
or: "eller"
|
or: "eller"
|
||||||
language: "Språk"
|
language: "Språk"
|
||||||
aboutX: "Om {x}"
|
aboutX: "Om {x}"
|
||||||
category: "Kategorier"
|
category: "Kategori"
|
||||||
createAccount: "Opprett konto"
|
createAccount: "Opprett konto"
|
||||||
|
openImageInNewTab: "Åpne bilder i ny fane"
|
||||||
|
clientSettings: "Klientinnstillinger"
|
||||||
|
accountSettings: "Kontoinnstillinger"
|
||||||
objectStorageRegion: "Region"
|
objectStorageRegion: "Region"
|
||||||
objectStorageUseSSL: "Bruk SSL"
|
objectStorageUseSSL: "Bruk SSL"
|
||||||
objectStorageUseProxy: "Bruk Proxy"
|
objectStorageUseProxy: "Bruk Proxy"
|
||||||
deleteAll: "Slett alt"
|
deleteAll: "Slett alt"
|
||||||
|
newNoteRecived: "Det er nye Notes"
|
||||||
listen: "Lytt"
|
listen: "Lytt"
|
||||||
none: "Ingen"
|
none: "Ingen"
|
||||||
|
volume: "Volum"
|
||||||
chooseEmoji: "Velg emoji"
|
chooseEmoji: "Velg emoji"
|
||||||
recentUsed: "Sist brukte"
|
recentUsed: "Sist brukte"
|
||||||
install: "Installer"
|
install: "Installer"
|
||||||
|
uninstall: "Avinstaller"
|
||||||
nothing: "Ingenting"
|
nothing: "Ingenting"
|
||||||
deleteAllFiles: "Slett alle filer"
|
deleteAllFiles: "Slett alle filer"
|
||||||
deleteAllFilesConfirm: "Vil du slette alle filer?"
|
deleteAllFilesConfirm: "Er du sikker på at du vil slette alle filer?"
|
||||||
|
userSuspended: "Denne brukeren har blitt suspendert."
|
||||||
accountDeleted: "Kontoen blir slettet"
|
accountDeleted: "Kontoen blir slettet"
|
||||||
accountDeletedDescription: "Denne kontoen blir slettet"
|
accountDeletedDescription: "Denne kontoen har blitt slettet."
|
||||||
menu: "Meny"
|
menu: "Meny"
|
||||||
poll: "Avstemning"
|
poll: "Avstemning"
|
||||||
description: "Beskrivelse"
|
description: "Beskrivelse"
|
||||||
@@ -274,6 +350,7 @@ small: "Liten"
|
|||||||
notificationType: "Varseltype"
|
notificationType: "Varseltype"
|
||||||
edit: "Rediger"
|
edit: "Rediger"
|
||||||
email: "E-post"
|
email: "E-post"
|
||||||
|
smtpHost: "Vert"
|
||||||
smtpUser: "Brukernavn"
|
smtpUser: "Brukernavn"
|
||||||
smtpPass: "Passord"
|
smtpPass: "Passord"
|
||||||
userSaysSomething: "{name} sa noe"
|
userSaysSomething: "{name} sa noe"
|
||||||
@@ -289,16 +366,25 @@ reportAbuse: "Rappoter"
|
|||||||
send: "Send"
|
send: "Send"
|
||||||
openInNewTab: "Åpne i ny fane"
|
openInNewTab: "Åpne i ny fane"
|
||||||
waitingFor: "Venter på {x}"
|
waitingFor: "Venter på {x}"
|
||||||
|
random: "Tilfeldig"
|
||||||
system: "System"
|
system: "System"
|
||||||
|
desktop: "Skrivebord"
|
||||||
|
i18nInfo: "Misskey oversettes til flere språk av frivillige. Du kan hjelpe til på {link}."
|
||||||
followingCount: "Følger"
|
followingCount: "Følger"
|
||||||
followersCount: "Følgere"
|
followersCount: "Følgere"
|
||||||
yes: "Ja"
|
yes: "Ja"
|
||||||
no: "Nei"
|
no: "Nei"
|
||||||
|
contact: "Kontakt"
|
||||||
|
developer: "Utvikler"
|
||||||
|
makeExplorable: "Gjør konto synlig i \"Utforsk\""
|
||||||
|
makeExplorableDescription: "Hvis du slår av dette, vises ikke kontoen din i \"Utforsk\" delen."
|
||||||
left: "Venstre"
|
left: "Venstre"
|
||||||
|
nNotes: "{n} Notes"
|
||||||
saveAs: "Lagre som"
|
saveAs: "Lagre som"
|
||||||
value: "Verdi"
|
value: "Verdi"
|
||||||
deleteConfirm: "Vil du slette?"
|
deleteConfirm: "Vil du slette?"
|
||||||
invalidValue: "Verdien er ugyldig."
|
invalidValue: "Verdien er ugyldig."
|
||||||
|
closeAccount: "Avslutt konto"
|
||||||
emailNotification: "E-postvarsler"
|
emailNotification: "E-postvarsler"
|
||||||
inChannelSearch: "Søk i kanal"
|
inChannelSearch: "Søk i kanal"
|
||||||
clear: "Tøm"
|
clear: "Tøm"
|
||||||
@@ -312,17 +398,23 @@ accounts: "Kontoer"
|
|||||||
switch: "Bytt"
|
switch: "Bytt"
|
||||||
gallery: "Galleri"
|
gallery: "Galleri"
|
||||||
ads: "Annonser"
|
ads: "Annonser"
|
||||||
|
memo: "Notat"
|
||||||
high: "Høy"
|
high: "Høy"
|
||||||
low: "Lav"
|
low: "Lav"
|
||||||
sent: "Send"
|
sent: "Sendt"
|
||||||
|
received: "Mottatt"
|
||||||
learnMore: "Les mer"
|
learnMore: "Les mer"
|
||||||
|
misskeyUpdated: "Misskey har blitt oppdatert!"
|
||||||
translate: "Oversett"
|
translate: "Oversett"
|
||||||
|
translatedFrom: "Oversatt fra {x}"
|
||||||
unread: "Ulest"
|
unread: "Ulest"
|
||||||
manageAccounts: "Administrer konto"
|
manageAccounts: "Administrer konto"
|
||||||
classic: "Klassisk"
|
classic: "Klassisk"
|
||||||
muteThread: "Skjul denne tråden"
|
muteThread: "Skjul denne tråden"
|
||||||
unmuteThread: "Vis denne tråden"
|
unmuteThread: "Vis denne tråden"
|
||||||
|
continueThread: "Vis fortsettelse av tråden"
|
||||||
hide: "Skjul"
|
hide: "Skjul"
|
||||||
|
smartphone: "Smarttelefon"
|
||||||
tablet: "Nettbrett"
|
tablet: "Nettbrett"
|
||||||
auto: "Automatisk"
|
auto: "Automatisk"
|
||||||
size: "Størrelse"
|
size: "Størrelse"
|
||||||
@@ -338,10 +430,10 @@ check: "Sjekk"
|
|||||||
deleteAccount: "Slett konto"
|
deleteAccount: "Slett konto"
|
||||||
document: "Dokumenter"
|
document: "Dokumenter"
|
||||||
logoutConfirm: "Vil du logge ut?"
|
logoutConfirm: "Vil du logge ut?"
|
||||||
pleaseSelect: "Vennligst velg"
|
pleaseSelect: "Velg et alternativ"
|
||||||
type: "Type"
|
type: "Type"
|
||||||
beta: "Beta"
|
beta: "Beta"
|
||||||
account: "Kontoer"
|
account: "Konto"
|
||||||
move: "Flytt"
|
move: "Flytt"
|
||||||
pushNotification: "Push-varsler"
|
pushNotification: "Push-varsler"
|
||||||
tools: "Verktøy"
|
tools: "Verktøy"
|
||||||
@@ -357,6 +449,7 @@ role: "Rolle"
|
|||||||
color: "Farge"
|
color: "Farge"
|
||||||
youCannotCreateAnymore: "Du kan ikke opprette flere."
|
youCannotCreateAnymore: "Du kan ikke opprette flere."
|
||||||
cannotPerformTemporary: "Midlertidig utilgjengelig"
|
cannotPerformTemporary: "Midlertidig utilgjengelig"
|
||||||
|
achievements: "Prestasjoner"
|
||||||
thisPostMayBeAnnoyingCancel: "Avbryt"
|
thisPostMayBeAnnoyingCancel: "Avbryt"
|
||||||
exploreOtherServers: "Utforsk andre severe"
|
exploreOtherServers: "Utforsk andre severe"
|
||||||
letsLookAtTimeline: "La oss se på tidslinje"
|
letsLookAtTimeline: "La oss se på tidslinje"
|
||||||
@@ -372,6 +465,26 @@ _initialAccountSetting:
|
|||||||
theseSettingsCanEditLater: "Du kan endre disse innstillingene senere."
|
theseSettingsCanEditLater: "Du kan endre disse innstillingene senere."
|
||||||
_achievements:
|
_achievements:
|
||||||
_types:
|
_types:
|
||||||
|
_notes10:
|
||||||
|
title: "Noen Notes"
|
||||||
|
_notes100:
|
||||||
|
title: "Mange Notes"
|
||||||
|
_notes500:
|
||||||
|
title: "Dekket i Notes"
|
||||||
|
_notes1000:
|
||||||
|
title: "Et fjell av Notes"
|
||||||
|
_notes5000:
|
||||||
|
title: "Overfylte Notes"
|
||||||
|
_notes10000:
|
||||||
|
title: "Super Notes"
|
||||||
|
_notes20000:
|
||||||
|
title: "Trenger... mer... Notes..."
|
||||||
|
_notes30000:
|
||||||
|
title: "Notes Notes Notes!"
|
||||||
|
_notes40000:
|
||||||
|
title: "Note fabrikk"
|
||||||
|
_notes50000:
|
||||||
|
title: "Planet av Notes"
|
||||||
_notes100000:
|
_notes100000:
|
||||||
flavor: "Du har jammen mye å si."
|
flavor: "Du har jammen mye å si."
|
||||||
_noteFavorited1:
|
_noteFavorited1:
|
||||||
@@ -400,7 +513,7 @@ _achievements:
|
|||||||
_justPlainLucky:
|
_justPlainLucky:
|
||||||
title: "Rett og slett heldig"
|
title: "Rett og slett heldig"
|
||||||
_setNameToSyuilo:
|
_setNameToSyuilo:
|
||||||
description: "Du har satt navnet ditt til \"syuilo\""
|
description: "Du satte navnet ditt til \"syuilo\""
|
||||||
_passedSinceAccountCreated1:
|
_passedSinceAccountCreated1:
|
||||||
title: "Ett års jubileum"
|
title: "Ett års jubileum"
|
||||||
description: "Det har gått ett år siden kontoen din ble opprettet"
|
description: "Det har gått ett år siden kontoen din ble opprettet"
|
||||||
@@ -468,15 +581,17 @@ _theme:
|
|||||||
key: "Nøkkel"
|
key: "Nøkkel"
|
||||||
keys:
|
keys:
|
||||||
link: "Lenke"
|
link: "Lenke"
|
||||||
|
renote: "Renote"
|
||||||
_sfx:
|
_sfx:
|
||||||
|
note: "Notes"
|
||||||
notification: "Varsler"
|
notification: "Varsler"
|
||||||
_ago:
|
_ago:
|
||||||
future: "Fremitid"
|
future: "Fremitid"
|
||||||
justNow: "Akkurat nå"
|
justNow: "Akkurat nå"
|
||||||
secondsAgo: "{n} sekunder siden"
|
secondsAgo: "{n}s siden"
|
||||||
minutesAgo: "{n} minutter siden"
|
minutesAgo: "{n}m siden"
|
||||||
hoursAgo: "{n} timer siden"
|
hoursAgo: "{n}t siden"
|
||||||
daysAgo: "{n} dager siden"
|
daysAgo: "{n}d siden"
|
||||||
weeksAgo: "{n} uker siden"
|
weeksAgo: "{n} uker siden"
|
||||||
monthsAgo: "{n} måneder siden"
|
monthsAgo: "{n} måneder siden"
|
||||||
yearsAgo: "{n} år siden"
|
yearsAgo: "{n} år siden"
|
||||||
@@ -488,6 +603,7 @@ _time:
|
|||||||
day: "Dager"
|
day: "Dager"
|
||||||
_timelineTutorial:
|
_timelineTutorial:
|
||||||
title: "Hvordan bruke Misskey"
|
title: "Hvordan bruke Misskey"
|
||||||
|
step2_2: "Hva med å skrive en selvpresentasjon, eller bare \"Hei {name}!\" hvis du ikke har lyst?"
|
||||||
_2fa:
|
_2fa:
|
||||||
renewTOTPCancel: "Avbryt"
|
renewTOTPCancel: "Avbryt"
|
||||||
_weekday:
|
_weekday:
|
||||||
@@ -500,6 +616,7 @@ _weekday:
|
|||||||
saturday: "Lørdag"
|
saturday: "Lørdag"
|
||||||
_widgets:
|
_widgets:
|
||||||
profile: "Profil"
|
profile: "Profil"
|
||||||
|
instanceInfo: "Serverinformasjon"
|
||||||
notifications: "Varsler"
|
notifications: "Varsler"
|
||||||
timeline: "Tidslinje"
|
timeline: "Tidslinje"
|
||||||
calendar: "Kalender"
|
calendar: "Kalender"
|
||||||
@@ -535,6 +652,7 @@ _postForm:
|
|||||||
_profile:
|
_profile:
|
||||||
name: "Navn"
|
name: "Navn"
|
||||||
username: "Brukernavn"
|
username: "Brukernavn"
|
||||||
|
description: "Biografi"
|
||||||
metadataContent: "Innhold"
|
metadataContent: "Innhold"
|
||||||
_exportOrImport:
|
_exportOrImport:
|
||||||
followingList: "Følg"
|
followingList: "Følg"
|
||||||
@@ -576,13 +694,17 @@ _pages:
|
|||||||
button: "Knapp"
|
button: "Knapp"
|
||||||
_notification:
|
_notification:
|
||||||
youWereFollowed: "fulgte deg"
|
youWereFollowed: "fulgte deg"
|
||||||
|
unreadAntennaNote: "Antenne {name}"
|
||||||
|
achievementEarned: "Prestasjon låst opp"
|
||||||
_types:
|
_types:
|
||||||
follow: "Følg"
|
follow: "Nye følgere"
|
||||||
reply: "Svar"
|
reply: "Svar"
|
||||||
quote: "Sitat"
|
renote: "Renotes"
|
||||||
reaction: "Reaksjon"
|
quote: "Sitater"
|
||||||
|
reaction: "Reaksjoner"
|
||||||
_actions:
|
_actions:
|
||||||
reply: "Svar"
|
reply: "Svar"
|
||||||
|
renote: "Renote"
|
||||||
_deck:
|
_deck:
|
||||||
swapLeft: "Flytt til venstre"
|
swapLeft: "Flytt til venstre"
|
||||||
swapRight: "Flytt til høyre"
|
swapRight: "Flytt til høyre"
|
||||||
@@ -594,6 +716,7 @@ _deck:
|
|||||||
_columns:
|
_columns:
|
||||||
notifications: "Varsler"
|
notifications: "Varsler"
|
||||||
tl: "Tidslinje"
|
tl: "Tidslinje"
|
||||||
|
antenna: "Antenner"
|
||||||
list: "Lister"
|
list: "Lister"
|
||||||
channel: "Kanaler"
|
channel: "Kanaler"
|
||||||
direct: "Direkte"
|
direct: "Direkte"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
_lang_: "Русский"
|
_lang_: "Русский"
|
||||||
headlineMisskey: "Сеть, сплетённая из заметок"
|
headlineMisskey: "Сеть, сплетённая из заметок"
|
||||||
introMisskey: "Добро пожаловать! Misskey — это децентрализованный сервис микроблогов с открытым исходным кодом.\nПишите «заметки» — делитесь со всеми происходящим вокруг или рассказывайте о себе 📡\nСтавьте «реакции» — выражайте свои чувства и эмоции от заметок других 👍\nОткройте для себя новый мир 🚀"
|
introMisskey: "Добро пожаловать! Misskey — это децентрализованный сервис микроблогов с открытым исходным кодом.\nПишите «заметки» — делитесь со всеми происходящим вокруг или рассказывайте о себе 📡\nСтавьте «реакции» — выражайте свои чувства и эмоции от заметок других 👍\nОткройте для себя новый мир 🚀"
|
||||||
poweredByMisskeyDescription: "{name} – один из инстансов (также называемый экземпляром Misskey), использующий платформу с открытым исходным кодом <b>Misskey</b>."
|
poweredByMisskeyDescription: "{name} – сервис на платформе с открытым исходным кодом <b>Misskey</b>, называемый инстансом Misskey."
|
||||||
monthAndDay: "{day}.{month}"
|
monthAndDay: "{day}.{month}"
|
||||||
search: "Поиск"
|
search: "Поиск"
|
||||||
notifications: "Уведомления"
|
notifications: "Уведомления"
|
||||||
@@ -649,8 +649,8 @@ abuseReported: "Жалоба отправлена. Большое спасибо
|
|||||||
reporter: "Сообщивший"
|
reporter: "Сообщивший"
|
||||||
reporteeOrigin: "О ком сообщено"
|
reporteeOrigin: "О ком сообщено"
|
||||||
reporterOrigin: "Кто сообщил"
|
reporterOrigin: "Кто сообщил"
|
||||||
forwardReport: "Перенаправление отчета на инстант."
|
forwardReport: "Отправить жалобу на инстанс автора."
|
||||||
forwardReportIsAnonymous: "Удаленный инстант не сможет увидеть вашу информацию и будет отображаться как анонимная системная учетная запись."
|
forwardReportIsAnonymous: "Жалоба на удалённый инстанс будет отправлена анонимно. Вместо ваших данных у получателя будет отображена системная учётная запись."
|
||||||
send: "Отправить"
|
send: "Отправить"
|
||||||
abuseMarkAsResolved: "Отметить жалобу как решённую"
|
abuseMarkAsResolved: "Отметить жалобу как решённую"
|
||||||
openInNewTab: "Открыть в новой вкладке"
|
openInNewTab: "Открыть в новой вкладке"
|
||||||
@@ -823,6 +823,7 @@ translatedFrom: "Перевод. Язык оригинала — {x}"
|
|||||||
accountDeletionInProgress: "В настоящее время выполняется удаление учетной записи"
|
accountDeletionInProgress: "В настоящее время выполняется удаление учетной записи"
|
||||||
usernameInfo: "Имя, которое отличает вашу учетную запись от других на этом сервере. Вы можете использовать алфавит (a~z, A~Z), цифры (0~9) или символы подчеркивания (_). Имена пользователей не могут быть изменены позже."
|
usernameInfo: "Имя, которое отличает вашу учетную запись от других на этом сервере. Вы можете использовать алфавит (a~z, A~Z), цифры (0~9) или символы подчеркивания (_). Имена пользователей не могут быть изменены позже."
|
||||||
aiChanMode: "Режим Ай"
|
aiChanMode: "Режим Ай"
|
||||||
|
devMode: "Режим разработчика"
|
||||||
keepCw: "Сохраняйте Предупреждения о содержимом"
|
keepCw: "Сохраняйте Предупреждения о содержимом"
|
||||||
pubSub: "Учётные записи Pub/Sub"
|
pubSub: "Учётные записи Pub/Sub"
|
||||||
lastCommunication: "Последнее сообщение"
|
lastCommunication: "Последнее сообщение"
|
||||||
@@ -914,8 +915,8 @@ cannotUploadBecauseInappropriate: "Файл не может быть загру
|
|||||||
cannotUploadBecauseNoFreeSpace: "Файл не может быть загружен, так как не осталось места на диске"
|
cannotUploadBecauseNoFreeSpace: "Файл не может быть загружен, так как не осталось места на диске"
|
||||||
cannotUploadBecauseExceedsFileSizeLimit: "Файл не может быть загружен, так как он превышает лимит размера файла."
|
cannotUploadBecauseExceedsFileSizeLimit: "Файл не может быть загружен, так как он превышает лимит размера файла."
|
||||||
beta: "Бета"
|
beta: "Бета"
|
||||||
enableAutoSensitive: "Автоматическое определение NSFW"
|
enableAutoSensitive: "Автоматическое определение содержимого не для всех"
|
||||||
enableAutoSensitiveDescription: "Если доступно, используйте машинное обучение для автоматической установки флага NSFW на носителе. Даже если эта функция отключена, она может быть установлена автоматически в зависимости от инстанта."
|
enableAutoSensitiveDescription: "Позволяет определять наличие содержимого не для всех при помощи искусственного интеллекта там, где это возможно. Даже если эту опцию отключить, она всё равно может быть включена на весь инстанс."
|
||||||
activeEmailValidationDescription: "Если включено, будет проводиться более строгая проверка адреса электронной почты, в том числе на то, что он действительный и не временный. Если же отключено, то проверяется только корректность написания адреса."
|
activeEmailValidationDescription: "Если включено, будет проводиться более строгая проверка адреса электронной почты, в том числе на то, что он действительный и не временный. Если же отключено, то проверяется только корректность написания адреса."
|
||||||
navbar: "Панель навигации"
|
navbar: "Панель навигации"
|
||||||
shuffle: "Перемешать"
|
shuffle: "Перемешать"
|
||||||
@@ -1006,6 +1007,7 @@ noteIdOrUrl: "ID или ссылка на заметку"
|
|||||||
video: "Видео"
|
video: "Видео"
|
||||||
videos: "Видео"
|
videos: "Видео"
|
||||||
dataSaver: "Экономия трафика"
|
dataSaver: "Экономия трафика"
|
||||||
|
renotesList: "Репосты"
|
||||||
horizontal: "Сбоку"
|
horizontal: "Сбоку"
|
||||||
youFollowing: "Подписки"
|
youFollowing: "Подписки"
|
||||||
options: "Настройки ролей"
|
options: "Настройки ролей"
|
||||||
@@ -1180,6 +1182,9 @@ _achievements:
|
|||||||
_client30min:
|
_client30min:
|
||||||
title: "Перерыв на обед"
|
title: "Перерыв на обед"
|
||||||
description: "Прошло 30 минут с момента запуска клиента"
|
description: "Прошло 30 минут с момента запуска клиента"
|
||||||
|
_client60min:
|
||||||
|
title: "Не наглядеться на Misskey"
|
||||||
|
description: "Misskey был открыт 60 минут подряд"
|
||||||
_noteDeletedWithin1min:
|
_noteDeletedWithin1min:
|
||||||
title: "Ой, нет!"
|
title: "Ой, нет!"
|
||||||
description: "Заметка удалена через минуту после публикации"
|
description: "Заметка удалена через минуту после публикации"
|
||||||
@@ -1282,6 +1287,7 @@ _role:
|
|||||||
canInvite: "Может создавать пригласительные коды"
|
canInvite: "Может создавать пригласительные коды"
|
||||||
canManageCustomEmojis: "Управлять пользовательскими эмодзи"
|
canManageCustomEmojis: "Управлять пользовательскими эмодзи"
|
||||||
driveCapacity: "Доступное пространство на «диске»"
|
driveCapacity: "Доступное пространство на «диске»"
|
||||||
|
alwaysMarkNsfw: "Всегда отмечать файлы как «не для всех»"
|
||||||
pinMax: "Доступное количество закреплённых заметок"
|
pinMax: "Доступное количество закреплённых заметок"
|
||||||
antennaMax: "Доступное количество антенн"
|
antennaMax: "Доступное количество антенн"
|
||||||
wordMuteMax: "Доступное количество знаков в списке скрытия слов"
|
wordMuteMax: "Доступное количество знаков в списке скрытия слов"
|
||||||
@@ -1309,7 +1315,7 @@ _sensitiveMediaDetection:
|
|||||||
description: "Машинное обучение может быть использовано для автоматического обнаружения чувствительных медиа для модерации. Нагрузка на сервер увеличивается незначительно."
|
description: "Машинное обучение может быть использовано для автоматического обнаружения чувствительных медиа для модерации. Нагрузка на сервер увеличивается незначительно."
|
||||||
sensitivity: "Чувствительность обнаружения"
|
sensitivity: "Чувствительность обнаружения"
|
||||||
sensitivityDescription: "Более низкая чувствительность уменьшает количество ложных срабатываний (false positives). Повышение чувствительности уменьшает утечку при обнаружении (ложноотрицательные результаты)."
|
sensitivityDescription: "Более низкая чувствительность уменьшает количество ложных срабатываний (false positives). Повышение чувствительности уменьшает утечку при обнаружении (ложноотрицательные результаты)."
|
||||||
setSensitiveFlagAutomatically: "Установить флаг NSFW"
|
setSensitiveFlagAutomatically: "Обозначить как не для всех"
|
||||||
setSensitiveFlagAutomaticallyDescription: "Даже если этот параметр отключен, результат оценки сохраняется внутри системы."
|
setSensitiveFlagAutomaticallyDescription: "Даже если этот параметр отключен, результат оценки сохраняется внутри системы."
|
||||||
analyzeVideos: "Анализировать видео?"
|
analyzeVideos: "Анализировать видео?"
|
||||||
analyzeVideosDescription: "Анализируйте видео в дополнение к неподвижным изображениям. Нагрузка на сервер немного увеличивается."
|
analyzeVideosDescription: "Анализируйте видео в дополнение к неподвижным изображениям. Нагрузка на сервер немного увеличивается."
|
||||||
@@ -1528,6 +1534,16 @@ _time:
|
|||||||
minute: "мин"
|
minute: "мин"
|
||||||
hour: "ч"
|
hour: "ч"
|
||||||
day: "сут"
|
day: "сут"
|
||||||
|
_timelineTutorial:
|
||||||
|
title: "Как пользоваться Misskey"
|
||||||
|
step1_1: "Это лицо Misskey, так называемая лента. Ваш инстанс, {name}, покажет тут все опубликованные на нём заметки в хронологическом порядке."
|
||||||
|
step1_2: "Здесь есть несколько лент. К примеру «персональная» лента отображает заметки тех, на кого вы подписаны. А «местная» — заметки тех, кого приютил {name}."
|
||||||
|
step2_1: "Что ж, теперь самое время опубликовать заметку. Если нажать вверху страницы на изображение карандаша, появится форма для текста."
|
||||||
|
step2_2: "Почему бы не написать немного о себе? Ну, или хотя бы «Привет, {name}»?"
|
||||||
|
step3_1: "Справились с первой заметкой?"
|
||||||
|
step3_2: "Отлично, теперь она должна появиться в вашей ленте."
|
||||||
|
step4_1: "А ещё здесь можно делиться своими реакциями на заметки."
|
||||||
|
step4_2: "Отмечайте реакции, нажимая на символ «+» под заметкой и выбирая значок по душе."
|
||||||
_2fa:
|
_2fa:
|
||||||
alreadyRegistered: "Двухфакторная аутентификация уже настроена."
|
alreadyRegistered: "Двухфакторная аутентификация уже настроена."
|
||||||
registerTOTP: "Начните настраивать приложение-аутентификатор"
|
registerTOTP: "Начните настраивать приложение-аутентификатор"
|
||||||
@@ -1868,6 +1884,9 @@ _deck:
|
|||||||
_dialog:
|
_dialog:
|
||||||
charactersExceeded: "Превышено максимальное количество символов! У вас {current} / из {max}"
|
charactersExceeded: "Превышено максимальное количество символов! У вас {current} / из {max}"
|
||||||
charactersBelow: "Это ниже минимального количества символов! У вас {current} / из {min}"
|
charactersBelow: "Это ниже минимального количества символов! У вас {current} / из {min}"
|
||||||
|
_disabledTimeline:
|
||||||
|
title: "Лента отключена"
|
||||||
|
description: "Ваша текущая роль не позволяет пользоваться этой лентой."
|
||||||
_webhookSettings:
|
_webhookSettings:
|
||||||
name: "Название"
|
name: "Название"
|
||||||
active: "Вкл."
|
active: "Вкл."
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
_lang_: "Türkçe"
|
_lang_: "Türkçe"
|
||||||
introMisskey: "Açık kaynaklı bir dağıtılmış mikroblog hizmeti olan Misskey'e hoş geldiniz.\nMisskey, neler olup bittiğini paylaşmak ve herkese sizden bahsetmek için \"notlar\" oluşturmanıza olanak tanıyan, açık kaynaklı, dağıtılmış bir mikroblog hizmetidir.\nHerkesin notlarına kendi tepkilerinizi hızlıca eklemek için \"Tepkiler\" özelliğini de kullanabilirsiniz👍.\nYeni bir dünyayı keşfedin🚀."
|
introMisskey: "Açık kaynaklı bir dağıtılmış mikroblog hizmeti olan Misskey'e hoş geldiniz.\nMisskey, neler olup bittiğini paylaşmak ve herkese sizden bahsetmek için \"notlar\" oluşturmanıza olanak tanıyan, açık kaynaklı, dağıtılmış bir mikroblog hizmetidir.\nHerkesin notlarına kendi tepkilerinizi hızlıca eklemek için \"Tepkiler\" özelliğini de kullanabilirsiniz👍.\nYeni bir dünyayı keşfedin🚀."
|
||||||
|
poweredByMisskeyDescription: "name}Açık kaynak bir platform\n<b>Misskey</b>Dünya'nın en sunucularında biri。"
|
||||||
monthAndDay: "{month}Ay {day}Gün"
|
monthAndDay: "{month}Ay {day}Gün"
|
||||||
search: "Arama"
|
search: "Arama"
|
||||||
notifications: "Bildirim"
|
notifications: "Bildirim"
|
||||||
@@ -13,7 +14,9 @@ cancel: "İptal"
|
|||||||
enterUsername: "Kullanıcı adınızı giriniz"
|
enterUsername: "Kullanıcı adınızı giriniz"
|
||||||
noNotes: "Notlar mevcut değil."
|
noNotes: "Notlar mevcut değil."
|
||||||
noNotifications: "Bildirim bulunmuyor"
|
noNotifications: "Bildirim bulunmuyor"
|
||||||
|
instance: "Sunucu"
|
||||||
settings: "Ayarlar"
|
settings: "Ayarlar"
|
||||||
|
notificationSettings: "Bildirim Ayarları"
|
||||||
basicSettings: "Temel Ayarlar"
|
basicSettings: "Temel Ayarlar"
|
||||||
otherSettings: "Diğer Ayarlar"
|
otherSettings: "Diğer Ayarlar"
|
||||||
openInWindow: "Bir pencere ile aç"
|
openInWindow: "Bir pencere ile aç"
|
||||||
@@ -21,9 +24,11 @@ profile: "Profil"
|
|||||||
timeline: "Zaman çizelgesi"
|
timeline: "Zaman çizelgesi"
|
||||||
noAccountDescription: "Bu kullanıcı henüz biyografisini yazmadı"
|
noAccountDescription: "Bu kullanıcı henüz biyografisini yazmadı"
|
||||||
login: "Giriş Yap "
|
login: "Giriş Yap "
|
||||||
|
loggingIn: "Oturum aç"
|
||||||
logout: "Çıkış Yap"
|
logout: "Çıkış Yap"
|
||||||
signup: "Kayıt Ol"
|
signup: "Kayıt Ol"
|
||||||
uploading: "Yükleniyor"
|
uploading: "Yükleniyor"
|
||||||
|
save: "Kaydet"
|
||||||
users: "Kullanıcı"
|
users: "Kullanıcı"
|
||||||
addUser: "Kullanıcı Ekle"
|
addUser: "Kullanıcı Ekle"
|
||||||
favorite: "Favoriler"
|
favorite: "Favoriler"
|
||||||
@@ -31,6 +36,7 @@ favorites: "Favoriler"
|
|||||||
unfavorite: "Favorilerden Kaldır"
|
unfavorite: "Favorilerden Kaldır"
|
||||||
favorited: "Favorilerime eklendi."
|
favorited: "Favorilerime eklendi."
|
||||||
alreadyFavorited: "Zaten favorilerinizde kayıtlı."
|
alreadyFavorited: "Zaten favorilerinizde kayıtlı."
|
||||||
|
cantFavorite: "Favorilere kayıt yapılamadı"
|
||||||
pin: "Sabitlenmiş"
|
pin: "Sabitlenmiş"
|
||||||
unpin: "Sabitlemeyi kaldır"
|
unpin: "Sabitlemeyi kaldır"
|
||||||
copyContent: "İçeriği kopyala"
|
copyContent: "İçeriği kopyala"
|
||||||
@@ -40,23 +46,88 @@ deleteAndEdit: "Sil ve yeniden düzenle"
|
|||||||
deleteAndEditConfirm: "Bu notu silip yeniden düzenlemek istiyor musunuz? Bu nota ilişkin tüm Tepkiler, Yeniden Notlar ve Yanıtlar da silinecektir."
|
deleteAndEditConfirm: "Bu notu silip yeniden düzenlemek istiyor musunuz? Bu nota ilişkin tüm Tepkiler, Yeniden Notlar ve Yanıtlar da silinecektir."
|
||||||
addToList: "Listeye ekle"
|
addToList: "Listeye ekle"
|
||||||
sendMessage: "Mesaj Gönder"
|
sendMessage: "Mesaj Gönder"
|
||||||
|
copyRSS: "RSSKopyala"
|
||||||
copyUsername: "Kullanıcı Adını Kopyala"
|
copyUsername: "Kullanıcı Adını Kopyala"
|
||||||
|
copyUserId: "KullanıcıyıKopyala"
|
||||||
|
copyNoteId: "Kimlik notunu kopyala"
|
||||||
searchUser: "Kullanıcıları ara"
|
searchUser: "Kullanıcıları ara"
|
||||||
|
reply: "yanıt"
|
||||||
|
loadMore: "Devamını yükle"
|
||||||
|
showMore: "Devamını yükle"
|
||||||
|
lists: "Listeler"
|
||||||
|
noLists: "Liste yok"
|
||||||
|
note: "not"
|
||||||
|
notes: "notlar"
|
||||||
|
following: "takipçi"
|
||||||
|
followers: "takipçi"
|
||||||
|
followsYou: "seni takip ediyor"
|
||||||
|
createList: "Liste oluştur"
|
||||||
|
manageLists: "Yönetici Listeleri"
|
||||||
|
error: "hata"
|
||||||
|
follow: "takipçi"
|
||||||
|
followRequest: "Takip isteği"
|
||||||
|
followRequests: "Takip istekleri"
|
||||||
|
unfollow: "takip etmeyi bırak"
|
||||||
|
followRequestPending: "Bekleyen Takip Etme Talebi"
|
||||||
|
enterEmoji: "Emoji Giriniz"
|
||||||
|
renote: "vazgeçme"
|
||||||
|
unrenote: "not alma"
|
||||||
|
renoted: "yeniden adlandırılmış"
|
||||||
|
cantRenote: "Ayrılamama"
|
||||||
|
cantReRenote: "not alabilirmiyim"
|
||||||
|
quote: "alıntı"
|
||||||
|
pinnedNote: "Sabitlenen"
|
||||||
pinned: "Sabitlenmiş"
|
pinned: "Sabitlenmiş"
|
||||||
|
you: "sen"
|
||||||
|
unmute: "sesi aç"
|
||||||
|
renoteMute: "sesi kapat"
|
||||||
|
renoteUnmute: "sesi açmayı iptal et"
|
||||||
|
block: "engelle"
|
||||||
|
unblock: "engellemeyi kaldır"
|
||||||
|
suspend: "askıya al"
|
||||||
|
unsuspend: "askıya alma"
|
||||||
|
blockConfirm: "Onayı engelle"
|
||||||
|
unblockConfirm: "engellemeyi kaldır onayla"
|
||||||
|
selectChannel: "Kanal seç"
|
||||||
|
flagAsBot: "Bot olarak işaretle"
|
||||||
|
instances: "Sunucu"
|
||||||
remove: "Sil"
|
remove: "Sil"
|
||||||
|
pinnedNotes: "Sabitlenen"
|
||||||
|
userList: "Listeler"
|
||||||
smtpUser: "Kullanıcı Adı"
|
smtpUser: "Kullanıcı Adı"
|
||||||
smtpPass: "Şifre"
|
smtpPass: "Şifre"
|
||||||
user: "Kullanıcı"
|
user: "Kullanıcı"
|
||||||
searchByGoogle: "Arama"
|
searchByGoogle: "Arama"
|
||||||
|
_theme:
|
||||||
|
keys:
|
||||||
|
renote: "vazgeçme"
|
||||||
_sfx:
|
_sfx:
|
||||||
|
note: "notlar"
|
||||||
notification: "Bildirim"
|
notification: "Bildirim"
|
||||||
_widgets:
|
_widgets:
|
||||||
profile: "Profil"
|
profile: "Profil"
|
||||||
notifications: "Bildirim"
|
notifications: "Bildirim"
|
||||||
timeline: "Zaman çizelgesi"
|
timeline: "Zaman çizelgesi"
|
||||||
|
_cw:
|
||||||
|
show: "Devamını yükle"
|
||||||
|
_visibility:
|
||||||
|
followers: "takipçi"
|
||||||
_profile:
|
_profile:
|
||||||
username: "Kullanıcı Adı"
|
username: "Kullanıcı Adı"
|
||||||
|
_exportOrImport:
|
||||||
|
followingList: "takipçi"
|
||||||
|
blockingList: "engelle"
|
||||||
|
userLists: "Listeler"
|
||||||
|
_notification:
|
||||||
|
_types:
|
||||||
|
follow: "takipçi"
|
||||||
|
renote: "vazgeçme"
|
||||||
|
quote: "alıntı"
|
||||||
|
_actions:
|
||||||
|
reply: "yanıt"
|
||||||
|
renote: "vazgeçme"
|
||||||
_deck:
|
_deck:
|
||||||
_columns:
|
_columns:
|
||||||
notifications: "Bildirim"
|
notifications: "Bildirim"
|
||||||
tl: "Zaman çizelgesi"
|
tl: "Zaman çizelgesi"
|
||||||
|
list: "Listeler"
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ addToList: "添加至列表"
|
|||||||
sendMessage: "发送"
|
sendMessage: "发送"
|
||||||
copyRSS: "复制RSS"
|
copyRSS: "复制RSS"
|
||||||
copyUsername: "复制用户名"
|
copyUsername: "复制用户名"
|
||||||
|
copyUserId: "复制用户ID"
|
||||||
|
copyNoteId: "复制帖子ID"
|
||||||
searchUser: "搜索用户"
|
searchUser: "搜索用户"
|
||||||
reply: "回复"
|
reply: "回复"
|
||||||
loadMore: "查看更多"
|
loadMore: "查看更多"
|
||||||
@@ -790,6 +792,7 @@ noMaintainerInformationWarning: "管理人员信息未设置。"
|
|||||||
noBotProtectionWarning: "Bot保护未设置。"
|
noBotProtectionWarning: "Bot保护未设置。"
|
||||||
configure: "设置"
|
configure: "设置"
|
||||||
postToGallery: "发送到图库"
|
postToGallery: "发送到图库"
|
||||||
|
postToHashtag: "投稿到这个标签"
|
||||||
gallery: "图库"
|
gallery: "图库"
|
||||||
recentPosts: "最新发布"
|
recentPosts: "最新发布"
|
||||||
popularPosts: "热门投稿"
|
popularPosts: "热门投稿"
|
||||||
@@ -823,6 +826,7 @@ translatedFrom: "从 {x} 翻译"
|
|||||||
accountDeletionInProgress: "正在删除账户"
|
accountDeletionInProgress: "正在删除账户"
|
||||||
usernameInfo: "在服务器上唯一标识您的帐户的名称。您可以使用字母 (a ~ z, A ~ Z)、数字 (0 ~ 9) 和下划线 (_)。用户名以后不能更改。"
|
usernameInfo: "在服务器上唯一标识您的帐户的名称。您可以使用字母 (a ~ z, A ~ Z)、数字 (0 ~ 9) 和下划线 (_)。用户名以后不能更改。"
|
||||||
aiChanMode: "小蓝模式"
|
aiChanMode: "小蓝模式"
|
||||||
|
devMode: "开发者模式"
|
||||||
keepCw: "回复时维持隐藏内容"
|
keepCw: "回复时维持隐藏内容"
|
||||||
pubSub: "Pub/Sub账户"
|
pubSub: "Pub/Sub账户"
|
||||||
lastCommunication: "最近通信"
|
lastCommunication: "最近通信"
|
||||||
@@ -832,6 +836,8 @@ breakFollow: "移除关注者"
|
|||||||
breakFollowConfirm: "你想取消关注吗?"
|
breakFollowConfirm: "你想取消关注吗?"
|
||||||
itsOn: "已开启"
|
itsOn: "已开启"
|
||||||
itsOff: "已关闭"
|
itsOff: "已关闭"
|
||||||
|
on: "开启"
|
||||||
|
off: "关闭"
|
||||||
emailRequiredForSignup: "注册账户需要电子邮件地址"
|
emailRequiredForSignup: "注册账户需要电子邮件地址"
|
||||||
unread: "未读"
|
unread: "未读"
|
||||||
filter: "筛选"
|
filter: "筛选"
|
||||||
@@ -986,6 +992,8 @@ cannotBeChangedLater: "之后不能再更改。"
|
|||||||
reactionAcceptance: "接受表情回应"
|
reactionAcceptance: "接受表情回应"
|
||||||
likeOnly: "仅点赞"
|
likeOnly: "仅点赞"
|
||||||
likeOnlyForRemote: "远程仅点赞"
|
likeOnlyForRemote: "远程仅点赞"
|
||||||
|
nonSensitiveOnly: "仅限非敏感内容"
|
||||||
|
nonSensitiveOnlyForLocalLikeOnlyForRemote: "仅限非敏感内容(远程仅点赞)"
|
||||||
rolesAssignedToMe: "指派给自己的角色"
|
rolesAssignedToMe: "指派给自己的角色"
|
||||||
resetPasswordConfirm: "确定重置密码?"
|
resetPasswordConfirm: "确定重置密码?"
|
||||||
sensitiveWords: "敏感词"
|
sensitiveWords: "敏感词"
|
||||||
@@ -1043,6 +1051,17 @@ preventAiLearning: "拒绝接受生成式AI的学习"
|
|||||||
preventAiLearningDescription: "要求文章生成AI或图像生成AI不能够以发布的帖子和图像等内容作为学习对象。这是通过在HTML响应中包含noai标志来实现的,这不能完全阻止AI学习你的发布内容,并不是所有AI都会遵守这类请求。"
|
preventAiLearningDescription: "要求文章生成AI或图像生成AI不能够以发布的帖子和图像等内容作为学习对象。这是通过在HTML响应中包含noai标志来实现的,这不能完全阻止AI学习你的发布内容,并不是所有AI都会遵守这类请求。"
|
||||||
options: "选项"
|
options: "选项"
|
||||||
specifyUser: "用户指定"
|
specifyUser: "用户指定"
|
||||||
|
failedToPreviewUrl: "无法预览"
|
||||||
|
update: "更新"
|
||||||
|
rolesThatCanBeUsedThisEmojiAsReaction: "可以使用表情作为回应的角色"
|
||||||
|
rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "在没有指定角色的情况下,任何人都可以使用表情作为回应。"
|
||||||
|
rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "角色必须是公开的。"
|
||||||
|
cancelReactionConfirm: "要取消回应吗?"
|
||||||
|
changeReactionConfirm: "要更改回应吗?"
|
||||||
|
later: "一会再说"
|
||||||
|
goToMisskey: "去往Misskey"
|
||||||
|
additionalEmojiDictionary: "表情符号追加字典"
|
||||||
|
installed: "已安装"
|
||||||
_initialAccountSetting:
|
_initialAccountSetting:
|
||||||
accountCreated: "账户创建完成了!"
|
accountCreated: "账户创建完成了!"
|
||||||
letsStartAccountSetup: "来进行帐户的初始设置吧。"
|
letsStartAccountSetup: "来进行帐户的初始设置吧。"
|
||||||
@@ -1057,6 +1076,7 @@ _initialAccountSetting:
|
|||||||
haveFun: "希望{name}在这里玩得开心!"
|
haveFun: "希望{name}在这里玩得开心!"
|
||||||
ifYouNeedLearnMore: "关于{name}(Misskey)的使用方法,详见{link}。"
|
ifYouNeedLearnMore: "关于{name}(Misskey)的使用方法,详见{link}。"
|
||||||
skipAreYouSure: "要跳过初始设置吗?"
|
skipAreYouSure: "要跳过初始设置吗?"
|
||||||
|
laterAreYouSure: "要稍后再进行初始设定吗?"
|
||||||
_serverRules:
|
_serverRules:
|
||||||
description: "在新用户注册前显示服务器的简单规则。推荐显示服务条款的主要内容。"
|
description: "在新用户注册前显示服务器的简单规则。推荐显示服务条款的主要内容。"
|
||||||
_accountMigration:
|
_accountMigration:
|
||||||
|
|||||||
@@ -1055,6 +1055,14 @@ failedToPreviewUrl: "無法預覽"
|
|||||||
update: "更新"
|
update: "更新"
|
||||||
rolesThatCanBeUsedThisEmojiAsReaction: "可以當成反應使用的角色"
|
rolesThatCanBeUsedThisEmojiAsReaction: "可以當成反應使用的角色"
|
||||||
rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "如果是未指定角色的情況,則任何人都可以被當成反應來使用。"
|
rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "如果是未指定角色的情況,則任何人都可以被當成反應來使用。"
|
||||||
|
rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "角色必須是公開的角色。"
|
||||||
|
cancelReactionConfirm: "要取消做出的反應嗎?"
|
||||||
|
changeReactionConfirm: "要變更做出的反應嗎?"
|
||||||
|
later: "稍後再說"
|
||||||
|
goToMisskey: "往Misskey"
|
||||||
|
additionalEmojiDictionary: "表情符號的附加辭典"
|
||||||
|
installed: "已安裝"
|
||||||
|
branding: "品牌宣傳"
|
||||||
_initialAccountSetting:
|
_initialAccountSetting:
|
||||||
accountCreated: "帳戶已建立完成!"
|
accountCreated: "帳戶已建立完成!"
|
||||||
letsStartAccountSetup: "來進行帳戶的初始設定吧。"
|
letsStartAccountSetup: "來進行帳戶的初始設定吧。"
|
||||||
@@ -1069,6 +1077,7 @@ _initialAccountSetting:
|
|||||||
haveFun: "盡情享受{name}吧!"
|
haveFun: "盡情享受{name}吧!"
|
||||||
ifYouNeedLearnMore: "關於如何使用{name}(Misskey)的詳細資訊,請見{link}。"
|
ifYouNeedLearnMore: "關於如何使用{name}(Misskey)的詳細資訊,請見{link}。"
|
||||||
skipAreYouSure: "要略過初始設定嗎?"
|
skipAreYouSure: "要略過初始設定嗎?"
|
||||||
|
laterAreYouSure: "稍後再重新進行初始設定嗎?"
|
||||||
_serverRules:
|
_serverRules:
|
||||||
description: "設定伺服器的簡要規則,在新的註冊之前顯示。建議的內容是使用條款的摘要。"
|
description: "設定伺服器的簡要規則,在新的註冊之前顯示。建議的內容是使用條款的摘要。"
|
||||||
_accountMigration:
|
_accountMigration:
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "misskey",
|
"name": "misskey",
|
||||||
"version": "13.13.0-beta.5",
|
"version": "13.13.2",
|
||||||
"codename": "nasubi",
|
"codename": "nasubi",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -51,16 +51,16 @@
|
|||||||
"gulp-replace": "1.1.4",
|
"gulp-replace": "1.1.4",
|
||||||
"gulp-terser": "2.1.0",
|
"gulp-terser": "2.1.0",
|
||||||
"js-yaml": "4.1.0",
|
"js-yaml": "4.1.0",
|
||||||
"typescript": "5.0.4"
|
"typescript": "5.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/gulp": "4.0.10",
|
"@types/gulp": "4.0.10",
|
||||||
"@types/gulp-rename": "2.0.1",
|
"@types/gulp-rename": "2.0.1",
|
||||||
"@typescript-eslint/eslint-plugin": "5.59.5",
|
"@typescript-eslint/eslint-plugin": "5.60.0",
|
||||||
"@typescript-eslint/parser": "5.59.5",
|
"@typescript-eslint/parser": "5.60.0",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
"cypress": "12.13.0",
|
"cypress": "12.15.0",
|
||||||
"eslint": "8.40.0",
|
"eslint": "8.43.0",
|
||||||
"start-server-and-test": "2.0.0"
|
"start-server-and-test": "2.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["*"]
|
"@/*": ["*"]
|
||||||
},
|
},
|
||||||
"target": "es2021"
|
"target": "es2022"
|
||||||
},
|
},
|
||||||
"minify": false
|
"minify": false
|
||||||
}
|
}
|
||||||
|
|||||||
17
packages/backend/migration/1685973839966-errorImageUrl.js
Normal file
17
packages/backend/migration/1685973839966-errorImageUrl.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export class ErrorImageUrl1685973839966 {
|
||||||
|
name = 'ErrorImageUrl1685973839966'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "errorImageUrl"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "meta" ADD "serverErrorImageUrl" character varying(1024)`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "meta" ADD "notFoundImageUrl" character varying(1024)`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "meta" ADD "infoImageUrl" character varying(1024)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "infoImageUrl"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "notFoundImageUrl"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "serverErrorImageUrl"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "meta" ADD "errorImageUrl" character varying(1024) DEFAULT 'https://xn--931a.moe/aiart/yubitun.png'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,33 +54,35 @@
|
|||||||
"@aws-sdk/client-s3": "3.321.1",
|
"@aws-sdk/client-s3": "3.321.1",
|
||||||
"@aws-sdk/lib-storage": "3.321.1",
|
"@aws-sdk/lib-storage": "3.321.1",
|
||||||
"@aws-sdk/node-http-handler": "3.321.1",
|
"@aws-sdk/node-http-handler": "3.321.1",
|
||||||
"@bull-board/api": "5.2.0",
|
"@bull-board/api": "5.5.3",
|
||||||
"@bull-board/fastify": "5.2.0",
|
"@bull-board/fastify": "5.5.3",
|
||||||
"@bull-board/ui": "5.2.0",
|
"@bull-board/ui": "5.5.3",
|
||||||
"@discordapp/twemoji": "14.1.2",
|
"@discordapp/twemoji": "14.1.2",
|
||||||
"@fastify/accepts": "4.1.0",
|
"@fastify/accepts": "4.2.0",
|
||||||
"@fastify/cookie": "8.3.0",
|
"@fastify/cookie": "8.3.0",
|
||||||
"@fastify/cors": "8.2.1",
|
"@fastify/cors": "8.3.0",
|
||||||
"@fastify/http-proxy": "9.1.0",
|
"@fastify/express": "^2.3.0",
|
||||||
"@fastify/multipart": "7.6.0",
|
"@fastify/http-proxy": "9.2.1",
|
||||||
"@fastify/static": "6.10.1",
|
"@fastify/multipart": "7.7.0",
|
||||||
|
"@fastify/static": "6.10.2",
|
||||||
"@fastify/view": "7.4.1",
|
"@fastify/view": "7.4.1",
|
||||||
"@nestjs/common": "9.4.2",
|
"@nestjs/common": "10.0.3",
|
||||||
"@nestjs/core": "9.4.2",
|
"@nestjs/core": "10.0.3",
|
||||||
"@nestjs/testing": "9.4.2",
|
"@nestjs/testing": "10.0.3",
|
||||||
"@peertube/http-signature": "1.7.0",
|
"@peertube/http-signature": "1.7.0",
|
||||||
"@sinonjs/fake-timers": "10.0.2",
|
"@sinonjs/fake-timers": "10.3.0",
|
||||||
"@swc/cli": "0.1.62",
|
"@swc/cli": "0.1.62",
|
||||||
"@swc/core": "1.3.59",
|
"@swc/core": "1.3.66",
|
||||||
"accepts": "1.3.8",
|
"accepts": "1.3.8",
|
||||||
"ajv": "8.12.0",
|
"ajv": "8.12.0",
|
||||||
"archiver": "5.3.1",
|
"archiver": "5.3.1",
|
||||||
"autwh": "0.1.0",
|
"autwh": "0.1.0",
|
||||||
"bcryptjs": "2.4.3",
|
"bcryptjs": "2.4.3",
|
||||||
"blurhash": "2.0.5",
|
"blurhash": "2.0.5",
|
||||||
"bullmq": "3.14.1",
|
"body-parser": "^1.20.2",
|
||||||
"cacheable-lookup": "6.1.0",
|
"bullmq": "4.1.0",
|
||||||
"cbor": "8.1.0",
|
"cacheable-lookup": "7.0.0",
|
||||||
|
"cbor": "9.0.0",
|
||||||
"chalk": "5.2.0",
|
"chalk": "5.2.0",
|
||||||
"chalk-template": "0.4.0",
|
"chalk-template": "0.4.0",
|
||||||
"chokidar": "3.5.3",
|
"chokidar": "3.5.3",
|
||||||
@@ -90,37 +92,41 @@
|
|||||||
"date-fns": "2.30.0",
|
"date-fns": "2.30.0",
|
||||||
"deep-email-validator": "0.1.21",
|
"deep-email-validator": "0.1.21",
|
||||||
"escape-regexp": "0.0.1",
|
"escape-regexp": "0.0.1",
|
||||||
"fastify": "4.17.0",
|
"fastify": "4.18.0",
|
||||||
"feed": "4.2.2",
|
"feed": "4.2.2",
|
||||||
"file-type": "18.4.0",
|
"file-type": "18.5.0",
|
||||||
"fluent-ffmpeg": "2.1.2",
|
"fluent-ffmpeg": "2.1.2",
|
||||||
"form-data": "4.0.0",
|
"form-data": "4.0.0",
|
||||||
"got": "12.6.0",
|
"got": "13.0.0",
|
||||||
"happy-dom": "9.19.2",
|
"happy-dom": "9.20.3",
|
||||||
"hpagent": "1.2.0",
|
"hpagent": "1.2.0",
|
||||||
|
"http-link-header": "^1.1.0",
|
||||||
"ioredis": "5.3.2",
|
"ioredis": "5.3.2",
|
||||||
"ip-cidr": "3.1.0",
|
"ip-cidr": "3.1.0",
|
||||||
|
"ipaddr.js": "2.1.0",
|
||||||
"is-svg": "4.3.2",
|
"is-svg": "4.3.2",
|
||||||
"js-yaml": "4.1.0",
|
"js-yaml": "4.1.0",
|
||||||
"jsdom": "21.1.1",
|
"jsdom": "22.1.0",
|
||||||
"json5": "2.2.3",
|
"json5": "2.2.3",
|
||||||
"jsonld": "8.1.1",
|
"jsonld": "8.2.0",
|
||||||
"jsrsasign": "10.8.6",
|
"jsrsasign": "10.8.6",
|
||||||
"meilisearch": "0.32.4",
|
"meilisearch": "0.33.0",
|
||||||
"mfm-js": "0.23.3",
|
"mfm-js": "0.23.3",
|
||||||
"mime-types": "2.1.35",
|
"mime-types": "2.1.35",
|
||||||
"misskey-js": "workspace:*",
|
"misskey-js": "workspace:*",
|
||||||
"ms": "3.0.0-canary.1",
|
"ms": "3.0.0-canary.1",
|
||||||
"nested-property": "4.0.0",
|
"nested-property": "4.0.0",
|
||||||
"node-fetch": "3.3.1",
|
"node-fetch": "3.3.1",
|
||||||
"nodemailer": "6.9.2",
|
"nodemailer": "6.9.3",
|
||||||
"nsfwjs": "2.4.2",
|
"nsfwjs": "2.4.2",
|
||||||
"oauth": "0.10.0",
|
"oauth": "0.10.0",
|
||||||
|
"oauth2orize": "^1.11.1",
|
||||||
|
"oauth2orize-pkce": "^0.1.2",
|
||||||
"os-utils": "0.0.14",
|
"os-utils": "0.0.14",
|
||||||
"otpauth": "9.1.2",
|
"otpauth": "9.1.2",
|
||||||
"parse5": "7.1.2",
|
"parse5": "7.1.2",
|
||||||
"pg": "8.11.0",
|
"pg": "8.11.0",
|
||||||
"private-ip": "3.0.0",
|
"pkce-challenge": "^4.0.1",
|
||||||
"probe-image-size": "7.2.3",
|
"probe-image-size": "7.2.3",
|
||||||
"promise-limit": "2.7.0",
|
"promise-limit": "2.7.0",
|
||||||
"pug": "3.0.2",
|
"pug": "3.0.2",
|
||||||
@@ -129,36 +135,34 @@
|
|||||||
"qrcode": "1.5.3",
|
"qrcode": "1.5.3",
|
||||||
"random-seed": "0.3.0",
|
"random-seed": "0.3.0",
|
||||||
"ratelimiter": "3.4.1",
|
"ratelimiter": "3.4.1",
|
||||||
"re2": "1.18.0",
|
"re2": "1.19.1",
|
||||||
"redis-lock": "0.1.4",
|
"redis-lock": "0.1.4",
|
||||||
"reflect-metadata": "0.1.13",
|
"reflect-metadata": "0.1.13",
|
||||||
"rename": "1.0.4",
|
"rename": "1.0.4",
|
||||||
"rndstr": "1.0.0",
|
|
||||||
"rss-parser": "3.13.0",
|
"rss-parser": "3.13.0",
|
||||||
"rxjs": "7.8.1",
|
"rxjs": "7.8.1",
|
||||||
"s-age": "1.1.2",
|
"s-age": "1.1.2",
|
||||||
"sanitize-html": "2.10.0",
|
"sanitize-html": "2.11.0",
|
||||||
"seedrandom": "3.0.5",
|
"semver": "7.5.3",
|
||||||
"semver": "7.5.1",
|
|
||||||
"sharp": "0.32.1",
|
"sharp": "0.32.1",
|
||||||
"sharp-read-bmp": "github:misskey-dev/sharp-read-bmp",
|
"sharp-read-bmp": "github:misskey-dev/sharp-read-bmp",
|
||||||
"slacc": "0.0.9",
|
"slacc": "0.0.9",
|
||||||
"strict-event-emitter-types": "2.0.0",
|
"strict-event-emitter-types": "2.0.0",
|
||||||
"stringz": "2.1.0",
|
"stringz": "2.1.0",
|
||||||
"summaly": "github:misskey-dev/summaly",
|
"summaly": "github:misskey-dev/summaly",
|
||||||
"systeminformation": "5.17.12",
|
"systeminformation": "5.18.4",
|
||||||
"tinycolor2": "1.6.0",
|
"tinycolor2": "1.6.0",
|
||||||
"tmp": "0.2.1",
|
"tmp": "0.2.1",
|
||||||
"tsc-alias": "1.8.6",
|
"tsc-alias": "1.8.6",
|
||||||
"tsconfig-paths": "4.2.0",
|
"tsconfig-paths": "4.2.0",
|
||||||
"twemoji-parser": "14.0.0",
|
"twemoji-parser": "14.0.0",
|
||||||
"typeorm": "0.3.16",
|
"typeorm": "0.3.17",
|
||||||
"typescript": "5.0.4",
|
"typescript": "5.1.3",
|
||||||
"ulid": "2.3.0",
|
"ulid": "2.3.0",
|
||||||
"unzipper": "0.10.11",
|
"unzipper": "0.10.14",
|
||||||
"uuid": "9.0.0",
|
"uuid": "9.0.0",
|
||||||
"vary": "1.1.2",
|
"vary": "1.1.2",
|
||||||
"web-push": "3.6.1",
|
"web-push": "3.6.3",
|
||||||
"ws": "8.13.0",
|
"ws": "8.13.0",
|
||||||
"xev": "3.0.2"
|
"xev": "3.0.2"
|
||||||
},
|
},
|
||||||
@@ -168,22 +172,26 @@
|
|||||||
"@types/accepts": "1.3.5",
|
"@types/accepts": "1.3.5",
|
||||||
"@types/archiver": "5.3.2",
|
"@types/archiver": "5.3.2",
|
||||||
"@types/bcryptjs": "2.4.2",
|
"@types/bcryptjs": "2.4.2",
|
||||||
|
"@types/body-parser": "^1.19.2",
|
||||||
"@types/cbor": "6.0.0",
|
"@types/cbor": "6.0.0",
|
||||||
"@types/color-convert": "2.0.0",
|
"@types/color-convert": "2.0.0",
|
||||||
"@types/content-disposition": "0.5.5",
|
"@types/content-disposition": "0.5.5",
|
||||||
"@types/escape-regexp": "0.0.1",
|
"@types/escape-regexp": "0.0.1",
|
||||||
"@types/fluent-ffmpeg": "2.1.21",
|
"@types/fluent-ffmpeg": "2.1.21",
|
||||||
"@types/jest": "29.5.1",
|
"@types/http-link-header": "^1.0.3",
|
||||||
|
"@types/jest": "29.5.2",
|
||||||
"@types/js-yaml": "4.0.5",
|
"@types/js-yaml": "4.0.5",
|
||||||
"@types/jsdom": "21.1.1",
|
"@types/jsdom": "21.1.1",
|
||||||
"@types/jsonld": "1.5.8",
|
"@types/jsonld": "1.5.9",
|
||||||
"@types/jsrsasign": "10.5.8",
|
"@types/jsrsasign": "10.5.8",
|
||||||
"@types/mime-types": "2.1.1",
|
"@types/mime-types": "2.1.1",
|
||||||
"@types/node": "20.2.3",
|
"@types/ms": "^0.7.31",
|
||||||
|
"@types/node": "20.3.1",
|
||||||
"@types/node-fetch": "3.0.3",
|
"@types/node-fetch": "3.0.3",
|
||||||
"@types/nodemailer": "6.4.8",
|
"@types/nodemailer": "6.4.8",
|
||||||
"@types/oauth": "0.9.1",
|
"@types/oauth": "0.9.1",
|
||||||
"@types/pg": "8.10.1",
|
"@types/oauth2orize": "^1.11.0",
|
||||||
|
"@types/pg": "8.10.2",
|
||||||
"@types/pug": "2.0.6",
|
"@types/pug": "2.0.6",
|
||||||
"@types/punycode": "2.1.0",
|
"@types/punycode": "2.1.0",
|
||||||
"@types/qrcode": "1.5.0",
|
"@types/qrcode": "1.5.0",
|
||||||
@@ -194,23 +202,25 @@
|
|||||||
"@types/sanitize-html": "2.9.0",
|
"@types/sanitize-html": "2.9.0",
|
||||||
"@types/semver": "7.5.0",
|
"@types/semver": "7.5.0",
|
||||||
"@types/sharp": "0.32.0",
|
"@types/sharp": "0.32.0",
|
||||||
|
"@types/simple-oauth2": "^5.0.4",
|
||||||
"@types/sinonjs__fake-timers": "8.1.2",
|
"@types/sinonjs__fake-timers": "8.1.2",
|
||||||
"@types/tinycolor2": "1.4.3",
|
"@types/tinycolor2": "1.4.3",
|
||||||
"@types/tmp": "0.2.3",
|
"@types/tmp": "0.2.3",
|
||||||
"@types/unzipper": "0.10.6",
|
"@types/unzipper": "0.10.6",
|
||||||
"@types/uuid": "9.0.1",
|
"@types/uuid": "9.0.2",
|
||||||
"@types/vary": "1.1.0",
|
"@types/vary": "1.1.0",
|
||||||
"@types/web-push": "3.3.2",
|
"@types/web-push": "3.3.2",
|
||||||
"@types/websocket": "1.0.5",
|
"@types/websocket": "1.0.5",
|
||||||
"@types/ws": "8.5.4",
|
"@types/ws": "8.5.5",
|
||||||
"@typescript-eslint/eslint-plugin": "5.59.5",
|
"@typescript-eslint/eslint-plugin": "5.60.0",
|
||||||
"@typescript-eslint/parser": "5.59.5",
|
"@typescript-eslint/parser": "5.60.0",
|
||||||
"aws-sdk-client-mock": "2.1.1",
|
"aws-sdk-client-mock": "2.1.1",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
"eslint": "8.40.0",
|
"eslint": "8.43.0",
|
||||||
"eslint-plugin-import": "2.27.5",
|
"eslint-plugin-import": "2.27.5",
|
||||||
"execa": "6.1.0",
|
"execa": "6.1.0",
|
||||||
"jest": "29.5.0",
|
"jest": "29.5.0",
|
||||||
"jest-mock": "29.5.0"
|
"jest-mock": "29.5.0",
|
||||||
|
"simple-oauth2": "^5.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5
packages/backend/src/@types/oauth2orize-pkce.d.ts
vendored
Normal file
5
packages/backend/src/@types/oauth2orize-pkce.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
declare module 'oauth2orize-pkce' {
|
||||||
|
export default {
|
||||||
|
extensions(): any;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -168,6 +168,17 @@ export class CacheService implements OnApplicationShutdown {
|
|||||||
@bindThis
|
@bindThis
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
this.redisForSub.off('message', this.onMessage);
|
this.redisForSub.off('message', this.onMessage);
|
||||||
|
this.userByIdCache.dispose();
|
||||||
|
this.localUserByNativeTokenCache.dispose();
|
||||||
|
this.localUserByIdCache.dispose();
|
||||||
|
this.uriPersonCache.dispose();
|
||||||
|
this.userProfileCache.dispose();
|
||||||
|
this.userMutingsCache.dispose();
|
||||||
|
this.userBlockingCache.dispose();
|
||||||
|
this.userBlockedCache.dispose();
|
||||||
|
this.renoteMutingsCache.dispose();
|
||||||
|
this.userFollowingsCache.dispose();
|
||||||
|
this.userFollowingChannelsCache.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||||
import { DataSource, In, IsNull } from 'typeorm';
|
import { DataSource, In, IsNull } from 'typeorm';
|
||||||
import * as Redis from 'ioredis';
|
import * as Redis from 'ioredis';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
@@ -18,7 +18,7 @@ import type { Serialized } from '@/server/api/stream/types.js';
|
|||||||
const parseEmojiStrRegexp = /^(\w+)(?:@([\w.-]+))?$/;
|
const parseEmojiStrRegexp = /^(\w+)(?:@([\w.-]+))?$/;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CustomEmojiService {
|
export class CustomEmojiService implements OnApplicationShutdown {
|
||||||
private cache: MemoryKVCache<Emoji | null>;
|
private cache: MemoryKVCache<Emoji | null>;
|
||||||
public localEmojisCache: RedisSingleCache<Map<string, Emoji>>;
|
public localEmojisCache: RedisSingleCache<Map<string, Emoji>>;
|
||||||
|
|
||||||
@@ -349,4 +349,14 @@ export class CustomEmojiService {
|
|||||||
this.cache.set(`${emoji.name} ${emoji.host}`, emoji);
|
this.cache.set(`${emoji.name} ${emoji.host}`, emoji);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public dispose(): void {
|
||||||
|
this.cache.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public onApplicationShutdown(signal?: string | undefined): void {
|
||||||
|
this.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import * as fs from 'node:fs';
|
|||||||
import * as stream from 'node:stream';
|
import * as stream from 'node:stream';
|
||||||
import * as util from 'node:util';
|
import * as util from 'node:util';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import IPCIDR from 'ip-cidr';
|
import ipaddr from 'ipaddr.js';
|
||||||
import PrivateIp from 'private-ip';
|
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import got, * as Got from 'got';
|
import got, * as Got from 'got';
|
||||||
import { parse } from 'content-disposition';
|
import { parse } from 'content-disposition';
|
||||||
@@ -140,13 +139,14 @@ export class DownloadService {
|
|||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private isPrivateIp(ip: string): boolean {
|
private isPrivateIp(ip: string): boolean {
|
||||||
|
const parsedIp = ipaddr.parse(ip);
|
||||||
|
|
||||||
for (const net of this.config.allowedPrivateNetworks ?? []) {
|
for (const net of this.config.allowedPrivateNetworks ?? []) {
|
||||||
const cidr = new IPCIDR(net);
|
if (parsedIp.match(ipaddr.parseCIDR(net))) {
|
||||||
if (cidr.contains(ip)) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return PrivateIp(ip) ?? false;
|
return parsedIp.range() !== 'unicast';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||||
import * as Redis from 'ioredis';
|
import * as Redis from 'ioredis';
|
||||||
import type { InstancesRepository } from '@/models/index.js';
|
import type { InstancesRepository } from '@/models/index.js';
|
||||||
import type { Instance } from '@/models/entities/Instance.js';
|
import type { Instance } from '@/models/entities/Instance.js';
|
||||||
@@ -9,7 +9,7 @@ import { UtilityService } from '@/core/UtilityService.js';
|
|||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FederatedInstanceService {
|
export class FederatedInstanceService implements OnApplicationShutdown {
|
||||||
public federatedInstanceCache: RedisKVCache<Instance | null>;
|
public federatedInstanceCache: RedisKVCache<Instance | null>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -77,4 +77,14 @@ export class FederatedInstanceService {
|
|||||||
|
|
||||||
this.federatedInstanceCache.set(result.host, result);
|
this.federatedInstanceCache.set(result.host, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public dispose(): void {
|
||||||
|
this.federatedInstanceCache.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public onApplicationShutdown(signal?: string | undefined): void {
|
||||||
|
this.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import type { Packed } from '@/misc/json-schema.js';
|
|||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { Role } from '@/models';
|
import { Role } from '@/models/index.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GlobalEventService {
|
export class GlobalEventService {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { Config } from '@/config.js';
|
|||||||
import { genAid, parseAid } from '@/misc/id/aid.js';
|
import { genAid, parseAid } from '@/misc/id/aid.js';
|
||||||
import { genMeid, parseMeid } from '@/misc/id/meid.js';
|
import { genMeid, parseMeid } from '@/misc/id/meid.js';
|
||||||
import { genMeidg, parseMeidg } from '@/misc/id/meidg.js';
|
import { genMeidg, parseMeidg } from '@/misc/id/meidg.js';
|
||||||
import { genObjectId } from '@/misc/id/object-id.js';
|
import { genObjectId, parseObjectId } from '@/misc/id/object-id.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { parseUlid } from '@/misc/id/ulid.js';
|
import { parseUlid } from '@/misc/id/ulid.js';
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ export class IdService {
|
|||||||
public parse(id: string): { date: Date; } {
|
public parse(id: string): { date: Date; } {
|
||||||
switch (this.method) {
|
switch (this.method) {
|
||||||
case 'aid': return parseAid(id);
|
case 'aid': return parseAid(id);
|
||||||
case 'objectid':
|
case 'objectid': return parseObjectId(id);
|
||||||
case 'meid': return parseMeid(id);
|
case 'meid': return parseMeid(id);
|
||||||
case 'meidg': return parseMeidg(id);
|
case 'meidg': return parseMeidg(id);
|
||||||
case 'ulid': return parseUlid(id);
|
case 'ulid': return parseUlid(id);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { DI } from '@/di-symbols.js';
|
|||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import Logger from '@/logger.js';
|
import Logger from '@/logger.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import type { KEYWORD } from 'color-convert/conversions';
|
import type { KEYWORD } from 'color-convert/conversions.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LoggerService {
|
export class LoggerService {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||||
import push from 'web-push';
|
import push from 'web-push';
|
||||||
import * as Redis from 'ioredis';
|
import * as Redis from 'ioredis';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import type { Packed } from '@/misc/json-schema';
|
import type { Packed } from '@/misc/json-schema.js';
|
||||||
import { getNoteSummary } from '@/misc/get-note-summary.js';
|
import { getNoteSummary } from '@/misc/get-note-summary.js';
|
||||||
import type { SwSubscription, SwSubscriptionsRepository } from '@/models/index.js';
|
import type { SwSubscription, SwSubscriptionsRepository } from '@/models/index.js';
|
||||||
import { MetaService } from '@/core/MetaService.js';
|
import { MetaService } from '@/core/MetaService.js';
|
||||||
@@ -42,7 +42,7 @@ function truncateBody<T extends keyof PushNotificationsTypes>(type: T, body: Pus
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PushNotificationService {
|
export class PushNotificationService implements OnApplicationShutdown {
|
||||||
private subscriptionsCache: RedisKVCache<SwSubscription[]>;
|
private subscriptionsCache: RedisKVCache<SwSubscription[]>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -115,4 +115,14 @@ export class PushNotificationService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public dispose(): void {
|
||||||
|
this.subscriptionsCache.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public onApplicationShutdown(signal?: string | undefined): void {
|
||||||
|
this.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -400,11 +400,11 @@ export class QueueService {
|
|||||||
this.deliverQueue.once('cleaned', (jobs, status) => {
|
this.deliverQueue.once('cleaned', (jobs, status) => {
|
||||||
//deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
|
//deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
|
||||||
});
|
});
|
||||||
this.deliverQueue.clean(0, Infinity, 'delayed');
|
this.deliverQueue.clean(0, 0, 'delayed');
|
||||||
|
|
||||||
this.inboxQueue.once('cleaned', (jobs, status) => {
|
this.inboxQueue.once('cleaned', (jobs, status) => {
|
||||||
//inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
|
//inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
|
||||||
});
|
});
|
||||||
this.inboxQueue.clean(0, Infinity, 'delayed');
|
this.inboxQueue.clean(0, 0, 'delayed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
|||||||
import { StreamMessages } from '@/server/api/stream/types.js';
|
import { StreamMessages } from '@/server/api/stream/types.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
import type { Packed } from '@/misc/json-schema';
|
import type { Packed } from '@/misc/json-schema.js';
|
||||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||||
|
|
||||||
export type RolePolicies = {
|
export type RolePolicies = {
|
||||||
@@ -435,6 +435,7 @@ export class RoleService implements OnApplicationShutdown {
|
|||||||
@bindThis
|
@bindThis
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
this.redisForSub.off('message', this.onMessage);
|
this.redisForSub.off('message', this.onMessage);
|
||||||
|
this.roleAssignmentByUserIdCache.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||||
import * as Redis from 'ioredis';
|
import * as Redis from 'ioredis';
|
||||||
import type { User } from '@/models/entities/User.js';
|
import type { User } from '@/models/entities/User.js';
|
||||||
import type { UserKeypairsRepository } from '@/models/index.js';
|
import type { UserKeypairsRepository } from '@/models/index.js';
|
||||||
@@ -8,7 +8,7 @@ import { DI } from '@/di-symbols.js';
|
|||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserKeypairService {
|
export class UserKeypairService implements OnApplicationShutdown {
|
||||||
private cache: RedisKVCache<UserKeypair>;
|
private cache: RedisKVCache<UserKeypair>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -31,4 +31,14 @@ export class UserKeypairService {
|
|||||||
public async getUserKeypair(userId: User['id']): Promise<UserKeypair> {
|
public async getUserKeypair(userId: User['id']): Promise<UserKeypair> {
|
||||||
return await this.cache.fetch(userId);
|
return await this.cache.fetch(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public dispose(): void {
|
||||||
|
this.cache.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public onApplicationShutdown(signal?: string | undefined): void {
|
||||||
|
this.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||||
import escapeRegexp from 'escape-regexp';
|
import escapeRegexp from 'escape-regexp';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js';
|
import type { NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js';
|
||||||
@@ -30,7 +30,7 @@ export type UriParseResult = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApDbResolverService {
|
export class ApDbResolverService implements OnApplicationShutdown {
|
||||||
private publicKeyCache: MemoryKVCache<UserPublickey | null>;
|
private publicKeyCache: MemoryKVCache<UserPublickey | null>;
|
||||||
private publicKeyByUserIdCache: MemoryKVCache<UserPublickey | null>;
|
private publicKeyByUserIdCache: MemoryKVCache<UserPublickey | null>;
|
||||||
|
|
||||||
@@ -162,4 +162,15 @@ export class ApDbResolverService {
|
|||||||
key,
|
key,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public dispose(): void {
|
||||||
|
this.publicKeyCache.dispose();
|
||||||
|
this.publicKeyByUserIdCache.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public onApplicationShutdown(signal?: string | undefined): void {
|
||||||
|
this.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { In, Not } from 'typeorm';
|
import { In, Not } from 'typeorm';
|
||||||
import * as Redis from 'ioredis';
|
import * as Redis from 'ioredis';
|
||||||
import Ajv from 'ajv';
|
import _Ajv from 'ajv';
|
||||||
import { ModuleRef } from '@nestjs/core';
|
import { ModuleRef } from '@nestjs/core';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
@@ -31,6 +31,7 @@ type IsMeAndIsUserDetailed<ExpectsMe extends boolean | null, Detailed extends bo
|
|||||||
Packed<'UserDetailed'> :
|
Packed<'UserDetailed'> :
|
||||||
Packed<'UserLite'>;
|
Packed<'UserLite'>;
|
||||||
|
|
||||||
|
const Ajv = _Ajv.default;
|
||||||
const ajv = new Ajv();
|
const ajv = new Ajv();
|
||||||
|
|
||||||
function isLocalUser(user: User): user is LocalUser;
|
function isLocalUser(user: User): user is LocalUser;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { default as convertColor } from 'color-convert';
|
|||||||
import { format as dateFormat } from 'date-fns';
|
import { format as dateFormat } from 'date-fns';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { envOption } from './env.js';
|
import { envOption } from './env.js';
|
||||||
import type { KEYWORD } from 'color-convert/conversions';
|
import type { KEYWORD } from 'color-convert/conversions.js';
|
||||||
|
|
||||||
type Context = {
|
type Context = {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -83,6 +83,16 @@ export class RedisKVCache<T> {
|
|||||||
|
|
||||||
// TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする
|
// TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public gc() {
|
||||||
|
this.memoryCache.gc();
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public dispose() {
|
||||||
|
this.memoryCache.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RedisSingleCache<T> {
|
export class RedisSingleCache<T> {
|
||||||
@@ -174,10 +184,15 @@ export class RedisSingleCache<T> {
|
|||||||
export class MemoryKVCache<T> {
|
export class MemoryKVCache<T> {
|
||||||
public cache: Map<string, { date: number; value: T; }>;
|
public cache: Map<string, { date: number; value: T; }>;
|
||||||
private lifetime: number;
|
private lifetime: number;
|
||||||
|
private gcIntervalHandle: NodeJS.Timer;
|
||||||
|
|
||||||
constructor(lifetime: MemoryKVCache<never>['lifetime']) {
|
constructor(lifetime: MemoryKVCache<never>['lifetime']) {
|
||||||
this.cache = new Map();
|
this.cache = new Map();
|
||||||
this.lifetime = lifetime;
|
this.lifetime = lifetime;
|
||||||
|
|
||||||
|
this.gcIntervalHandle = setInterval(() => {
|
||||||
|
this.gc();
|
||||||
|
}, 1000 * 60 * 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
@@ -200,7 +215,7 @@ export class MemoryKVCache<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public delete(key: string) {
|
public delete(key: string): void {
|
||||||
this.cache.delete(key);
|
this.cache.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +270,21 @@ export class MemoryKVCache<T> {
|
|||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public gc(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, { date }] of this.cache.entries()) {
|
||||||
|
if ((now - date) > this.lifetime) {
|
||||||
|
this.cache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public dispose(): void {
|
||||||
|
clearInterval(this.gcIntervalHandle);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MemorySingleCache<T> {
|
export class MemorySingleCache<T> {
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||||
|
|
||||||
export default () => secureRndstr(16, true);
|
export default () => secureRndstr(16);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import IPCIDR from 'ip-cidr';
|
import IPCIDR from 'ip-cidr';
|
||||||
|
|
||||||
export function getIpHash(ip: string) {
|
export function getIpHash(ip: string): string {
|
||||||
try {
|
try {
|
||||||
// because a single person may control many IPv6 addresses,
|
// because a single person may control many IPv6 addresses,
|
||||||
// only a /64 subnet prefix of any IP will be taken into account.
|
// only a /64 subnet prefix of any IP will be taken into account.
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import * as crypto from 'node:crypto';
|
import * as crypto from 'node:crypto';
|
||||||
|
|
||||||
const L_CHARS = '0123456789abcdefghijklmnopqrstuvwxyz';
|
export const L_CHARS = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||||
const LU_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
const LU_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
|
|
||||||
export function secureRndstr(length = 32, useLU = true): string {
|
export function secureRndstr(length = 32, { chars = LU_CHARS } = {}): string {
|
||||||
const chars = useLU ? LU_CHARS : L_CHARS;
|
|
||||||
const chars_len = chars.length;
|
const chars_len = chars.length;
|
||||||
|
|
||||||
let str = '';
|
let str = '';
|
||||||
|
|||||||
@@ -101,13 +101,25 @@ export class Meta {
|
|||||||
length: 1024,
|
length: 1024,
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
public errorImageUrl: string | null;
|
public iconUrl: string | null;
|
||||||
|
|
||||||
@Column('varchar', {
|
@Column('varchar', {
|
||||||
length: 1024,
|
length: 1024,
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
public iconUrl: string | null;
|
public serverErrorImageUrl: string | null;
|
||||||
|
|
||||||
|
@Column('varchar', {
|
||||||
|
length: 1024,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
public notFoundImageUrl: string | null;
|
||||||
|
|
||||||
|
@Column('varchar', {
|
||||||
|
length: 1024,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
public infoImageUrl: string | null;
|
||||||
|
|
||||||
@Column('boolean', {
|
@Column('boolean', {
|
||||||
default: true,
|
default: true,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, Inject } from '@nestjs/common';
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
import Ajv from 'ajv';
|
import _Ajv from 'ajv';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
import Logger from '@/logger.js';
|
import Logger from '@/logger.js';
|
||||||
@@ -10,6 +10,8 @@ import { QueueLoggerService } from '../QueueLoggerService.js';
|
|||||||
import { DBAntennaImportJobData } from '../types.js';
|
import { DBAntennaImportJobData } from '../types.js';
|
||||||
import type * as Bull from 'bullmq';
|
import type * as Bull from 'bullmq';
|
||||||
|
|
||||||
|
const Ajv = _Ajv.default;
|
||||||
|
|
||||||
const validate = new Ajv().compile({
|
const validate = new Ajv().compile({
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { UserListChannelService } from './api/stream/channels/user-list.js';
|
|||||||
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
|
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
|
||||||
import { ClientLoggerService } from './web/ClientLoggerService.js';
|
import { ClientLoggerService } from './web/ClientLoggerService.js';
|
||||||
import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
|
import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
|
||||||
|
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -78,6 +79,7 @@ import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.
|
|||||||
ServerStatsChannelService,
|
ServerStatsChannelService,
|
||||||
UserListChannelService,
|
UserListChannelService,
|
||||||
OpenApiServerService,
|
OpenApiServerService,
|
||||||
|
OAuth2ProviderService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
ServerService,
|
ServerService,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { WellKnownServerService } from './WellKnownServerService.js';
|
|||||||
import { FileServerService } from './FileServerService.js';
|
import { FileServerService } from './FileServerService.js';
|
||||||
import { ClientServerService } from './web/ClientServerService.js';
|
import { ClientServerService } from './web/ClientServerService.js';
|
||||||
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
|
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
|
||||||
|
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
|
||||||
|
|
||||||
const _dirname = fileURLToPath(new URL('.', import.meta.url));
|
const _dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
|
||||||
@@ -56,12 +57,13 @@ export class ServerService implements OnApplicationShutdown {
|
|||||||
private clientServerService: ClientServerService,
|
private clientServerService: ClientServerService,
|
||||||
private globalEventService: GlobalEventService,
|
private globalEventService: GlobalEventService,
|
||||||
private loggerService: LoggerService,
|
private loggerService: LoggerService,
|
||||||
|
private oauth2ProviderService: OAuth2ProviderService,
|
||||||
) {
|
) {
|
||||||
this.logger = this.loggerService.getLogger('server', 'gray', false);
|
this.logger = this.loggerService.getLogger('server', 'gray', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async launch() {
|
public async launch(): Promise<void> {
|
||||||
const fastify = Fastify({
|
const fastify = Fastify({
|
||||||
trustProxy: true,
|
trustProxy: true,
|
||||||
logger: !['production', 'test'].includes(process.env.NODE_ENV ?? ''),
|
logger: !['production', 'test'].includes(process.env.NODE_ENV ?? ''),
|
||||||
@@ -90,6 +92,7 @@ export class ServerService implements OnApplicationShutdown {
|
|||||||
fastify.register(this.activityPubServerService.createServer);
|
fastify.register(this.activityPubServerService.createServer);
|
||||||
fastify.register(this.nodeinfoServerService.createServer);
|
fastify.register(this.nodeinfoServerService.createServer);
|
||||||
fastify.register(this.wellKnownServerService.createServer);
|
fastify.register(this.wellKnownServerService.createServer);
|
||||||
|
fastify.register(this.oauth2ProviderService.createServer);
|
||||||
|
|
||||||
fastify.get<{ Params: { path: string }; Querystring: { static?: any; badge?: any; }; }>('/emoji/:path(.*)', async (request, reply) => {
|
fastify.get<{ Params: { path: string }; Querystring: { static?: any; badge?: any; }; }>('/emoji/:path(.*)', async (request, reply) => {
|
||||||
const path = request.params.path;
|
const path = request.params.path;
|
||||||
|
|||||||
@@ -53,44 +53,72 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||||||
}, 1000 * 60 * 60);
|
}, 1000 * 60 * 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#sendApiError(reply: FastifyReply, err: ApiError): void {
|
||||||
|
let statusCode = err.httpStatusCode;
|
||||||
|
if (err.httpStatusCode === 401) {
|
||||||
|
reply.header('WWW-Authenticate', 'Bearer realm="Misskey"');
|
||||||
|
} else if (err.kind === 'client') {
|
||||||
|
reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="invalid_request", error_description="${err.message}"`);
|
||||||
|
statusCode = statusCode ?? 400;
|
||||||
|
} else if (err.kind === 'permission') {
|
||||||
|
// (ROLE_PERMISSION_DENIEDは関係ない)
|
||||||
|
if (err.code === 'PERMISSION_DENIED') {
|
||||||
|
reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="insufficient_scope", error_description="${err.message}"`);
|
||||||
|
}
|
||||||
|
statusCode = statusCode ?? 403;
|
||||||
|
} else if (!statusCode) {
|
||||||
|
statusCode = 500;
|
||||||
|
}
|
||||||
|
this.send(reply, statusCode, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
#sendAuthenticationError(reply: FastifyReply, err: unknown): void {
|
||||||
|
if (err instanceof AuthenticationError) {
|
||||||
|
const message = 'Authentication failed. Please ensure your token is correct.';
|
||||||
|
reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="invalid_token", error_description="${message}"`);
|
||||||
|
this.send(reply, 401, new ApiError({
|
||||||
|
message: 'Authentication failed. Please ensure your token is correct.',
|
||||||
|
code: 'AUTHENTICATION_FAILED',
|
||||||
|
id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14',
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
this.send(reply, 500, new ApiError());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public handleRequest(
|
public handleRequest(
|
||||||
endpoint: IEndpoint & { exec: any },
|
endpoint: IEndpoint & { exec: any },
|
||||||
request: FastifyRequest<{ Body: Record<string, unknown> | undefined, Querystring: Record<string, unknown> }>,
|
request: FastifyRequest<{ Body: Record<string, unknown> | undefined, Querystring: Record<string, unknown> }>,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply,
|
||||||
) {
|
): void {
|
||||||
const body = request.method === 'GET'
|
const body = request.method === 'GET'
|
||||||
? request.query
|
? request.query
|
||||||
: request.body;
|
: request.body;
|
||||||
|
|
||||||
const token = body?.['i'];
|
// https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1 (case sensitive)
|
||||||
|
const token = request.headers.authorization?.startsWith('Bearer ')
|
||||||
|
? request.headers.authorization.slice(7)
|
||||||
|
: body?.['i'];
|
||||||
if (token != null && typeof token !== 'string') {
|
if (token != null && typeof token !== 'string') {
|
||||||
reply.code(400);
|
reply.code(400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.authenticateService.authenticate(token).then(([user, app]) => {
|
this.authenticateService.authenticate(token).then(([user, app]) => {
|
||||||
this.call(endpoint, user, app, body, null, request).then((res) => {
|
this.call(endpoint, user, app, body, null, request).then((res) => {
|
||||||
if (request.method === 'GET' && endpoint.meta.cacheSec && !body?.['i'] && !user) {
|
if (request.method === 'GET' && endpoint.meta.cacheSec && !token && !user) {
|
||||||
reply.header('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`);
|
reply.header('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`);
|
||||||
}
|
}
|
||||||
this.send(reply, res);
|
this.send(reply, res);
|
||||||
}).catch((err: ApiError) => {
|
}).catch((err: ApiError) => {
|
||||||
this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : err.kind === 'permission' ? 403 : 500, err);
|
this.#sendApiError(reply, err);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
this.logIp(request, user);
|
this.logIp(request, user);
|
||||||
}
|
}
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
if (err instanceof AuthenticationError) {
|
this.#sendAuthenticationError(reply, err);
|
||||||
this.send(reply, 403, new ApiError({
|
|
||||||
message: 'Authentication failed. Please ensure your token is correct.',
|
|
||||||
code: 'AUTHENTICATION_FAILED',
|
|
||||||
id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14',
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
this.send(reply, 500, new ApiError());
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +127,7 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||||||
endpoint: IEndpoint & { exec: any },
|
endpoint: IEndpoint & { exec: any },
|
||||||
request: FastifyRequest<{ Body: Record<string, unknown>, Querystring: Record<string, unknown> }>,
|
request: FastifyRequest<{ Body: Record<string, unknown>, Querystring: Record<string, unknown> }>,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply,
|
||||||
) {
|
): Promise<void> {
|
||||||
const multipartData = await request.file().catch(() => {
|
const multipartData = await request.file().catch(() => {
|
||||||
/* Fastify throws if the remote didn't send multipart data. Return 400 below. */
|
/* Fastify throws if the remote didn't send multipart data. Return 400 below. */
|
||||||
});
|
});
|
||||||
@@ -117,7 +145,10 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||||||
fields[k] = typeof v === 'object' && 'value' in v ? v.value : undefined;
|
fields[k] = typeof v === 'object' && 'value' in v ? v.value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = fields['i'];
|
// https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1 (case sensitive)
|
||||||
|
const token = request.headers.authorization?.startsWith('Bearer ')
|
||||||
|
? request.headers.authorization.slice(7)
|
||||||
|
: fields['i'];
|
||||||
if (token != null && typeof token !== 'string') {
|
if (token != null && typeof token !== 'string') {
|
||||||
reply.code(400);
|
reply.code(400);
|
||||||
return;
|
return;
|
||||||
@@ -129,22 +160,14 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||||||
}, request).then((res) => {
|
}, request).then((res) => {
|
||||||
this.send(reply, res);
|
this.send(reply, res);
|
||||||
}).catch((err: ApiError) => {
|
}).catch((err: ApiError) => {
|
||||||
this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : err.kind === 'permission' ? 403 : 500, err);
|
this.#sendApiError(reply, err);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
this.logIp(request, user);
|
this.logIp(request, user);
|
||||||
}
|
}
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
if (err instanceof AuthenticationError) {
|
this.#sendAuthenticationError(reply, err);
|
||||||
this.send(reply, 403, new ApiError({
|
|
||||||
message: 'Authentication failed. Please ensure your token is correct.',
|
|
||||||
code: 'AUTHENTICATION_FAILED',
|
|
||||||
id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14',
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
this.send(reply, 500, new ApiError());
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +236,7 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (ep.meta.limit) {
|
if (ep.meta.limit) {
|
||||||
// koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app.
|
// koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app.
|
||||||
let limitActor: string;
|
let limitActor: string;
|
||||||
if (user) {
|
if (user) {
|
||||||
limitActor = user.id;
|
limitActor = user.id;
|
||||||
@@ -255,8 +278,8 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||||||
throw new ApiError({
|
throw new ApiError({
|
||||||
message: 'Your account has been suspended.',
|
message: 'Your account has been suspended.',
|
||||||
code: 'YOUR_ACCOUNT_SUSPENDED',
|
code: 'YOUR_ACCOUNT_SUSPENDED',
|
||||||
|
kind: 'permission',
|
||||||
id: 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370',
|
id: 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370',
|
||||||
httpStatusCode: 403,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,8 +289,8 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||||||
throw new ApiError({
|
throw new ApiError({
|
||||||
message: 'You have moved your account.',
|
message: 'You have moved your account.',
|
||||||
code: 'YOUR_ACCOUNT_MOVED',
|
code: 'YOUR_ACCOUNT_MOVED',
|
||||||
|
kind: 'permission',
|
||||||
id: '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31',
|
id: '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31',
|
||||||
httpStatusCode: 403,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -278,6 +301,7 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||||||
throw new ApiError({
|
throw new ApiError({
|
||||||
message: 'You are not assigned to a moderator role.',
|
message: 'You are not assigned to a moderator role.',
|
||||||
code: 'ROLE_PERMISSION_DENIED',
|
code: 'ROLE_PERMISSION_DENIED',
|
||||||
|
kind: 'permission',
|
||||||
id: 'd33d5333-db36-423d-a8f9-1a2b9549da41',
|
id: 'd33d5333-db36-423d-a8f9-1a2b9549da41',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -285,6 +309,7 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||||||
throw new ApiError({
|
throw new ApiError({
|
||||||
message: 'You are not assigned to an administrator role.',
|
message: 'You are not assigned to an administrator role.',
|
||||||
code: 'ROLE_PERMISSION_DENIED',
|
code: 'ROLE_PERMISSION_DENIED',
|
||||||
|
kind: 'permission',
|
||||||
id: 'c3d38592-54c0-429d-be96-5636b0431a61',
|
id: 'c3d38592-54c0-429d-be96-5636b0431a61',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -296,6 +321,7 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||||||
throw new ApiError({
|
throw new ApiError({
|
||||||
message: 'You are not assigned to a required role.',
|
message: 'You are not assigned to a required role.',
|
||||||
code: 'ROLE_PERMISSION_DENIED',
|
code: 'ROLE_PERMISSION_DENIED',
|
||||||
|
kind: 'permission',
|
||||||
id: '7f86f06f-7e15-4057-8561-f4b6d4ac755a',
|
id: '7f86f06f-7e15-4057-8561-f4b6d4ac755a',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -305,6 +331,7 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||||||
throw new ApiError({
|
throw new ApiError({
|
||||||
message: 'Your app does not have the necessary permissions to use this endpoint.',
|
message: 'Your app does not have the necessary permissions to use this endpoint.',
|
||||||
code: 'PERMISSION_DENIED',
|
code: 'PERMISSION_DENIED',
|
||||||
|
kind: 'permission',
|
||||||
id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838',
|
id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -317,7 +344,7 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||||||
try {
|
try {
|
||||||
data[k] = JSON.parse(data[k]);
|
data[k] = JSON.parse(data[k]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new ApiError({
|
throw new ApiError({
|
||||||
message: 'Invalid param.',
|
message: 'Invalid param.',
|
||||||
code: 'INVALID_PARAM',
|
code: 'INVALID_PARAM',
|
||||||
id: '0b5f1631-7c1a-41a6-b399-cce335f34d85',
|
id: '0b5f1631-7c1a-41a6-b399-cce335f34d85',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { AccessTokensRepository, AppsRepository, UsersRepository } from '@/models/index.js';
|
import type { AccessTokensRepository, AppsRepository, UsersRepository } from '@/models/index.js';
|
||||||
import type { LocalUser } from '@/models/entities/User.js';
|
import type { LocalUser } from '@/models/entities/User.js';
|
||||||
@@ -17,7 +17,7 @@ export class AuthenticationError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthenticateService {
|
export class AuthenticateService implements OnApplicationShutdown {
|
||||||
private appCache: MemoryKVCache<App>;
|
private appCache: MemoryKVCache<App>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -85,4 +85,14 @@ export class AuthenticateService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public dispose(): void {
|
||||||
|
this.appCache.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public onApplicationShutdown(signal?: string | undefined): void {
|
||||||
|
this.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -333,7 +333,6 @@ import * as ep___users_reportAbuse from './endpoints/users/report-abuse.js';
|
|||||||
import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by-username-and-host.js';
|
import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by-username-and-host.js';
|
||||||
import * as ep___users_search from './endpoints/users/search.js';
|
import * as ep___users_search from './endpoints/users/search.js';
|
||||||
import * as ep___users_show from './endpoints/users/show.js';
|
import * as ep___users_show from './endpoints/users/show.js';
|
||||||
import * as ep___users_stats from './endpoints/users/stats.js';
|
|
||||||
import * as ep___users_achievements from './endpoints/users/achievements.js';
|
import * as ep___users_achievements from './endpoints/users/achievements.js';
|
||||||
import * as ep___users_updateMemo from './endpoints/users/update-memo.js';
|
import * as ep___users_updateMemo from './endpoints/users/update-memo.js';
|
||||||
import * as ep___fetchRss from './endpoints/fetch-rss.js';
|
import * as ep___fetchRss from './endpoints/fetch-rss.js';
|
||||||
@@ -674,7 +673,6 @@ const $users_reportAbuse: Provider = { provide: 'ep:users/report-abuse', useClas
|
|||||||
const $users_searchByUsernameAndHost: Provider = { provide: 'ep:users/search-by-username-and-host', useClass: ep___users_searchByUsernameAndHost.default };
|
const $users_searchByUsernameAndHost: Provider = { provide: 'ep:users/search-by-username-and-host', useClass: ep___users_searchByUsernameAndHost.default };
|
||||||
const $users_search: Provider = { provide: 'ep:users/search', useClass: ep___users_search.default };
|
const $users_search: Provider = { provide: 'ep:users/search', useClass: ep___users_search.default };
|
||||||
const $users_show: Provider = { provide: 'ep:users/show', useClass: ep___users_show.default };
|
const $users_show: Provider = { provide: 'ep:users/show', useClass: ep___users_show.default };
|
||||||
const $users_stats: Provider = { provide: 'ep:users/stats', useClass: ep___users_stats.default };
|
|
||||||
const $users_achievements: Provider = { provide: 'ep:users/achievements', useClass: ep___users_achievements.default };
|
const $users_achievements: Provider = { provide: 'ep:users/achievements', useClass: ep___users_achievements.default };
|
||||||
const $users_updateMemo: Provider = { provide: 'ep:users/update-memo', useClass: ep___users_updateMemo.default };
|
const $users_updateMemo: Provider = { provide: 'ep:users/update-memo', useClass: ep___users_updateMemo.default };
|
||||||
const $fetchRss: Provider = { provide: 'ep:fetch-rss', useClass: ep___fetchRss.default };
|
const $fetchRss: Provider = { provide: 'ep:fetch-rss', useClass: ep___fetchRss.default };
|
||||||
@@ -1019,7 +1017,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
|||||||
$users_searchByUsernameAndHost,
|
$users_searchByUsernameAndHost,
|
||||||
$users_search,
|
$users_search,
|
||||||
$users_show,
|
$users_show,
|
||||||
$users_stats,
|
|
||||||
$users_achievements,
|
$users_achievements,
|
||||||
$users_updateMemo,
|
$users_updateMemo,
|
||||||
$fetchRss,
|
$fetchRss,
|
||||||
@@ -1356,7 +1353,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
|||||||
$users_searchByUsernameAndHost,
|
$users_searchByUsernameAndHost,
|
||||||
$users_search,
|
$users_search,
|
||||||
$users_show,
|
$users_show,
|
||||||
$users_stats,
|
|
||||||
$users_achievements,
|
$users_achievements,
|
||||||
$users_updateMemo,
|
$users_updateMemo,
|
||||||
$fetchRss,
|
$fetchRss,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import rndstr from 'rndstr';
|
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { IsNull } from 'typeorm';
|
import { IsNull } from 'typeorm';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
@@ -16,6 +15,7 @@ import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
|
|||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { SigninService } from './SigninService.js';
|
import { SigninService } from './SigninService.js';
|
||||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SignupApiService {
|
export class SignupApiService {
|
||||||
@@ -142,7 +142,7 @@ export class SignupApiService {
|
|||||||
throw new FastifyReplyError(400, 'DENIED_USERNAME');
|
throw new FastifyReplyError(400, 'DENIED_USERNAME');
|
||||||
}
|
}
|
||||||
|
|
||||||
const code = rndstr('a-z0-9', 16);
|
const code = secureRndstr(16, { chars: L_CHARS });
|
||||||
|
|
||||||
// Generate hash of password
|
// Generate hash of password
|
||||||
const salt = await bcrypt.genSalt(8);
|
const salt = await bcrypt.genSalt(8);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { GlobalEventService } from '@/core/GlobalEventService.js';
|
|||||||
import { NotificationService } from '@/core/NotificationService.js';
|
import { NotificationService } from '@/core/NotificationService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { CacheService } from '@/core/CacheService.js';
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
import { LocalUser } from '@/models/entities/User';
|
import { LocalUser } from '@/models/entities/User.js';
|
||||||
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
|
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
|
||||||
import MainStreamConnection from './stream/index.js';
|
import MainStreamConnection from './stream/index.js';
|
||||||
import { ChannelsService } from './stream/ChannelsService.js';
|
import { ChannelsService } from './stream/ChannelsService.js';
|
||||||
@@ -19,6 +19,8 @@ import type * as http from 'node:http';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class StreamingApiServerService {
|
export class StreamingApiServerService {
|
||||||
#wss: WebSocket.WebSocketServer;
|
#wss: WebSocket.WebSocketServer;
|
||||||
|
#connections = new Map<WebSocket.WebSocket, number>();
|
||||||
|
#cleanConnectionsIntervalId: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.config)
|
@Inject(DI.config)
|
||||||
@@ -56,11 +58,21 @@ export class StreamingApiServerService {
|
|||||||
let user: LocalUser | null = null;
|
let user: LocalUser | null = null;
|
||||||
let app: AccessToken | null = null;
|
let app: AccessToken | null = null;
|
||||||
|
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1
|
||||||
|
// Note that the standard WHATWG WebSocket API does not support setting any headers,
|
||||||
|
// but non-browser apps may still be able to set it.
|
||||||
|
const token = request.headers.authorization?.startsWith('Bearer ')
|
||||||
|
? request.headers.authorization.slice(7)
|
||||||
|
: q.get('i');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
[user, app] = await this.authenticateService.authenticate(q.get('i'));
|
[user, app] = await this.authenticateService.authenticate(token);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof AuthenticationError) {
|
if (e instanceof AuthenticationError) {
|
||||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
socket.write([
|
||||||
|
'HTTP/1.1 401 Unauthorized',
|
||||||
|
'WWW-Authenticate: Bearer realm="Misskey", error="invalid_token", error_description="Failed to authenticate"',
|
||||||
|
].join('\r\n') + '\r\n\r\n');
|
||||||
} else {
|
} else {
|
||||||
socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
|
socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
|
||||||
}
|
}
|
||||||
@@ -109,7 +121,9 @@ export class StreamingApiServerService {
|
|||||||
|
|
||||||
await stream.listen(ev, connection);
|
await stream.listen(ev, connection);
|
||||||
|
|
||||||
const intervalId = user ? setInterval(() => {
|
this.#connections.set(connection, Date.now());
|
||||||
|
|
||||||
|
const userUpdateIntervalId = user ? setInterval(() => {
|
||||||
this.usersRepository.update(user.id, {
|
this.usersRepository.update(user.id, {
|
||||||
lastActiveDate: new Date(),
|
lastActiveDate: new Date(),
|
||||||
});
|
});
|
||||||
@@ -124,19 +138,35 @@ export class StreamingApiServerService {
|
|||||||
ev.removeAllListeners();
|
ev.removeAllListeners();
|
||||||
stream.dispose();
|
stream.dispose();
|
||||||
this.redisForSub.off('message', onRedisMessage);
|
this.redisForSub.off('message', onRedisMessage);
|
||||||
if (intervalId) clearInterval(intervalId);
|
this.#connections.delete(connection);
|
||||||
|
if (userUpdateIntervalId) clearInterval(userUpdateIntervalId);
|
||||||
});
|
});
|
||||||
|
|
||||||
connection.on('message', async (data) => {
|
connection.on('pong', () => {
|
||||||
if (data.toString() === 'ping') {
|
this.#connections.set(connection, Date.now());
|
||||||
connection.send('pong');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 一定期間通信が無いコネクションは実際には切断されている可能性があるため定期的にterminateする
|
||||||
|
this.#cleanConnectionsIntervalId = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [connection, lastActive] of this.#connections.entries()) {
|
||||||
|
if (now - lastActive > 1000 * 60 * 2) {
|
||||||
|
connection.terminate();
|
||||||
|
this.#connections.delete(connection);
|
||||||
|
} else {
|
||||||
|
connection.ping();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000 * 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public detach(): Promise<void> {
|
public detach(): Promise<void> {
|
||||||
|
if (this.#cleanConnectionsIntervalId) {
|
||||||
|
clearInterval(this.#cleanConnectionsIntervalId);
|
||||||
|
this.#cleanConnectionsIntervalId = null;
|
||||||
|
}
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
this.#wss.close(() => resolve());
|
this.#wss.close(() => resolve());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import Ajv from 'ajv';
|
import _Ajv from 'ajv';
|
||||||
import type { Schema, SchemaType } from '@/misc/json-schema.js';
|
import type { Schema, SchemaType } from '@/misc/json-schema.js';
|
||||||
import type { LocalUser } from '@/models/entities/User.js';
|
import type { LocalUser } from '@/models/entities/User.js';
|
||||||
import type { AccessToken } from '@/models/entities/AccessToken.js';
|
import type { AccessToken } from '@/models/entities/AccessToken.js';
|
||||||
import { ApiError } from './error.js';
|
import { ApiError } from './error.js';
|
||||||
import type { IEndpointMeta } from './endpoints.js';
|
import type { IEndpointMeta } from './endpoints.js';
|
||||||
|
|
||||||
|
const Ajv = _Ajv.default;
|
||||||
|
|
||||||
const ajv = new Ajv({
|
const ajv = new Ajv({
|
||||||
useDefaults: true,
|
useDefaults: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -333,7 +333,6 @@ import * as ep___users_reportAbuse from './endpoints/users/report-abuse.js';
|
|||||||
import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by-username-and-host.js';
|
import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by-username-and-host.js';
|
||||||
import * as ep___users_search from './endpoints/users/search.js';
|
import * as ep___users_search from './endpoints/users/search.js';
|
||||||
import * as ep___users_show from './endpoints/users/show.js';
|
import * as ep___users_show from './endpoints/users/show.js';
|
||||||
import * as ep___users_stats from './endpoints/users/stats.js';
|
|
||||||
import * as ep___users_achievements from './endpoints/users/achievements.js';
|
import * as ep___users_achievements from './endpoints/users/achievements.js';
|
||||||
import * as ep___users_updateMemo from './endpoints/users/update-memo.js';
|
import * as ep___users_updateMemo from './endpoints/users/update-memo.js';
|
||||||
import * as ep___fetchRss from './endpoints/fetch-rss.js';
|
import * as ep___fetchRss from './endpoints/fetch-rss.js';
|
||||||
@@ -672,7 +671,6 @@ const eps = [
|
|||||||
['users/search-by-username-and-host', ep___users_searchByUsernameAndHost],
|
['users/search-by-username-and-host', ep___users_searchByUsernameAndHost],
|
||||||
['users/search', ep___users_search],
|
['users/search', ep___users_search],
|
||||||
['users/show', ep___users_show],
|
['users/show', ep___users_show],
|
||||||
['users/stats', ep___users_stats],
|
|
||||||
['users/achievements', ep___users_achievements],
|
['users/achievements', ep___users_achievements],
|
||||||
['users/update-memo', ep___users_updateMemo],
|
['users/update-memo', ep___users_updateMemo],
|
||||||
['fetch-rss', ep___fetchRss],
|
['fetch-rss', ep___fetchRss],
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import rndstr from 'rndstr';
|
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import type { DriveFilesRepository } from '@/models/index.js';
|
import type { DriveFilesRepository } from '@/models/index.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
|
|||||||
@@ -61,10 +61,17 @@ export const meta = {
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
optional: false, nullable: true,
|
optional: false, nullable: true,
|
||||||
},
|
},
|
||||||
errorImageUrl: {
|
serverErrorImageUrl: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: true,
|
||||||
|
},
|
||||||
|
infoImageUrl: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: true,
|
||||||
|
},
|
||||||
|
notFoundImageUrl: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
optional: false, nullable: true,
|
optional: false, nullable: true,
|
||||||
default: 'https://xn--931a.moe/aiart/yubitun.png',
|
|
||||||
},
|
},
|
||||||
iconUrl: {
|
iconUrl: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@@ -305,7 +312,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
themeColor: instance.themeColor,
|
themeColor: instance.themeColor,
|
||||||
mascotImageUrl: instance.mascotImageUrl,
|
mascotImageUrl: instance.mascotImageUrl,
|
||||||
bannerUrl: instance.bannerUrl,
|
bannerUrl: instance.bannerUrl,
|
||||||
errorImageUrl: instance.errorImageUrl,
|
serverErrorImageUrl: instance.serverErrorImageUrl,
|
||||||
|
notFoundImageUrl: instance.notFoundImageUrl,
|
||||||
|
infoImageUrl: instance.infoImageUrl,
|
||||||
iconUrl: instance.iconUrl,
|
iconUrl: instance.iconUrl,
|
||||||
backgroundImageUrl: instance.backgroundImageUrl,
|
backgroundImageUrl: instance.backgroundImageUrl,
|
||||||
logoImageUrl: instance.logoImageUrl,
|
logoImageUrl: instance.logoImageUrl,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import rndstr from 'rndstr';
|
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import type { UsersRepository, UserProfilesRepository } from '@/models/index.js';
|
import type { UsersRepository, UserProfilesRepository } from '@/models/index.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['admin'],
|
tags: ['admin'],
|
||||||
@@ -54,7 +54,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
throw new Error('cannot reset password of root');
|
throw new Error('cannot reset password of root');
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwd = rndstr('a-zA-Z0-9', 8);
|
const passwd = secureRndstr(8);
|
||||||
|
|
||||||
// Generate hash of password
|
// Generate hash of password
|
||||||
const hash = bcrypt.hashSync(passwd);
|
const hash = bcrypt.hashSync(passwd);
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ export const paramDef = {
|
|||||||
themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' },
|
themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' },
|
||||||
mascotImageUrl: { type: 'string', nullable: true },
|
mascotImageUrl: { type: 'string', nullable: true },
|
||||||
bannerUrl: { type: 'string', nullable: true },
|
bannerUrl: { type: 'string', nullable: true },
|
||||||
errorImageUrl: { type: 'string', nullable: true },
|
serverErrorImageUrl: { type: 'string', nullable: true },
|
||||||
|
infoImageUrl: { type: 'string', nullable: true },
|
||||||
|
notFoundImageUrl: { type: 'string', nullable: true },
|
||||||
iconUrl: { type: 'string', nullable: true },
|
iconUrl: { type: 'string', nullable: true },
|
||||||
backgroundImageUrl: { type: 'string', nullable: true },
|
backgroundImageUrl: { type: 'string', nullable: true },
|
||||||
logoImageUrl: { type: 'string', nullable: true },
|
logoImageUrl: { type: 'string', nullable: true },
|
||||||
@@ -149,6 +151,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
set.iconUrl = ps.iconUrl;
|
set.iconUrl = ps.iconUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ps.serverErrorImageUrl !== undefined) {
|
||||||
|
set.serverErrorImageUrl = ps.serverErrorImageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ps.infoImageUrl !== undefined) {
|
||||||
|
set.infoImageUrl = ps.infoImageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ps.notFoundImageUrl !== undefined) {
|
||||||
|
set.notFoundImageUrl = ps.notFoundImageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
if (ps.backgroundImageUrl !== undefined) {
|
if (ps.backgroundImageUrl !== undefined) {
|
||||||
set.backgroundImageUrl = ps.backgroundImageUrl;
|
set.backgroundImageUrl = ps.backgroundImageUrl;
|
||||||
}
|
}
|
||||||
@@ -281,10 +295,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
set.smtpPass = ps.smtpPass;
|
set.smtpPass = ps.smtpPass;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ps.errorImageUrl !== undefined) {
|
|
||||||
set.errorImageUrl = ps.errorImageUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ps.enableServiceWorker !== undefined) {
|
if (ps.enableServiceWorker !== undefined) {
|
||||||
set.enableServiceWorker = ps.enableServiceWorker;
|
set.enableServiceWorker = ps.enableServiceWorker;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.antennasRepository.update(antenna.id, {
|
this.antennasRepository.update(antenna.id, {
|
||||||
|
isActive: true,
|
||||||
lastUsedAt: new Date(),
|
lastUsedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
// Generate secret
|
// Generate secret
|
||||||
const secret = secureRndstr(32, true);
|
const secret = secureRndstr(32);
|
||||||
|
|
||||||
// for backward compatibility
|
// for backward compatibility
|
||||||
const permission = unique(ps.permission.map(v => v.replace(/^(.+)(\/|-)(read|write)$/, '$3:$1')));
|
const permission = unique(ps.permission.map(v => v.replace(/^(.+)(\/|-)(read|write)$/, '$3:$1')));
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
throw new ApiError(meta.errors.noSuchSession);
|
throw new ApiError(meta.errors.noSuchSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessToken = secureRndstr(32, true);
|
const accessToken = secureRndstr(32);
|
||||||
|
|
||||||
// Fetch exist access token
|
// Fetch exist access token
|
||||||
const exist = await this.accessTokensRepository.findOneBy({
|
const exist = await this.accessTokensRepository.findOneBy({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import * as cbor from 'cbor';
|
import cbor from 'cbor';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
const checkMoving = await this.accountMoveService.validateAlsoKnownAs(
|
const checkMoving = await this.accountMoveService.validateAlsoKnownAs(
|
||||||
me,
|
me,
|
||||||
(old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(),
|
(old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(),
|
||||||
true
|
true,
|
||||||
);
|
);
|
||||||
if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile);
|
if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile);
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
const checkMoving = await this.accountMoveService.validateAlsoKnownAs(
|
const checkMoving = await this.accountMoveService.validateAlsoKnownAs(
|
||||||
me,
|
me,
|
||||||
(old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(),
|
(old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(),
|
||||||
true
|
true,
|
||||||
);
|
);
|
||||||
if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile);
|
if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile);
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
const checkMoving = await this.accountMoveService.validateAlsoKnownAs(
|
const checkMoving = await this.accountMoveService.validateAlsoKnownAs(
|
||||||
me,
|
me,
|
||||||
(old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(),
|
(old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(),
|
||||||
true
|
true,
|
||||||
);
|
);
|
||||||
if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile);
|
if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile);
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
const checkMoving = await this.accountMoveService.validateAlsoKnownAs(
|
const checkMoving = await this.accountMoveService.validateAlsoKnownAs(
|
||||||
me,
|
me,
|
||||||
(old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(),
|
(old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(),
|
||||||
true
|
true,
|
||||||
);
|
);
|
||||||
if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile);
|
if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile);
|
||||||
|
|
||||||
|
|||||||
@@ -91,18 +91,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
|
const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
|
||||||
const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
|
const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
|
||||||
|
|
||||||
const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
||||||
const notificationsRes = await this.redisClient.xrevrange(
|
const notificationsRes = await this.redisClient.xrevrange(
|
||||||
`notificationTimeline:${me.id}`,
|
`notificationTimeline:${me.id}`,
|
||||||
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+',
|
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+',
|
||||||
'-',
|
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : '-',
|
||||||
'COUNT', limit);
|
'COUNT', limit);
|
||||||
|
|
||||||
if (notificationsRes.length === 0) {
|
if (notificationsRes.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
let notifications = notificationsRes.map(x => JSON.parse(x[1][1])).filter(x => x.id !== ps.untilId) as Notification[];
|
let notifications = notificationsRes.map(x => JSON.parse(x[1][1])).filter(x => x.id !== ps.untilId && x !== ps.sinceId) as Notification[];
|
||||||
|
|
||||||
if (includeTypes && includeTypes.length > 0) {
|
if (includeTypes && includeTypes.length > 0) {
|
||||||
notifications = notifications.filter(notification => includeTypes.includes(notification.type));
|
notifications = notifications.filter(notification => includeTypes.includes(notification.type));
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import rndstr from 'rndstr';
|
|
||||||
import ms from 'ms';
|
import ms from 'ms';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
@@ -9,6 +8,7 @@ import { EmailService } from '@/core/EmailService.js';
|
|||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
|
import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js';
|
||||||
import { ApiError } from '../../error.js';
|
import { ApiError } from '../../error.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
@@ -94,7 +94,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
this.globalEventService.publishMainStream(me.id, 'meUpdated', iObj);
|
this.globalEventService.publishMainStream(me.id, 'meUpdated', iObj);
|
||||||
|
|
||||||
if (ps.email != null) {
|
if (ps.email != null) {
|
||||||
const code = rndstr('a-z0-9', 16);
|
const code = secureRndstr(16, { chars: L_CHARS });
|
||||||
|
|
||||||
await this.userProfilesRepository.update(me.id, {
|
await this.userProfilesRepository.update(me.id, {
|
||||||
emailVerifyCode: code,
|
emailVerifyCode: code,
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export const paramDef = {
|
|||||||
alwaysMarkNsfw: { type: 'boolean' },
|
alwaysMarkNsfw: { type: 'boolean' },
|
||||||
autoSensitive: { type: 'boolean' },
|
autoSensitive: { type: 'boolean' },
|
||||||
ffVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
|
ffVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
|
||||||
pinnedPageId: { type: 'string', format: 'misskey:id' },
|
pinnedPageId: { type: 'string', format: 'misskey:id', nullable: true },
|
||||||
mutedWords: { type: 'array' },
|
mutedWords: { type: 'array' },
|
||||||
mutedInstances: { type: 'array', items: {
|
mutedInstances: { type: 'array', items: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import rndstr from 'rndstr';
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import type { RegistrationTicketsRepository } from '@/models/index.js';
|
import type { RegistrationTicketsRepository } from '@/models/index.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['meta'],
|
tags: ['meta'],
|
||||||
@@ -42,9 +42,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const code = rndstr({
|
const code = secureRndstr(8, {
|
||||||
length: 8,
|
chars: '23456789ABCDEFGHJKLMNPQRSTUVWXYZ', // [0-9A-Z] w/o [01IO] (32 patterns)
|
||||||
chars: '2-9A-HJ-NP-Z', // [0-9A-Z] w/o [01IO] (32 patterns)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.registrationTicketsRepository.insert({
|
await this.registrationTicketsRepository.insert({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { IsNull, LessThanOrEqual, MoreThan } from 'typeorm';
|
import { IsNull, LessThanOrEqual, MoreThan } from 'typeorm';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import * as JSON5 from 'json5';
|
import JSON5 from 'json5';
|
||||||
import type { AdsRepository, UsersRepository } from '@/models/index.js';
|
import type { AdsRepository, UsersRepository } from '@/models/index.js';
|
||||||
import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
|
import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
@@ -124,10 +124,17 @@ export const meta = {
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
},
|
},
|
||||||
errorImageUrl: {
|
serverErrorImageUrl: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: true,
|
||||||
default: 'https://xn--931a.moe/aiart/yubitun.png',
|
},
|
||||||
|
infoImageUrl: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: true,
|
||||||
|
},
|
||||||
|
notFoundImageUrl: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: true,
|
||||||
},
|
},
|
||||||
iconUrl: {
|
iconUrl: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@@ -288,7 +295,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
themeColor: instance.themeColor,
|
themeColor: instance.themeColor,
|
||||||
mascotImageUrl: instance.mascotImageUrl,
|
mascotImageUrl: instance.mascotImageUrl,
|
||||||
bannerUrl: instance.bannerUrl,
|
bannerUrl: instance.bannerUrl,
|
||||||
errorImageUrl: instance.errorImageUrl,
|
infoImageUrl: instance.infoImageUrl,
|
||||||
|
serverErrorImageUrl: instance.serverErrorImageUrl,
|
||||||
|
notFoundImageUrl: instance.notFoundImageUrl,
|
||||||
iconUrl: instance.iconUrl,
|
iconUrl: instance.iconUrl,
|
||||||
backgroundImageUrl: instance.backgroundImageUrl,
|
backgroundImageUrl: instance.backgroundImageUrl,
|
||||||
logoImageUrl: instance.logoImageUrl,
|
logoImageUrl: instance.logoImageUrl,
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
// Generate access token
|
// Generate access token
|
||||||
const accessToken = secureRndstr(32, true);
|
const accessToken = secureRndstr(32);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import type { UsersRepository, NotesRepository } from '@/models/index.js';
|
|||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import { NoteDeleteService } from '@/core/NoteDeleteService.js';
|
import { NoteDeleteService } from '@/core/NoteDeleteService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { ApiError } from '../../error.js';
|
|
||||||
import { GetterService } from '@/server/api/GetterService.js';
|
import { GetterService } from '@/server/api/GetterService.js';
|
||||||
|
import { ApiError } from '../../error.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['notes'],
|
tags: ['notes'],
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import rndstr from 'rndstr';
|
|
||||||
import ms from 'ms';
|
import ms from 'ms';
|
||||||
import { IsNull } from 'typeorm';
|
import { IsNull } from 'typeorm';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
@@ -8,6 +7,7 @@ import { IdService } from '@/core/IdService.js';
|
|||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { EmailService } from '@/core/EmailService.js';
|
import { EmailService } from '@/core/EmailService.js';
|
||||||
|
import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['reset password'],
|
tags: ['reset password'],
|
||||||
@@ -77,7 +77,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = rndstr('a-z0-9', 64);
|
const token = secureRndstr(64, { chars: L_CHARS });
|
||||||
|
|
||||||
await this.passwordResetRequestsRepository.insert({
|
await this.passwordResetRequestsRepository.insert({
|
||||||
id: this.idService.genId(),
|
id: this.idService.genId(),
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const roles = await this.rolesRepository.findBy({
|
const roles = await this.rolesRepository.findBy({
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
|
isExplorable: true,
|
||||||
});
|
});
|
||||||
return await this.roleEntityService.packMany(roles, me);
|
return await this.roleEntityService.packMany(roles, me);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
const role = await this.rolesRepository.findOneBy({
|
const role = await this.rolesRepository.findOneBy({
|
||||||
id: ps.roleId,
|
id: ps.roleId,
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
|
isExplorable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (role == null) {
|
if (role == null) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as sanitizeHtml from 'sanitize-html';
|
import sanitizeHtml from 'sanitize-html';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import type { UsersRepository, AbuseUserReportsRepository } from '@/models/index.js';
|
import type { UsersRepository, AbuseUserReportsRepository } from '@/models/index.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
|
|||||||
@@ -1,228 +0,0 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
|
||||||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
|
||||||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
|
||||||
import { DI } from '@/di-symbols.js';
|
|
||||||
import type { UsersRepository, NotesRepository, FollowingsRepository, DriveFilesRepository, NoteReactionsRepository, PageLikesRepository, NoteFavoritesRepository, PollVotesRepository } from '@/models/index.js';
|
|
||||||
import { ApiError } from '../../error.js';
|
|
||||||
|
|
||||||
export const meta = {
|
|
||||||
tags: ['users'],
|
|
||||||
|
|
||||||
requireCredential: false,
|
|
||||||
|
|
||||||
description: 'Show statistics about a user.',
|
|
||||||
|
|
||||||
errors: {
|
|
||||||
noSuchUser: {
|
|
||||||
message: 'No such user.',
|
|
||||||
code: 'NO_SUCH_USER',
|
|
||||||
id: '9e638e45-3b25-4ef7-8f95-07e8498f1819',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
res: {
|
|
||||||
type: 'object',
|
|
||||||
optional: false, nullable: false,
|
|
||||||
properties: {
|
|
||||||
notesCount: {
|
|
||||||
type: 'integer',
|
|
||||||
optional: false, nullable: false,
|
|
||||||
},
|
|
||||||
repliesCount: {
|
|
||||||
type: 'integer',
|
|
||||||
optional: false, nullable: false,
|
|
||||||
},
|
|
||||||
renotesCount: {
|
|
||||||
type: 'integer',
|
|
||||||
optional: false, nullable: false,
|
|
||||||
},
|
|
||||||
repliedCount: {
|
|
||||||
type: 'integer',
|
|
||||||
optional: false, nullable: false,
|
|
||||||
},
|
|
||||||
renotedCount: {
|
|
||||||
type: 'integer',
|
|
||||||
optional: false, nullable: false,
|
|
||||||
},
|
|
||||||
pollVotesCount: {
|
|
||||||
type: 'integer',
|
|
||||||
optional: false, nullable: false,
|
|
||||||
},
|
|
||||||
pollVotedCount: {
|
|
||||||
type: 'integer',
|
|
||||||
optional: false, nullable: false,
|
|
||||||
},
|
|
||||||
localFollowingCount: {
|
|
||||||
type: 'integer',
|
|
||||||
optional: false, nullable: false,
|
|
||||||
},
|
|
||||||
remoteFollowingCount: {
|
|
||||||
type: 'integer',
|
|
||||||
optional: false, nullable: false,
|
|
||||||
},
|
|
||||||
localFollowersCount: {
|
|
||||||
type: 'integer',
|
|
||||||
optional: false, nullable: false,
|
|
||||||
},
|
|
||||||
remoteFollowersCount: {
|
|
||||||
type: 'integer',
|
|
||||||
optional: false, nullable: false,
|
|
||||||
},
|
|
||||||
followingCount: {
|
|
||||||
type: 'integer',
|
|
||||||
optional: false, nullable: false,
|
|
||||||
},
|
|
||||||
followersCount: {
|
|
||||||
type: 'integer',
|
|
||||||
optional: false, nullable: false,
|
|
||||||
},
|
|
||||||
sentReactionsCount: {
|
|
||||||
type: 'integer',
|
|
||||||
optional: false, nullable: false,
|
|
||||||
},
|
|
||||||
receivedReactionsCount: {
|
|
||||||
type: 'integer',
|
|
||||||
optional: false, nullable: false,
|
|
||||||
},
|
|
||||||
noteFavoritesCount: {
|
|
||||||
type: 'integer',
|
|
||||||
optional: false, nullable: false,
|
|
||||||
},
|
|
||||||
pageLikesCount: {
|
|
||||||
type: 'integer',
|
|
||||||
optional: false, nullable: false,
|
|
||||||
},
|
|
||||||
pageLikedCount: {
|
|
||||||
type: 'integer',
|
|
||||||
optional: false, nullable: false,
|
|
||||||
},
|
|
||||||
driveFilesCount: {
|
|
||||||
type: 'integer',
|
|
||||||
optional: false, nullable: false,
|
|
||||||
},
|
|
||||||
driveUsage: {
|
|
||||||
type: 'integer',
|
|
||||||
optional: false, nullable: false,
|
|
||||||
description: 'Drive usage in bytes',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const paramDef = {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
userId: { type: 'string', format: 'misskey:id' },
|
|
||||||
},
|
|
||||||
required: ['userId'],
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// eslint-disable-next-line import/no-default-export
|
|
||||||
@Injectable()
|
|
||||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|
||||||
constructor(
|
|
||||||
@Inject(DI.usersRepository)
|
|
||||||
private usersRepository: UsersRepository,
|
|
||||||
|
|
||||||
@Inject(DI.notesRepository)
|
|
||||||
private notesRepository: NotesRepository,
|
|
||||||
|
|
||||||
@Inject(DI.followingsRepository)
|
|
||||||
private followingsRepository: FollowingsRepository,
|
|
||||||
|
|
||||||
@Inject(DI.driveFilesRepository)
|
|
||||||
private driveFilesRepository: DriveFilesRepository,
|
|
||||||
|
|
||||||
@Inject(DI.noteReactionsRepository)
|
|
||||||
private noteReactionsRepository: NoteReactionsRepository,
|
|
||||||
|
|
||||||
@Inject(DI.pageLikesRepository)
|
|
||||||
private pageLikesRepository: PageLikesRepository,
|
|
||||||
|
|
||||||
@Inject(DI.noteFavoritesRepository)
|
|
||||||
private noteFavoritesRepository: NoteFavoritesRepository,
|
|
||||||
|
|
||||||
@Inject(DI.pollVotesRepository)
|
|
||||||
private pollVotesRepository: PollVotesRepository,
|
|
||||||
|
|
||||||
private driveFileEntityService: DriveFileEntityService,
|
|
||||||
) {
|
|
||||||
super(meta, paramDef, async (ps, me) => {
|
|
||||||
const user = await this.usersRepository.findOneBy({ id: ps.userId });
|
|
||||||
if (user == null) {
|
|
||||||
throw new ApiError(meta.errors.noSuchUser);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await awaitAll({
|
|
||||||
notesCount: this.notesRepository.createQueryBuilder('note')
|
|
||||||
.where('note.userId = :userId', { userId: user.id })
|
|
||||||
.getCount(),
|
|
||||||
repliesCount: this.notesRepository.createQueryBuilder('note')
|
|
||||||
.where('note.userId = :userId', { userId: user.id })
|
|
||||||
.andWhere('note.replyId IS NOT NULL')
|
|
||||||
.getCount(),
|
|
||||||
renotesCount: this.notesRepository.createQueryBuilder('note')
|
|
||||||
.where('note.userId = :userId', { userId: user.id })
|
|
||||||
.andWhere('note.renoteId IS NOT NULL')
|
|
||||||
.getCount(),
|
|
||||||
repliedCount: this.notesRepository.createQueryBuilder('note')
|
|
||||||
.where('note.replyUserId = :userId', { userId: user.id })
|
|
||||||
.getCount(),
|
|
||||||
renotedCount: this.notesRepository.createQueryBuilder('note')
|
|
||||||
.where('note.renoteUserId = :userId', { userId: user.id })
|
|
||||||
.getCount(),
|
|
||||||
pollVotesCount: this.pollVotesRepository.createQueryBuilder('vote')
|
|
||||||
.where('vote.userId = :userId', { userId: user.id })
|
|
||||||
.getCount(),
|
|
||||||
pollVotedCount: this.pollVotesRepository.createQueryBuilder('vote')
|
|
||||||
.innerJoin('vote.note', 'note')
|
|
||||||
.where('note.userId = :userId', { userId: user.id })
|
|
||||||
.getCount(),
|
|
||||||
localFollowingCount: this.followingsRepository.createQueryBuilder('following')
|
|
||||||
.where('following.followerId = :userId', { userId: user.id })
|
|
||||||
.andWhere('following.followeeHost IS NULL')
|
|
||||||
.getCount(),
|
|
||||||
remoteFollowingCount: this.followingsRepository.createQueryBuilder('following')
|
|
||||||
.where('following.followerId = :userId', { userId: user.id })
|
|
||||||
.andWhere('following.followeeHost IS NOT NULL')
|
|
||||||
.getCount(),
|
|
||||||
localFollowersCount: this.followingsRepository.createQueryBuilder('following')
|
|
||||||
.where('following.followeeId = :userId', { userId: user.id })
|
|
||||||
.andWhere('following.followerHost IS NULL')
|
|
||||||
.getCount(),
|
|
||||||
remoteFollowersCount: this.followingsRepository.createQueryBuilder('following')
|
|
||||||
.where('following.followeeId = :userId', { userId: user.id })
|
|
||||||
.andWhere('following.followerHost IS NOT NULL')
|
|
||||||
.getCount(),
|
|
||||||
sentReactionsCount: this.noteReactionsRepository.createQueryBuilder('reaction')
|
|
||||||
.where('reaction.userId = :userId', { userId: user.id })
|
|
||||||
.getCount(),
|
|
||||||
receivedReactionsCount: this.noteReactionsRepository.createQueryBuilder('reaction')
|
|
||||||
.innerJoin('reaction.note', 'note')
|
|
||||||
.where('note.userId = :userId', { userId: user.id })
|
|
||||||
.getCount(),
|
|
||||||
noteFavoritesCount: this.noteFavoritesRepository.createQueryBuilder('favorite')
|
|
||||||
.where('favorite.userId = :userId', { userId: user.id })
|
|
||||||
.getCount(),
|
|
||||||
pageLikesCount: this.pageLikesRepository.createQueryBuilder('like')
|
|
||||||
.where('like.userId = :userId', { userId: user.id })
|
|
||||||
.getCount(),
|
|
||||||
pageLikedCount: this.pageLikesRepository.createQueryBuilder('like')
|
|
||||||
.innerJoin('like.page', 'page')
|
|
||||||
.where('page.userId = :userId', { userId: user.id })
|
|
||||||
.getCount(),
|
|
||||||
driveFilesCount: this.driveFilesRepository.createQueryBuilder('file')
|
|
||||||
.where('file.userId = :userId', { userId: user.id })
|
|
||||||
.getCount(),
|
|
||||||
driveUsage: this.driveFileEntityService.calcDriveUsageOf(user),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
followingCount: result.localFollowingCount + result.remoteFollowingCount,
|
|
||||||
followersCount: result.localFollowersCount + result.remoteFollowersCount,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import type Connection from '.';
|
import type Connection from './index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream channel
|
* Stream channel
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type { Page } from '@/models/entities/Page.js';
|
|||||||
import type { Packed } from '@/misc/json-schema.js';
|
import type { Packed } from '@/misc/json-schema.js';
|
||||||
import type { Webhook } from '@/models/entities/Webhook.js';
|
import type { Webhook } from '@/models/entities/Webhook.js';
|
||||||
import type { Meta } from '@/models/entities/Meta.js';
|
import type { Meta } from '@/models/entities/Meta.js';
|
||||||
import { Role, RoleAssignment } from '@/models';
|
import { Role, RoleAssignment } from '@/models/index.js';
|
||||||
import type Emitter from 'strict-event-emitter-types';
|
import type Emitter from 'strict-event-emitter-types';
|
||||||
import type { EventEmitter } from 'events';
|
import type { EventEmitter } from 'events';
|
||||||
|
|
||||||
@@ -233,7 +233,7 @@ export type StreamMessages = {
|
|||||||
|
|
||||||
// API event definitions
|
// API event definitions
|
||||||
// ストリームごとのEmitterの辞書を用意
|
// ストリームごとのEmitterの辞書を用意
|
||||||
type EventEmitterDictionary = { [x in keyof StreamMessages]: Emitter<EventEmitter, { [y in StreamMessages[x]['name']]: (e: StreamMessages[x]['payload']) => void }> };
|
type EventEmitterDictionary = { [x in keyof StreamMessages]: Emitter.default<EventEmitter, { [y in StreamMessages[x]['name']]: (e: StreamMessages[x]['payload']) => void }> };
|
||||||
// 共用体型を交差型にする型 https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection
|
// 共用体型を交差型にする型 https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection
|
||||||
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
|
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
|
||||||
// Emitter辞書から共用体型を作り、UnionToIntersectionで交差型にする
|
// Emitter辞書から共用体型を作り、UnionToIntersectionで交差型にする
|
||||||
|
|||||||
466
packages/backend/src/server/oauth/OAuth2ProviderService.ts
Normal file
466
packages/backend/src/server/oauth/OAuth2ProviderService.ts
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
import dns from 'node:dns/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
import httpLinkHeader from 'http-link-header';
|
||||||
|
import ipaddr from 'ipaddr.js';
|
||||||
|
import oauth2orize, { type OAuth2, AuthorizationError, ValidateFunctionArity2, OAuth2Req, MiddlewareRequest } from 'oauth2orize';
|
||||||
|
import oauth2Pkce from 'oauth2orize-pkce';
|
||||||
|
import fastifyView from '@fastify/view';
|
||||||
|
import pug from 'pug';
|
||||||
|
import bodyParser from 'body-parser';
|
||||||
|
import fastifyExpress from '@fastify/express';
|
||||||
|
import { verifyChallenge } from 'pkce-challenge';
|
||||||
|
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||||
|
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||||
|
import { kinds } from '@/misc/api-permissions.js';
|
||||||
|
import type { Config } from '@/config.js';
|
||||||
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { bindThis } from '@/decorators.js';
|
||||||
|
import type { AccessTokensRepository, UsersRepository } from '@/models/index.js';
|
||||||
|
import { IdService } from '@/core/IdService.js';
|
||||||
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
|
import type { LocalUser } from '@/models/entities/User.js';
|
||||||
|
import { MemoryKVCache } from '@/misc/cache.js';
|
||||||
|
import { LoggerService } from '@/core/LoggerService.js';
|
||||||
|
import Logger from '@/logger.js';
|
||||||
|
import type { ServerResponse } from 'node:http';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
|
||||||
|
// TODO: Consider migrating to @node-oauth/oauth2-server once
|
||||||
|
// https://github.com/node-oauth/node-oauth2-server/issues/180 is figured out.
|
||||||
|
// Upstream the various validations and RFC9207 implementation in that case.
|
||||||
|
|
||||||
|
// Follows https://indieauth.spec.indieweb.org/#client-identifier
|
||||||
|
// This is also mostly similar to https://developers.google.com/identity/protocols/oauth2/web-server#uri-validation
|
||||||
|
// although Google has stricter rule.
|
||||||
|
function validateClientId(raw: string): URL {
|
||||||
|
// "Clients are identified by a [URL]."
|
||||||
|
const url = ((): URL => {
|
||||||
|
try {
|
||||||
|
return new URL(raw);
|
||||||
|
} catch { throw new AuthorizationError('client_id must be a valid URL', 'invalid_request'); }
|
||||||
|
})();
|
||||||
|
|
||||||
|
// "Client identifier URLs MUST have either an https or http scheme"
|
||||||
|
// But then again:
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc6749.html#section-3.1.2.1
|
||||||
|
// 'The redirection endpoint SHOULD require the use of TLS as described
|
||||||
|
// in Section 1.6 when the requested response type is "code" or "token"'
|
||||||
|
// TODO: Consider allowing custom URIs per RFC 8252.
|
||||||
|
const allowedProtocols = process.env.NODE_ENV === 'test' ? ['http:', 'https:'] : ['https:'];
|
||||||
|
if (!allowedProtocols.includes(url.protocol)) {
|
||||||
|
throw new AuthorizationError('client_id must be a valid HTTPS URL', 'invalid_request');
|
||||||
|
}
|
||||||
|
|
||||||
|
// "MUST contain a path component (new URL() implicitly adds one)"
|
||||||
|
|
||||||
|
// "MUST NOT contain single-dot or double-dot path segments,"
|
||||||
|
const segments = url.pathname.split('/');
|
||||||
|
if (segments.includes('.') || segments.includes('..')) {
|
||||||
|
throw new AuthorizationError('client_id must not contain dot path segments', 'invalid_request');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ("MAY contain a query string component")
|
||||||
|
|
||||||
|
// "MUST NOT contain a fragment component"
|
||||||
|
if (url.hash) {
|
||||||
|
throw new AuthorizationError('client_id must not contain a fragment component', 'invalid_request');
|
||||||
|
}
|
||||||
|
|
||||||
|
// "MUST NOT contain a username or password component"
|
||||||
|
if (url.username || url.password) {
|
||||||
|
throw new AuthorizationError('client_id must not contain a username or a password', 'invalid_request');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ("MAY contain a port")
|
||||||
|
|
||||||
|
// "host names MUST be domain names or a loopback interface and MUST NOT be
|
||||||
|
// IPv4 or IPv6 addresses except for IPv4 127.0.0.1 or IPv6 [::1]."
|
||||||
|
if (!url.hostname.match(/\.\w+$/) && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) {
|
||||||
|
throw new AuthorizationError('client_id must have a domain name as a host name', 'invalid_request');
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClientInformation {
|
||||||
|
id: string;
|
||||||
|
redirectUris: string[];
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://indieauth.spec.indieweb.org/#client-information-discovery
|
||||||
|
// "Authorization servers SHOULD support parsing the [h-app] Microformat from the client_id,
|
||||||
|
// and if there is an [h-app] with a url property matching the client_id URL,
|
||||||
|
// then it should use the name and icon and display them on the authorization prompt."
|
||||||
|
// (But we don't display any icon for now)
|
||||||
|
// https://indieauth.spec.indieweb.org/#redirect-url
|
||||||
|
// "The client SHOULD publish one or more <link> tags or Link HTTP headers with a rel attribute
|
||||||
|
// of redirect_uri at the client_id URL.
|
||||||
|
// Authorization endpoints verifying that a redirect_uri is allowed for use by a client MUST
|
||||||
|
// look for an exact match of the given redirect_uri in the request against the list of
|
||||||
|
// redirect_uris discovered after resolving any relative URLs."
|
||||||
|
async function discoverClientInformation(httpRequestService: HttpRequestService, id: string): Promise<ClientInformation> {
|
||||||
|
try {
|
||||||
|
const res = await httpRequestService.send(id);
|
||||||
|
const redirectUris: string[] = [];
|
||||||
|
|
||||||
|
const linkHeader = res.headers.get('link');
|
||||||
|
if (linkHeader) {
|
||||||
|
redirectUris.push(...httpLinkHeader.parse(linkHeader).get('rel', 'redirect_uri').map(r => r.uri));
|
||||||
|
}
|
||||||
|
|
||||||
|
const fragment = JSDOM.fragment(await res.text());
|
||||||
|
|
||||||
|
redirectUris.push(...[...fragment.querySelectorAll<HTMLLinkElement>('link[rel=redirect_uri][href]')].map(el => el.href));
|
||||||
|
|
||||||
|
const name = fragment.querySelector<HTMLElement>('.h-app .p-name')?.textContent?.trim() ?? id;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
redirectUris: redirectUris.map(uri => new URL(uri, res.url).toString()),
|
||||||
|
name,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
throw new AuthorizationError('Failed to fetch client information', 'server_error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type OmitFirstElement<T extends unknown[]> = T extends [unknown, ...(infer R)]
|
||||||
|
? R
|
||||||
|
: [];
|
||||||
|
|
||||||
|
interface OAuthParsedRequest extends OAuth2Req {
|
||||||
|
codeChallenge: string;
|
||||||
|
codeChallengeMethod: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OAuthHttpResponse extends ServerResponse {
|
||||||
|
redirect(location: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OAuth2DecisionRequest extends MiddlewareRequest {
|
||||||
|
body: {
|
||||||
|
transaction_id: string;
|
||||||
|
cancel: boolean;
|
||||||
|
login_token: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQueryMode(issuerUrl: string): oauth2orize.grant.Options['modes'] {
|
||||||
|
return {
|
||||||
|
query: (txn, res, params): void => {
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc9207#name-response-parameter-iss
|
||||||
|
// "In authorization responses to the client, including error responses,
|
||||||
|
// an authorization server supporting this specification MUST indicate its
|
||||||
|
// identity by including the iss parameter in the response."
|
||||||
|
params.iss = issuerUrl;
|
||||||
|
|
||||||
|
const parsed = new URL(txn.redirectURI);
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
parsed.searchParams.append(key, value as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (res as OAuthHttpResponse).redirect(parsed.toString());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the transaction ID and the oauth/authorize parameters.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. oauth/authorize endpoint will call store() to store the parameters
|
||||||
|
* and puts the generated transaction ID to the dialog page
|
||||||
|
* 2. oauth/decision will call load() to retrieve the parameters and then remove()
|
||||||
|
*/
|
||||||
|
class OAuth2Store {
|
||||||
|
#cache = new MemoryKVCache<OAuth2>(1000 * 60 * 5); // expires after 5min
|
||||||
|
|
||||||
|
load(req: OAuth2DecisionRequest, cb: (err: Error | null, txn?: OAuth2) => void): void {
|
||||||
|
const { transaction_id } = req.body;
|
||||||
|
if (!transaction_id) {
|
||||||
|
cb(new AuthorizationError('Missing transaction ID', 'invalid_request'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loaded = this.#cache.get(transaction_id);
|
||||||
|
if (!loaded) {
|
||||||
|
cb(new AuthorizationError('Invalid or expired transaction ID', 'access_denied'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cb(null, loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
store(req: OAuth2DecisionRequest, oauth2: OAuth2, cb: (err: Error | null, transactionID?: string) => void): void {
|
||||||
|
const transactionId = secureRndstr(128, true);
|
||||||
|
this.#cache.set(transactionId, oauth2);
|
||||||
|
cb(null, transactionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(req: OAuth2DecisionRequest, tid: string, cb: () => void): void {
|
||||||
|
this.#cache.delete(tid);
|
||||||
|
cb();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OAuth2ProviderService {
|
||||||
|
#server = oauth2orize.createServer({
|
||||||
|
store: new OAuth2Store(),
|
||||||
|
});
|
||||||
|
#logger: Logger;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(DI.config)
|
||||||
|
private config: Config,
|
||||||
|
private httpRequestService: HttpRequestService,
|
||||||
|
@Inject(DI.accessTokensRepository)
|
||||||
|
accessTokensRepository: AccessTokensRepository,
|
||||||
|
idService: IdService,
|
||||||
|
@Inject(DI.usersRepository)
|
||||||
|
private usersRepository: UsersRepository,
|
||||||
|
private cacheService: CacheService,
|
||||||
|
loggerService: LoggerService,
|
||||||
|
) {
|
||||||
|
this.#logger = loggerService.getLogger('oauth');
|
||||||
|
|
||||||
|
const grantCodeCache = new MemoryKVCache<{
|
||||||
|
clientId: string,
|
||||||
|
userId: string,
|
||||||
|
redirectUri: string,
|
||||||
|
codeChallenge: string,
|
||||||
|
scopes: string[],
|
||||||
|
|
||||||
|
// fields to prevent multiple code use
|
||||||
|
grantedToken?: string,
|
||||||
|
revoked?: boolean,
|
||||||
|
used?: boolean,
|
||||||
|
}>(1000 * 60 * 5); // expires after 5m
|
||||||
|
|
||||||
|
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics
|
||||||
|
// "Authorization servers MUST support PKCE [RFC7636]."
|
||||||
|
this.#server.grant(oauth2Pkce.extensions());
|
||||||
|
this.#server.grant(oauth2orize.grant.code({
|
||||||
|
modes: getQueryMode(config.url),
|
||||||
|
}, (client, redirectUri, token, ares, areq, locals, done) => {
|
||||||
|
(async (): Promise<OmitFirstElement<Parameters<typeof done>>> => {
|
||||||
|
this.#logger.info(`Checking the user before sending authorization code to ${client.id}`);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new AuthorizationError('No user', 'invalid_request');
|
||||||
|
}
|
||||||
|
const user = await this.cacheService.localUserByNativeTokenCache.fetch(token,
|
||||||
|
() => this.usersRepository.findOneBy({ token }) as Promise<LocalUser | null>);
|
||||||
|
if (!user) {
|
||||||
|
throw new AuthorizationError('No such user', 'invalid_request');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#logger.info(`Sending authorization code on behalf of user ${user.id} to ${client.id} through ${redirectUri}, with scope: [${areq.scope}]`);
|
||||||
|
|
||||||
|
const code = secureRndstr(128, true);
|
||||||
|
grantCodeCache.set(code, {
|
||||||
|
clientId: client.id,
|
||||||
|
userId: user.id,
|
||||||
|
redirectUri,
|
||||||
|
codeChallenge: (areq as OAuthParsedRequest).codeChallenge,
|
||||||
|
scopes: areq.scope,
|
||||||
|
});
|
||||||
|
return [code];
|
||||||
|
})().then(args => done(null, ...args), err => done(err));
|
||||||
|
}));
|
||||||
|
this.#server.exchange(oauth2orize.exchange.authorizationCode((client, code, redirectUri, body, authInfo, done) => {
|
||||||
|
(async (): Promise<OmitFirstElement<Parameters<typeof done>> | undefined> => {
|
||||||
|
this.#logger.info('Checking the received authorization code for the exchange');
|
||||||
|
const granted = grantCodeCache.get(code);
|
||||||
|
if (!granted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.2
|
||||||
|
// "If an authorization code is used more than once, the authorization server
|
||||||
|
// MUST deny the request and SHOULD revoke (when possible) all tokens
|
||||||
|
// previously issued based on that authorization code."
|
||||||
|
if (granted.used) {
|
||||||
|
this.#logger.info(`Detected multiple code use from ${granted.clientId} for user ${granted.userId}. Revoking the code.`);
|
||||||
|
grantCodeCache.delete(code);
|
||||||
|
granted.revoked = true;
|
||||||
|
if (granted.grantedToken) {
|
||||||
|
await accessTokensRepository.delete({ token: granted.grantedToken });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
granted.used = true;
|
||||||
|
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.3
|
||||||
|
if (body.client_id !== granted.clientId) return;
|
||||||
|
if (redirectUri !== granted.redirectUri) return;
|
||||||
|
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc7636.html#section-4.6
|
||||||
|
if (!body.code_verifier) return;
|
||||||
|
if (!(await verifyChallenge(body.code_verifier as string, granted.codeChallenge))) return;
|
||||||
|
|
||||||
|
const accessToken = secureRndstr(128, true);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// NOTE: we don't have a setup for automatic token expiration
|
||||||
|
await accessTokensRepository.insert({
|
||||||
|
id: idService.genId(),
|
||||||
|
createdAt: now,
|
||||||
|
lastUsedAt: now,
|
||||||
|
userId: granted.userId,
|
||||||
|
token: accessToken,
|
||||||
|
hash: accessToken,
|
||||||
|
name: granted.clientId,
|
||||||
|
permission: granted.scopes,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (granted.revoked) {
|
||||||
|
this.#logger.info('Canceling the token as the authorization code was revoked in parallel during the process.');
|
||||||
|
await accessTokensRepository.delete({ token: accessToken });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
granted.grantedToken = accessToken;
|
||||||
|
this.#logger.info(`Generated access token for ${granted.clientId} for user ${granted.userId}, with scope: [${granted.scopes}]`);
|
||||||
|
|
||||||
|
return [accessToken, undefined, { scope: granted.scopes.join(' ') }];
|
||||||
|
})().then(args => done(null, ...args ?? []), err => done(err));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async createServer(fastify: FastifyInstance): Promise<void> {
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc8414.html
|
||||||
|
// https://indieauth.spec.indieweb.org/#indieauth-server-metadata
|
||||||
|
fastify.get('/.well-known/oauth-authorization-server', async (_request, reply) => {
|
||||||
|
reply.send({
|
||||||
|
issuer: this.config.url,
|
||||||
|
authorization_endpoint: new URL('/oauth/authorize', this.config.url),
|
||||||
|
token_endpoint: new URL('/oauth/token', this.config.url),
|
||||||
|
scopes_supported: kinds,
|
||||||
|
response_types_supported: ['code'],
|
||||||
|
grant_types_supported: ['authorization_code'],
|
||||||
|
service_documentation: 'https://misskey-hub.net',
|
||||||
|
code_challenge_methods_supported: ['S256'],
|
||||||
|
authorization_response_iss_parameter_supported: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get('/oauth/authorize', async (request, reply) => {
|
||||||
|
const oauth2 = (request.raw as MiddlewareRequest).oauth2;
|
||||||
|
if (!oauth2) {
|
||||||
|
throw new Error('Unexpected lack of authorization information');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#logger.info(`Rendering authorization page for "${oauth2.client.name}"`);
|
||||||
|
|
||||||
|
reply.header('Cache-Control', 'no-store');
|
||||||
|
return await reply.view('oauth', {
|
||||||
|
transactionId: oauth2.transactionID,
|
||||||
|
clientName: oauth2.client.name,
|
||||||
|
scope: oauth2.req.scope.join(' '),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
fastify.post('/oauth/decision', async () => { });
|
||||||
|
fastify.post('/oauth/token', async () => { });
|
||||||
|
|
||||||
|
fastify.register(fastifyView, {
|
||||||
|
root: fileURLToPath(new URL('../web/views', import.meta.url)),
|
||||||
|
engine: { pug },
|
||||||
|
defaultContext: {
|
||||||
|
version: this.config.version,
|
||||||
|
config: this.config,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await fastify.register(fastifyExpress);
|
||||||
|
fastify.use('/oauth/authorize', this.#server.authorize(((areq, done) => {
|
||||||
|
(async (): Promise<Parameters<typeof done>> => {
|
||||||
|
// This should return client/redirectURI AND the error, or
|
||||||
|
// the handler can't send error to the redirection URI
|
||||||
|
|
||||||
|
const { codeChallenge, codeChallengeMethod, clientID, redirectURI, scope } = areq as OAuthParsedRequest;
|
||||||
|
|
||||||
|
this.#logger.info(`Validating authorization parameters, with client_id: ${clientID}, redirect_uri: ${redirectURI}, scope: ${scope}`);
|
||||||
|
|
||||||
|
const clientUrl = validateClientId(clientID);
|
||||||
|
|
||||||
|
// TODO: Consider allowing localhost for native apps (RFC 8252)
|
||||||
|
// This is currently blocked by the redirect_uri check below, but we can theoretically
|
||||||
|
// loosen the rule for localhost as the data never leaves the client machine.
|
||||||
|
if (process.env.NODE_ENV !== 'test' || process.env.MISSKEY_TEST_CHECK_IP_RANGE === '1') {
|
||||||
|
const lookup = await dns.lookup(clientUrl.hostname);
|
||||||
|
if (ipaddr.parse(lookup.address).range() !== 'unicast') {
|
||||||
|
throw new AuthorizationError('client_id resolves to disallowed IP range.', 'invalid_request');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find client information from the remote.
|
||||||
|
const clientInfo = await discoverClientInformation(this.httpRequestService, clientUrl.href);
|
||||||
|
|
||||||
|
// Require the redirect URI to be included in an explicit list, per
|
||||||
|
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-4.1.3
|
||||||
|
if (!clientInfo.redirectUris.includes(redirectURI)) {
|
||||||
|
throw new AuthorizationError('Invalid redirect_uri', 'invalid_request');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const scopes = [...new Set(scope)].filter(s => kinds.includes(s));
|
||||||
|
if (!scopes.length) {
|
||||||
|
throw new AuthorizationError('`scope` parameter has no known scope', 'invalid_scope');
|
||||||
|
}
|
||||||
|
areq.scope = scopes;
|
||||||
|
|
||||||
|
// Require PKCE parameters.
|
||||||
|
// Recommended by https://indieauth.spec.indieweb.org/#authorization-request, but also prevents downgrade attack:
|
||||||
|
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#name-pkce-downgrade-attack
|
||||||
|
if (typeof codeChallenge !== 'string') {
|
||||||
|
throw new AuthorizationError('`code_challenge` parameter is required', 'invalid_request');
|
||||||
|
}
|
||||||
|
if (codeChallengeMethod !== 'S256') {
|
||||||
|
throw new AuthorizationError('`code_challenge_method` parameter must be set as S256', 'invalid_request');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return [err as Error, clientInfo, redirectURI];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [null, clientInfo, redirectURI];
|
||||||
|
})().then(args => done(...args), err => done(err));
|
||||||
|
}) as ValidateFunctionArity2));
|
||||||
|
fastify.use('/oauth/authorize', this.#server.errorHandler({
|
||||||
|
mode: 'indirect',
|
||||||
|
modes: getQueryMode(this.config.url),
|
||||||
|
}));
|
||||||
|
fastify.use('/oauth/authorize', this.#server.errorHandler());
|
||||||
|
|
||||||
|
fastify.use('/oauth/decision', bodyParser.urlencoded({ extended: false }));
|
||||||
|
fastify.use('/oauth/decision', this.#server.decision((req, done) => {
|
||||||
|
const { body } = req as OAuth2DecisionRequest;
|
||||||
|
this.#logger.info(`Received the decision. Cancel: ${!!body.cancel}`);
|
||||||
|
req.user = body.login_token;
|
||||||
|
done(null, undefined);
|
||||||
|
}));
|
||||||
|
fastify.use('/oauth/decision', this.#server.errorHandler());
|
||||||
|
|
||||||
|
// Clients may use JSON or urlencoded
|
||||||
|
fastify.use('/oauth/token', bodyParser.urlencoded({ extended: false }));
|
||||||
|
fastify.use('/oauth/token', bodyParser.json({ strict: true }));
|
||||||
|
fastify.use('/oauth/token', this.#server.token());
|
||||||
|
fastify.use('/oauth/token', this.#server.errorHandler());
|
||||||
|
|
||||||
|
// Return 404 for any unknown paths under /oauth so that clients can know
|
||||||
|
// whether a certain endpoint is supported or not.
|
||||||
|
fastify.all('/oauth/*', async (_request, reply) => {
|
||||||
|
reply.code(404);
|
||||||
|
reply.send({
|
||||||
|
error: {
|
||||||
|
message: 'Unknown OAuth endpoint.',
|
||||||
|
code: 'UNKNOWN_OAUTH_ENDPOINT',
|
||||||
|
id: 'aa49e620-26cb-4e28-aad6-8cbcb58db147',
|
||||||
|
kind: 'client',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ import { PageEntityService } from '@/core/entities/PageEntityService.js';
|
|||||||
import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js';
|
import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js';
|
||||||
import { ClipEntityService } from '@/core/entities/ClipEntityService.js';
|
import { ClipEntityService } from '@/core/entities/ClipEntityService.js';
|
||||||
import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js';
|
import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js';
|
||||||
import type { ChannelsRepository, ClipsRepository, FlashsRepository, GalleryPostsRepository, NotesRepository, PagesRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
|
import type { ChannelsRepository, ClipsRepository, FlashsRepository, GalleryPostsRepository, Meta, NotesRepository, PagesRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
|
||||||
import type Logger from '@/logger.js';
|
import type Logger from '@/logger.js';
|
||||||
import { deepClone } from '@/misc/clone.js';
|
import { deepClone } from '@/misc/clone.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
@@ -117,6 +117,18 @@ export class ClientServerService {
|
|||||||
return (res);
|
return (res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private generateCommonPugData(meta: Meta) {
|
||||||
|
return {
|
||||||
|
instanceName: meta.name ?? 'Misskey',
|
||||||
|
icon: meta.iconUrl,
|
||||||
|
themeColor: meta.themeColor,
|
||||||
|
serverErrorImageUrl: meta.serverErrorImageUrl ?? 'https://xn--931a.moe/assets/error.jpg',
|
||||||
|
infoImageUrl: meta.infoImageUrl ?? 'https://xn--931a.moe/assets/info.jpg',
|
||||||
|
notFoundImageUrl: meta.notFoundImageUrl ?? 'https://xn--931a.moe/assets/not-found.jpg',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
|
public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
|
||||||
fastify.register(fastifyCookie, {});
|
fastify.register(fastifyCookie, {});
|
||||||
@@ -341,12 +353,10 @@ export class ClientServerService {
|
|||||||
reply.header('Cache-Control', 'public, max-age=30');
|
reply.header('Cache-Control', 'public, max-age=30');
|
||||||
return await reply.view('base', {
|
return await reply.view('base', {
|
||||||
img: meta.bannerUrl,
|
img: meta.bannerUrl,
|
||||||
title: meta.name ?? 'Misskey',
|
|
||||||
instanceName: meta.name ?? 'Misskey',
|
|
||||||
url: this.config.url,
|
url: this.config.url,
|
||||||
|
title: meta.name ?? 'Misskey',
|
||||||
desc: meta.description,
|
desc: meta.description,
|
||||||
icon: meta.iconUrl,
|
...this.generateCommonPugData(meta),
|
||||||
themeColor: meta.themeColor,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -431,9 +441,7 @@ export class ClientServerService {
|
|||||||
user, profile, me,
|
user, profile, me,
|
||||||
avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user),
|
avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user),
|
||||||
sub: request.params.sub,
|
sub: request.params.sub,
|
||||||
instanceName: meta.name ?? 'Misskey',
|
...this.generateCommonPugData(meta),
|
||||||
icon: meta.iconUrl,
|
|
||||||
themeColor: meta.themeColor,
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// リモートユーザーなので
|
// リモートユーザーなので
|
||||||
@@ -481,9 +489,7 @@ export class ClientServerService {
|
|||||||
avatarUrl: _note.user.avatarUrl,
|
avatarUrl: _note.user.avatarUrl,
|
||||||
// TODO: Let locale changeable by instance setting
|
// TODO: Let locale changeable by instance setting
|
||||||
summary: getNoteSummary(_note),
|
summary: getNoteSummary(_note),
|
||||||
instanceName: meta.name ?? 'Misskey',
|
...this.generateCommonPugData(meta),
|
||||||
icon: meta.iconUrl,
|
|
||||||
themeColor: meta.themeColor,
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return await renderBase(reply);
|
return await renderBase(reply);
|
||||||
@@ -522,9 +528,7 @@ export class ClientServerService {
|
|||||||
page: _page,
|
page: _page,
|
||||||
profile,
|
profile,
|
||||||
avatarUrl: _page.user.avatarUrl,
|
avatarUrl: _page.user.avatarUrl,
|
||||||
instanceName: meta.name ?? 'Misskey',
|
...this.generateCommonPugData(meta),
|
||||||
icon: meta.iconUrl,
|
|
||||||
themeColor: meta.themeColor,
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return await renderBase(reply);
|
return await renderBase(reply);
|
||||||
@@ -550,9 +554,7 @@ export class ClientServerService {
|
|||||||
flash: _flash,
|
flash: _flash,
|
||||||
profile,
|
profile,
|
||||||
avatarUrl: _flash.user.avatarUrl,
|
avatarUrl: _flash.user.avatarUrl,
|
||||||
instanceName: meta.name ?? 'Misskey',
|
...this.generateCommonPugData(meta),
|
||||||
icon: meta.iconUrl,
|
|
||||||
themeColor: meta.themeColor,
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return await renderBase(reply);
|
return await renderBase(reply);
|
||||||
@@ -578,9 +580,7 @@ export class ClientServerService {
|
|||||||
clip: _clip,
|
clip: _clip,
|
||||||
profile,
|
profile,
|
||||||
avatarUrl: _clip.user.avatarUrl,
|
avatarUrl: _clip.user.avatarUrl,
|
||||||
instanceName: meta.name ?? 'Misskey',
|
...this.generateCommonPugData(meta),
|
||||||
icon: meta.iconUrl,
|
|
||||||
themeColor: meta.themeColor,
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return await renderBase(reply);
|
return await renderBase(reply);
|
||||||
@@ -604,9 +604,7 @@ export class ClientServerService {
|
|||||||
post: _post,
|
post: _post,
|
||||||
profile,
|
profile,
|
||||||
avatarUrl: _post.user.avatarUrl,
|
avatarUrl: _post.user.avatarUrl,
|
||||||
instanceName: meta.name ?? 'Misskey',
|
...this.generateCommonPugData(meta),
|
||||||
icon: meta.iconUrl,
|
|
||||||
themeColor: meta.themeColor,
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return await renderBase(reply);
|
return await renderBase(reply);
|
||||||
@@ -625,9 +623,7 @@ export class ClientServerService {
|
|||||||
reply.header('Cache-Control', 'public, max-age=15');
|
reply.header('Cache-Control', 'public, max-age=15');
|
||||||
return await reply.view('channel', {
|
return await reply.view('channel', {
|
||||||
channel: _channel,
|
channel: _channel,
|
||||||
instanceName: meta.name ?? 'Misskey',
|
...this.generateCommonPugData(meta),
|
||||||
icon: meta.iconUrl,
|
|
||||||
themeColor: meta.themeColor,
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return await renderBase(reply);
|
return await renderBase(reply);
|
||||||
|
|||||||
@@ -116,9 +116,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const colorSchema = localStorage.getItem('colorSchema');
|
const colorScheme = localStorage.getItem('colorScheme');
|
||||||
if (colorSchema) {
|
if (colorScheme) {
|
||||||
document.documentElement.style.setProperty('color-schema', colorSchema);
|
document.documentElement.style.setProperty('color-scheme', colorScheme);
|
||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ html
|
|||||||
link(rel='apple-touch-icon' href= icon || '/apple-touch-icon.png')
|
link(rel='apple-touch-icon' href= icon || '/apple-touch-icon.png')
|
||||||
link(rel='manifest' href='/manifest.json')
|
link(rel='manifest' href='/manifest.json')
|
||||||
link(rel='search' type='application/opensearchdescription+xml' title=(title || "Misskey") href=`${url}/opensearch.xml`)
|
link(rel='search' type='application/opensearchdescription+xml' title=(title || "Misskey") href=`${url}/opensearch.xml`)
|
||||||
link(rel='prefetch' href='https://xn--931a.moe/assets/info.jpg')
|
link(rel='prefetch' href=serverErrorImageUrl)
|
||||||
link(rel='prefetch' href='https://xn--931a.moe/assets/not-found.jpg')
|
link(rel='prefetch' href=infoImageUrl)
|
||||||
link(rel='prefetch' href='https://xn--931a.moe/assets/error.jpg')
|
link(rel='prefetch' href=notFoundImageUrl)
|
||||||
//- https://github.com/misskey-dev/misskey/issues/9842
|
//- https://github.com/misskey-dev/misskey/issues/9842
|
||||||
link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v2.17.0')
|
link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v2.22.0')
|
||||||
link(rel='modulepreload' href=`/vite/${clientEntry.file}`)
|
link(rel='modulepreload' href=`/vite/${clientEntry.file}`)
|
||||||
|
|
||||||
if !config.clientManifestExists
|
if !config.clientManifestExists
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ block vars
|
|||||||
- const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`;
|
- const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`;
|
||||||
- const url = `${config.url}/notes/${note.id}`;
|
- const url = `${config.url}/notes/${note.id}`;
|
||||||
- const isRenote = note.renote && note.text == null && note.fileIds.length == 0 && note.poll == null;
|
- const isRenote = note.renote && note.text == null && note.fileIds.length == 0 && note.poll == null;
|
||||||
- const image = (note.files || []).find(file => file.type.startsWith('image/') && !file.type.isSensitive)
|
- const image = (note.files || []).find(file => file.type.startsWith('image/') && !file.isSensitive)
|
||||||
- const video = (note.files || []).find(file => file.type.startsWith('video/') && !file.type.isSensitive)
|
- const video = (note.files || []).find(file => file.type.startsWith('video/') && !file.isSensitive)
|
||||||
|
|
||||||
block title
|
block title
|
||||||
= `${title} | ${instanceName}`
|
= `${title} | ${instanceName}`
|
||||||
|
|||||||
9
packages/backend/src/server/web/views/oauth.pug
Normal file
9
packages/backend/src/server/web/views/oauth.pug
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
extends ./base
|
||||||
|
|
||||||
|
block meta
|
||||||
|
//- Should be removed by the page when it loads, so that it won't needlessly
|
||||||
|
//- stay when user navigates away via the navigation bar
|
||||||
|
//- XXX: Remove navigation bar in auth page?
|
||||||
|
meta(name='misskey:oauth:transaction-id' content=transactionId)
|
||||||
|
meta(name='misskey:oauth:client-name' content=clientName)
|
||||||
|
meta(name='misskey:oauth:scope' content=scope)
|
||||||
@@ -2,15 +2,16 @@ process.env.NODE_ENV = 'test';
|
|||||||
|
|
||||||
import * as assert from 'assert';
|
import * as assert from 'assert';
|
||||||
import * as crypto from 'node:crypto';
|
import * as crypto from 'node:crypto';
|
||||||
import * as cbor from 'cbor';
|
import cbor from 'cbor';
|
||||||
import * as OTPAuth from 'otpauth';
|
import * as OTPAuth from 'otpauth';
|
||||||
import { loadConfig } from '../../src/config.js';
|
import { loadConfig } from '../../src/config.js';
|
||||||
import { signup, api, post, react, startServer, waitFire } from '../utils.js';
|
import { signup, api, post, react, startServer, waitFire } from '../utils.js';
|
||||||
import type { INestApplicationContext } from '@nestjs/common';
|
import type { INestApplicationContext } from '@nestjs/common';
|
||||||
|
import type * as misskey from 'misskey-js';
|
||||||
|
|
||||||
describe('2要素認証', () => {
|
describe('2要素認証', () => {
|
||||||
let app: INestApplicationContext;
|
let app: INestApplicationContext;
|
||||||
let alice: unknown;
|
let alice: misskey.entities.MeSignup;
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const password = 'test';
|
const password = 'test';
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ describe('アンテナ', () => {
|
|||||||
// - srcのenumにgroupが残っている
|
// - srcのenumにgroupが残っている
|
||||||
// - userGroupIdが残っている, isActiveがない
|
// - userGroupIdが残っている, isActiveがない
|
||||||
type Antenna = misskey.entities.Antenna | Packed<'Antenna'>;
|
type Antenna = misskey.entities.Antenna | Packed<'Antenna'>;
|
||||||
type User = misskey.entities.MeDetailed & { token: string };
|
type User = misskey.entities.MeSignup;
|
||||||
type Note = misskey.entities.Note;
|
type Note = misskey.entities.Note;
|
||||||
|
|
||||||
// アンテナを作成できる最小のパラメタ
|
// アンテナを作成できる最小のパラメタ
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ process.env.NODE_ENV = 'test';
|
|||||||
import * as assert from 'assert';
|
import * as assert from 'assert';
|
||||||
import { signup, api, post, startServer } from '../utils.js';
|
import { signup, api, post, startServer } from '../utils.js';
|
||||||
import type { INestApplicationContext } from '@nestjs/common';
|
import type { INestApplicationContext } from '@nestjs/common';
|
||||||
|
import type * as misskey from 'misskey-js';
|
||||||
|
|
||||||
describe('API visibility', () => {
|
describe('API visibility', () => {
|
||||||
let app: INestApplicationContext;
|
let app: INestApplicationContext;
|
||||||
@@ -18,15 +19,15 @@ describe('API visibility', () => {
|
|||||||
describe('Note visibility', () => {
|
describe('Note visibility', () => {
|
||||||
//#region vars
|
//#region vars
|
||||||
/** ヒロイン */
|
/** ヒロイン */
|
||||||
let alice: any;
|
let alice: misskey.entities.MeSignup;
|
||||||
/** フォロワー */
|
/** フォロワー */
|
||||||
let follower: any;
|
let follower: misskey.entities.MeSignup;
|
||||||
/** 非フォロワー */
|
/** 非フォロワー */
|
||||||
let other: any;
|
let other: misskey.entities.MeSignup;
|
||||||
/** 非フォロワーでもリプライやメンションをされた人 */
|
/** 非フォロワーでもリプライやメンションをされた人 */
|
||||||
let target: any;
|
let target: misskey.entities.MeSignup;
|
||||||
/** specified mentionでmentionを飛ばされる人 */
|
/** specified mentionでmentionを飛ばされる人 */
|
||||||
let target2: any;
|
let target2: misskey.entities.MeSignup;
|
||||||
|
|
||||||
/** public-post */
|
/** public-post */
|
||||||
let pub: any;
|
let pub: any;
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
process.env.NODE_ENV = 'test';
|
process.env.NODE_ENV = 'test';
|
||||||
|
|
||||||
import * as assert from 'assert';
|
import * as assert from 'assert';
|
||||||
import { signup, api, startServer } from '../utils.js';
|
import { signup, api, startServer, successfulApiCall, failedApiCall, uploadFile, waitFire, connectStream, relativeFetch } from '../utils.js';
|
||||||
import type { INestApplicationContext } from '@nestjs/common';
|
import type { INestApplicationContext } from '@nestjs/common';
|
||||||
|
import type * as misskey from 'misskey-js';
|
||||||
|
import { IncomingMessage } from 'http';
|
||||||
|
|
||||||
describe('API', () => {
|
describe('API', () => {
|
||||||
let app: INestApplicationContext;
|
let app: INestApplicationContext;
|
||||||
let alice: any;
|
let alice: misskey.entities.MeSignup;
|
||||||
let bob: any;
|
let bob: misskey.entities.MeSignup;
|
||||||
let carol: any;
|
let carol: misskey.entities.MeSignup;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await startServer();
|
app = await startServer();
|
||||||
@@ -80,4 +82,178 @@ describe('API', () => {
|
|||||||
assert.strictEqual(res.body.nullableDefault, 'hello');
|
assert.strictEqual(res.body.nullableDefault, 'hello');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('管理者専用のAPIのアクセス制限', async () => {
|
||||||
|
// aliceは管理者、APIを使える
|
||||||
|
await successfulApiCall({
|
||||||
|
endpoint: '/admin/get-index-stats',
|
||||||
|
parameters: {},
|
||||||
|
user: alice,
|
||||||
|
});
|
||||||
|
|
||||||
|
// bobは一般ユーザーだからダメ
|
||||||
|
await failedApiCall({
|
||||||
|
endpoint: '/admin/get-index-stats',
|
||||||
|
parameters: {},
|
||||||
|
user: bob,
|
||||||
|
}, {
|
||||||
|
status: 403,
|
||||||
|
code: 'ROLE_PERMISSION_DENIED',
|
||||||
|
id: 'c3d38592-54c0-429d-be96-5636b0431a61',
|
||||||
|
});
|
||||||
|
|
||||||
|
// publicアクセスももちろんダメ
|
||||||
|
await failedApiCall({
|
||||||
|
endpoint: '/admin/get-index-stats',
|
||||||
|
parameters: {},
|
||||||
|
user: undefined,
|
||||||
|
}, {
|
||||||
|
status: 401,
|
||||||
|
code: 'CREDENTIAL_REQUIRED',
|
||||||
|
id: '1384574d-a912-4b81-8601-c7b1c4085df1',
|
||||||
|
});
|
||||||
|
|
||||||
|
// ごまがしもダメ
|
||||||
|
await failedApiCall({
|
||||||
|
endpoint: '/admin/get-index-stats',
|
||||||
|
parameters: {},
|
||||||
|
user: { token: 'tsukawasete' },
|
||||||
|
}, {
|
||||||
|
status: 401,
|
||||||
|
code: 'AUTHENTICATION_FAILED',
|
||||||
|
id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Authentication header', () => {
|
||||||
|
test('一般リクエスト', async () => {
|
||||||
|
await successfulApiCall({
|
||||||
|
endpoint: '/admin/get-index-stats',
|
||||||
|
parameters: {},
|
||||||
|
user: {
|
||||||
|
token: alice.token,
|
||||||
|
bearer: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multipartリクエスト', async () => {
|
||||||
|
const result = await uploadFile({
|
||||||
|
token: alice.token,
|
||||||
|
bearer: true,
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('streaming', async () => {
|
||||||
|
const fired = await waitFire(
|
||||||
|
{
|
||||||
|
token: alice.token,
|
||||||
|
bearer: true,
|
||||||
|
},
|
||||||
|
'homeTimeline',
|
||||||
|
() => api('notes/create', { text: 'foo' }, alice),
|
||||||
|
msg => msg.type === 'note' && msg.body.text === 'foo',
|
||||||
|
);
|
||||||
|
assert.strictEqual(fired, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tokenエラー応答でWWW-Authenticate headerを送る', () => {
|
||||||
|
describe('invalid_token', () => {
|
||||||
|
test('一般リクエスト', async () => {
|
||||||
|
const result = await api('/admin/get-index-stats', {}, {
|
||||||
|
token: 'syuilo',
|
||||||
|
bearer: true,
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.status, 401);
|
||||||
|
assert.ok(result.headers.get('WWW-Authenticate')?.startsWith('Bearer realm="Misskey", error="invalid_token", error_description'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multipartリクエスト', async () => {
|
||||||
|
const result = await uploadFile({
|
||||||
|
token: 'syuilo',
|
||||||
|
bearer: true,
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.status, 401);
|
||||||
|
assert.ok(result.headers.get('WWW-Authenticate')?.startsWith('Bearer realm="Misskey", error="invalid_token", error_description'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('streaming', async () => {
|
||||||
|
await assert.rejects(connectStream(
|
||||||
|
{
|
||||||
|
token: 'syuilo',
|
||||||
|
bearer: true,
|
||||||
|
},
|
||||||
|
'homeTimeline',
|
||||||
|
() => { },
|
||||||
|
), (err: IncomingMessage) => {
|
||||||
|
assert.strictEqual(err.statusCode, 401);
|
||||||
|
assert.ok(err.headers['www-authenticate']?.startsWith('Bearer realm="Misskey", error="invalid_token", error_description'));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tokenがないとrealmだけおくる', () => {
|
||||||
|
test('一般リクエスト', async () => {
|
||||||
|
const result = await api('/admin/get-index-stats', {});
|
||||||
|
assert.strictEqual(result.status, 401);
|
||||||
|
assert.strictEqual(result.headers.get('WWW-Authenticate'), 'Bearer realm="Misskey"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multipartリクエスト', async () => {
|
||||||
|
const result = await uploadFile();
|
||||||
|
assert.strictEqual(result.status, 401);
|
||||||
|
assert.strictEqual(result.headers.get('WWW-Authenticate'), 'Bearer realm="Misskey"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid_request', async () => {
|
||||||
|
const result = await api('/notes/create', { text: true }, {
|
||||||
|
token: alice.token,
|
||||||
|
bearer: true,
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.status, 400);
|
||||||
|
assert.ok(result.headers.get('WWW-Authenticate')?.startsWith('Bearer realm="Misskey", error="invalid_request", error_description'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('invalid bearer format', () => {
|
||||||
|
test('No preceding bearer', async () => {
|
||||||
|
const result = await relativeFetch('api/notes/create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: alice.token,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ text: 'test' }),
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Lowercase bearer', async () => {
|
||||||
|
const result = await relativeFetch('api/notes/create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `bearer ${alice.token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ text: 'test' }),
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('No space after bearer', async () => {
|
||||||
|
const result = await relativeFetch('api/notes/create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer${alice.token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ text: 'test' }),
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.status, 401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,14 +3,15 @@ process.env.NODE_ENV = 'test';
|
|||||||
import * as assert from 'assert';
|
import * as assert from 'assert';
|
||||||
import { signup, api, post, startServer } from '../utils.js';
|
import { signup, api, post, startServer } from '../utils.js';
|
||||||
import type { INestApplicationContext } from '@nestjs/common';
|
import type { INestApplicationContext } from '@nestjs/common';
|
||||||
|
import type * as misskey from 'misskey-js';
|
||||||
|
|
||||||
describe('Block', () => {
|
describe('Block', () => {
|
||||||
let app: INestApplicationContext;
|
let app: INestApplicationContext;
|
||||||
|
|
||||||
// alice blocks bob
|
// alice blocks bob
|
||||||
let alice: any;
|
let alice: misskey.entities.MeSignup;
|
||||||
let bob: any;
|
let bob: misskey.entities.MeSignup;
|
||||||
let carol: any;
|
let carol: misskey.entities.MeSignup;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await startServer();
|
app = await startServer();
|
||||||
|
|||||||
@@ -4,17 +4,18 @@ import * as assert from 'assert';
|
|||||||
// node-fetch only supports it's own Blob yet
|
// node-fetch only supports it's own Blob yet
|
||||||
// https://github.com/node-fetch/node-fetch/pull/1664
|
// https://github.com/node-fetch/node-fetch/pull/1664
|
||||||
import { Blob } from 'node-fetch';
|
import { Blob } from 'node-fetch';
|
||||||
|
import { User } from '@/models/index.js';
|
||||||
import { startServer, signup, post, api, uploadFile, simpleGet, initTestDb } from '../utils.js';
|
import { startServer, signup, post, api, uploadFile, simpleGet, initTestDb } from '../utils.js';
|
||||||
import type { INestApplicationContext } from '@nestjs/common';
|
import type { INestApplicationContext } from '@nestjs/common';
|
||||||
import { User } from '@/models/index.js';
|
import type * as misskey from 'misskey-js';
|
||||||
|
|
||||||
describe('Endpoints', () => {
|
describe('Endpoints', () => {
|
||||||
let app: INestApplicationContext;
|
let app: INestApplicationContext;
|
||||||
|
|
||||||
let alice: any;
|
let alice: misskey.entities.MeSignup;
|
||||||
let bob: any;
|
let bob: misskey.entities.MeSignup;
|
||||||
let carol: any;
|
let carol: misskey.entities.MeSignup;
|
||||||
let dave: any;
|
let dave: misskey.entities.MeSignup;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await startServer();
|
app = await startServer();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as assert from 'assert';
|
|||||||
import { startServer, channel, clip, cookie, galleryPost, signup, page, play, post, simpleGet, uploadFile } from '../utils.js';
|
import { startServer, channel, clip, cookie, galleryPost, signup, page, play, post, simpleGet, uploadFile } from '../utils.js';
|
||||||
import type { SimpleGetResponse } from '../utils.js';
|
import type { SimpleGetResponse } from '../utils.js';
|
||||||
import type { INestApplicationContext } from '@nestjs/common';
|
import type { INestApplicationContext } from '@nestjs/common';
|
||||||
|
import type * as misskey from 'misskey-js';
|
||||||
|
|
||||||
// Request Accept
|
// Request Accept
|
||||||
const ONLY_AP = 'application/activity+json';
|
const ONLY_AP = 'application/activity+json';
|
||||||
@@ -19,7 +20,7 @@ const JSON_UTF8 = 'application/json; charset=utf-8';
|
|||||||
describe('Webリソース', () => {
|
describe('Webリソース', () => {
|
||||||
let app: INestApplicationContext;
|
let app: INestApplicationContext;
|
||||||
|
|
||||||
let alice: any;
|
let alice: misskey.entities.MeSignup;
|
||||||
let aliceUploadedFile: any;
|
let aliceUploadedFile: any;
|
||||||
let alicesPost: any;
|
let alicesPost: any;
|
||||||
let alicePage: any;
|
let alicePage: any;
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ process.env.NODE_ENV = 'test';
|
|||||||
import * as assert from 'assert';
|
import * as assert from 'assert';
|
||||||
import { signup, api, startServer, simpleGet } from '../utils.js';
|
import { signup, api, startServer, simpleGet } from '../utils.js';
|
||||||
import type { INestApplicationContext } from '@nestjs/common';
|
import type { INestApplicationContext } from '@nestjs/common';
|
||||||
|
import type * as misskey from 'misskey-js';
|
||||||
|
|
||||||
describe('FF visibility', () => {
|
describe('FF visibility', () => {
|
||||||
let app: INestApplicationContext;
|
let app: INestApplicationContext;
|
||||||
|
|
||||||
let alice: any;
|
let alice: misskey.entities.MeSignup;
|
||||||
let bob: any;
|
let bob: misskey.entities.MeSignup;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await startServer();
|
app = await startServer();
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
process.env.NODE_ENV = 'test';
|
process.env.NODE_ENV = 'test';
|
||||||
|
|
||||||
import * as assert from 'assert';
|
import * as assert from 'assert';
|
||||||
import rndstr from 'rndstr';
|
|
||||||
import { loadConfig } from '@/config.js';
|
import { loadConfig } from '@/config.js';
|
||||||
import { User, UsersRepository } from '@/models/index.js';
|
import { User, UsersRepository } from '@/models/index.js';
|
||||||
import { jobQueue } from '@/boot/common.js';
|
import { jobQueue } from '@/boot/common.js';
|
||||||
|
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||||
import { uploadFile, signup, startServer, initTestDb, api, sleep, successfulApiCall } from '../utils.js';
|
import { uploadFile, signup, startServer, initTestDb, api, sleep, successfulApiCall } from '../utils.js';
|
||||||
import type { INestApplicationContext } from '@nestjs/common';
|
import type { INestApplicationContext } from '@nestjs/common';
|
||||||
|
import type * as misskey from 'misskey-js';
|
||||||
|
|
||||||
describe('Account Move', () => {
|
describe('Account Move', () => {
|
||||||
let app: INestApplicationContext;
|
let app: INestApplicationContext;
|
||||||
@@ -14,12 +15,12 @@ describe('Account Move', () => {
|
|||||||
let url: URL;
|
let url: URL;
|
||||||
|
|
||||||
let root: any;
|
let root: any;
|
||||||
let alice: any;
|
let alice: misskey.entities.MeSignup;
|
||||||
let bob: any;
|
let bob: misskey.entities.MeSignup;
|
||||||
let carol: any;
|
let carol: misskey.entities.MeSignup;
|
||||||
let dave: any;
|
let dave: misskey.entities.MeSignup;
|
||||||
let eve: any;
|
let eve: misskey.entities.MeSignup;
|
||||||
let frank: any;
|
let frank: misskey.entities.MeSignup;
|
||||||
|
|
||||||
let Users: UsersRepository;
|
let Users: UsersRepository;
|
||||||
|
|
||||||
@@ -162,7 +163,7 @@ describe('Account Move', () => {
|
|||||||
alsoKnownAs: [`@alice@${url.hostname}`],
|
alsoKnownAs: [`@alice@${url.hostname}`],
|
||||||
}, root);
|
}, root);
|
||||||
const listRoot = await api('/users/lists/create', {
|
const listRoot = await api('/users/lists/create', {
|
||||||
name: rndstr('0-9a-z', 8),
|
name: secureRndstr(8),
|
||||||
}, root);
|
}, root);
|
||||||
await api('/users/lists/push', {
|
await api('/users/lists/push', {
|
||||||
listId: listRoot.body.id,
|
listId: listRoot.body.id,
|
||||||
@@ -176,9 +177,9 @@ describe('Account Move', () => {
|
|||||||
userId: eve.id,
|
userId: eve.id,
|
||||||
}, alice);
|
}, alice);
|
||||||
const antenna = await api('/antennas/create', {
|
const antenna = await api('/antennas/create', {
|
||||||
name: rndstr('0-9a-z', 8),
|
name: secureRndstr(8),
|
||||||
src: 'home',
|
src: 'home',
|
||||||
keywords: [rndstr('0-9a-z', 8)],
|
keywords: [secureRndstr(8)],
|
||||||
excludeKeywords: [],
|
excludeKeywords: [],
|
||||||
users: [],
|
users: [],
|
||||||
caseSensitive: false,
|
caseSensitive: false,
|
||||||
@@ -210,7 +211,7 @@ describe('Account Move', () => {
|
|||||||
userId: dave.id,
|
userId: dave.id,
|
||||||
}, eve);
|
}, eve);
|
||||||
const listEve = await api('/users/lists/create', {
|
const listEve = await api('/users/lists/create', {
|
||||||
name: rndstr('0-9a-z', 8),
|
name: secureRndstr(8),
|
||||||
}, eve);
|
}, eve);
|
||||||
await api('/users/lists/push', {
|
await api('/users/lists/push', {
|
||||||
listId: listEve.body.id,
|
listId: listEve.body.id,
|
||||||
@@ -419,9 +420,9 @@ describe('Account Move', () => {
|
|||||||
test('Prohibit access after moving: /antennas/update', async () => {
|
test('Prohibit access after moving: /antennas/update', async () => {
|
||||||
const res = await api('/antennas/update', {
|
const res = await api('/antennas/update', {
|
||||||
antennaId,
|
antennaId,
|
||||||
name: rndstr('0-9a-z', 8),
|
name: secureRndstr(8),
|
||||||
src: 'users',
|
src: 'users',
|
||||||
keywords: [rndstr('0-9a-z', 8)],
|
keywords: [secureRndstr(8)],
|
||||||
excludeKeywords: [],
|
excludeKeywords: [],
|
||||||
users: [eve.id],
|
users: [eve.id],
|
||||||
caseSensitive: false,
|
caseSensitive: false,
|
||||||
|
|||||||
@@ -3,14 +3,15 @@ process.env.NODE_ENV = 'test';
|
|||||||
import * as assert from 'assert';
|
import * as assert from 'assert';
|
||||||
import { signup, api, post, react, startServer, waitFire } from '../utils.js';
|
import { signup, api, post, react, startServer, waitFire } from '../utils.js';
|
||||||
import type { INestApplicationContext } from '@nestjs/common';
|
import type { INestApplicationContext } from '@nestjs/common';
|
||||||
|
import type * as misskey from 'misskey-js';
|
||||||
|
|
||||||
describe('Mute', () => {
|
describe('Mute', () => {
|
||||||
let app: INestApplicationContext;
|
let app: INestApplicationContext;
|
||||||
|
|
||||||
// alice mutes carol
|
// alice mutes carol
|
||||||
let alice: any;
|
let alice: misskey.entities.MeSignup;
|
||||||
let bob: any;
|
let bob: misskey.entities.MeSignup;
|
||||||
let carol: any;
|
let carol: misskey.entities.MeSignup;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await startServer();
|
app = await startServer();
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import * as assert from 'assert';
|
|||||||
import { Note } from '@/models/entities/Note.js';
|
import { Note } from '@/models/entities/Note.js';
|
||||||
import { signup, post, uploadUrl, startServer, initTestDb, api, uploadFile } from '../utils.js';
|
import { signup, post, uploadUrl, startServer, initTestDb, api, uploadFile } from '../utils.js';
|
||||||
import type { INestApplicationContext } from '@nestjs/common';
|
import type { INestApplicationContext } from '@nestjs/common';
|
||||||
|
import type * as misskey from 'misskey-js';
|
||||||
|
|
||||||
describe('Note', () => {
|
describe('Note', () => {
|
||||||
let app: INestApplicationContext;
|
let app: INestApplicationContext;
|
||||||
let Notes: any;
|
let Notes: any;
|
||||||
|
|
||||||
let alice: any;
|
let alice: misskey.entities.MeSignup;
|
||||||
let bob: any;
|
let bob: misskey.entities.MeSignup;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await startServer();
|
app = await startServer();
|
||||||
|
|||||||
925
packages/backend/test/e2e/oauth.ts
Normal file
925
packages/backend/test/e2e/oauth.ts
Normal file
@@ -0,0 +1,925 @@
|
|||||||
|
/**
|
||||||
|
* Basic OAuth tests to make sure the library is correctly integrated to Misskey
|
||||||
|
* and not regressed by version updates or potential migration to another library.
|
||||||
|
*/
|
||||||
|
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
|
||||||
|
import * as assert from 'assert';
|
||||||
|
import { AuthorizationCode, ResourceOwnerPassword, type AuthorizationTokenConfig, ClientCredentials, ModuleOptions } from 'simple-oauth2';
|
||||||
|
import pkceChallenge from 'pkce-challenge';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
import Fastify, { type FastifyReply, type FastifyInstance } from 'fastify';
|
||||||
|
import { api, port, signup, startServer } from '../utils.js';
|
||||||
|
import type * as misskey from 'misskey-js';
|
||||||
|
import type { INestApplicationContext } from '@nestjs/common';
|
||||||
|
|
||||||
|
const host = `http://127.0.0.1:${port}`;
|
||||||
|
|
||||||
|
const clientPort = port + 1;
|
||||||
|
const redirect_uri = `http://127.0.0.1:${clientPort}/redirect`;
|
||||||
|
|
||||||
|
const basicAuthParams: AuthorizationParamsExtended = {
|
||||||
|
redirect_uri,
|
||||||
|
scope: 'write:notes',
|
||||||
|
state: 'state',
|
||||||
|
code_challenge: 'code',
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AuthorizationParamsExtended {
|
||||||
|
redirect_uri: string;
|
||||||
|
scope: string | string[];
|
||||||
|
state: string;
|
||||||
|
code_challenge?: string;
|
||||||
|
code_challenge_method?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthorizationTokenConfigExtended extends AuthorizationTokenConfig {
|
||||||
|
code_verifier: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetTokenError {
|
||||||
|
data: {
|
||||||
|
payload: {
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientConfig: ModuleOptions<'client_id'> = {
|
||||||
|
client: {
|
||||||
|
id: `http://127.0.0.1:${clientPort}/`,
|
||||||
|
secret: '',
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
tokenHost: host,
|
||||||
|
tokenPath: '/oauth/token',
|
||||||
|
authorizePath: '/oauth/authorize',
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
authorizationMethod: 'body',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function getMeta(html: string): { transactionId: string | undefined, clientName: string | undefined } {
|
||||||
|
const fragment = JSDOM.fragment(html);
|
||||||
|
return {
|
||||||
|
transactionId: fragment.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:transaction-id"]')?.content,
|
||||||
|
clientName: fragment.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-name"]')?.content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchDecision(transactionId: string, user: misskey.entities.MeSignup, { cancel }: { cancel?: boolean } = {}): Promise<Response> {
|
||||||
|
return fetch(new URL('/oauth/decision', host), {
|
||||||
|
method: 'post',
|
||||||
|
body: new URLSearchParams({
|
||||||
|
transaction_id: transactionId,
|
||||||
|
login_token: user.token,
|
||||||
|
cancel: cancel ? 'cancel' : '',
|
||||||
|
}),
|
||||||
|
redirect: 'manual',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDecisionFromResponse(response: Response, user: misskey.entities.MeSignup, { cancel }: { cancel?: boolean } = {}): Promise<Response> {
|
||||||
|
const { transactionId } = getMeta(await response.text());
|
||||||
|
assert.ok(transactionId);
|
||||||
|
|
||||||
|
return await fetchDecision(transactionId, user, { cancel });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAuthorizationCode(user: misskey.entities.MeSignup, scope: string, code_challenge: string): Promise<{ client: AuthorizationCode, code: string }> {
|
||||||
|
const client = new AuthorizationCode(clientConfig);
|
||||||
|
|
||||||
|
const response = await fetch(client.authorizeURL({
|
||||||
|
redirect_uri,
|
||||||
|
scope,
|
||||||
|
state: 'state',
|
||||||
|
code_challenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
} as AuthorizationParamsExtended));
|
||||||
|
assert.strictEqual(response.status, 200);
|
||||||
|
|
||||||
|
const decisionResponse = await fetchDecisionFromResponse(response, user);
|
||||||
|
assert.strictEqual(decisionResponse.status, 302);
|
||||||
|
|
||||||
|
const locationHeader = decisionResponse.headers.get('location');
|
||||||
|
assert.ok(locationHeader);
|
||||||
|
|
||||||
|
const location = new URL(locationHeader);
|
||||||
|
assert.ok(location.searchParams.has('code'));
|
||||||
|
|
||||||
|
const code = new URL(location).searchParams.get('code');
|
||||||
|
assert.ok(code);
|
||||||
|
|
||||||
|
return { client, code };
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertIndirectError(response: Response, error: string): void {
|
||||||
|
assert.strictEqual(response.status, 302);
|
||||||
|
|
||||||
|
const locationHeader = response.headers.get('location');
|
||||||
|
assert.ok(locationHeader);
|
||||||
|
|
||||||
|
const location = new URL(locationHeader);
|
||||||
|
assert.strictEqual(location.searchParams.get('error'), error);
|
||||||
|
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc9207#name-response-parameter-iss
|
||||||
|
assert.strictEqual(location.searchParams.get('iss'), 'http://misskey.local');
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.2.1
|
||||||
|
assert.ok(location.searchParams.has('state'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertDirectError(response: Response, status: number, error: string): Promise<void> {
|
||||||
|
assert.strictEqual(response.status, status);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
assert.strictEqual(data.error, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OAuth', () => {
|
||||||
|
let app: INestApplicationContext;
|
||||||
|
let fastify: FastifyInstance;
|
||||||
|
|
||||||
|
let alice: misskey.entities.MeSignup;
|
||||||
|
let bob: misskey.entities.MeSignup;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await startServer();
|
||||||
|
alice = await signup({ username: 'alice' });
|
||||||
|
bob = await signup({ username: 'bob' });
|
||||||
|
}, 1000 * 60 * 2);
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
process.env.MISSKEY_TEST_CHECK_IP_RANGE = '';
|
||||||
|
fastify = Fastify();
|
||||||
|
fastify.get('/', async (request, reply) => {
|
||||||
|
reply.send(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<link rel="redirect_uri" href="/redirect" />
|
||||||
|
<div class="h-app"><div class="p-name">Misklient
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
await fastify.listen({ port: clientPort });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await fastify.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Full flow', async () => {
|
||||||
|
const { code_challenge, code_verifier } = await pkceChallenge(128);
|
||||||
|
|
||||||
|
const client = new AuthorizationCode(clientConfig);
|
||||||
|
|
||||||
|
const response = await fetch(client.authorizeURL({
|
||||||
|
redirect_uri,
|
||||||
|
scope: 'write:notes',
|
||||||
|
state: 'state',
|
||||||
|
code_challenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
} as AuthorizationParamsExtended));
|
||||||
|
assert.strictEqual(response.status, 200);
|
||||||
|
|
||||||
|
const meta = getMeta(await response.text());
|
||||||
|
assert.strictEqual(typeof meta.transactionId, 'string');
|
||||||
|
assert.ok(meta.transactionId);
|
||||||
|
assert.strictEqual(meta.clientName, 'Misklient');
|
||||||
|
|
||||||
|
const decisionResponse = await fetchDecision(meta.transactionId, alice);
|
||||||
|
assert.strictEqual(decisionResponse.status, 302);
|
||||||
|
assert.ok(decisionResponse.headers.has('location'));
|
||||||
|
|
||||||
|
const locationHeader = decisionResponse.headers.get('location');
|
||||||
|
assert.ok(locationHeader);
|
||||||
|
|
||||||
|
const location = new URL(locationHeader);
|
||||||
|
assert.strictEqual(location.origin + location.pathname, redirect_uri);
|
||||||
|
assert.ok(location.searchParams.has('code'));
|
||||||
|
assert.strictEqual(location.searchParams.get('state'), 'state');
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc9207#name-response-parameter-iss
|
||||||
|
assert.strictEqual(location.searchParams.get('iss'), 'http://misskey.local');
|
||||||
|
|
||||||
|
const code = new URL(location).searchParams.get('code');
|
||||||
|
assert.ok(code);
|
||||||
|
|
||||||
|
const token = await client.getToken({
|
||||||
|
code,
|
||||||
|
redirect_uri,
|
||||||
|
code_verifier,
|
||||||
|
} as AuthorizationTokenConfigExtended);
|
||||||
|
assert.strictEqual(typeof token.token.access_token, 'string');
|
||||||
|
assert.strictEqual(token.token.token_type, 'Bearer');
|
||||||
|
assert.strictEqual(token.token.scope, 'write:notes');
|
||||||
|
|
||||||
|
const createResult = await api('notes/create', { text: 'test' }, {
|
||||||
|
token: token.token.access_token as string,
|
||||||
|
bearer: true,
|
||||||
|
});
|
||||||
|
assert.strictEqual(createResult.status, 200);
|
||||||
|
|
||||||
|
const createResultBody = createResult.body as misskey.Endpoints['notes/create']['res'];
|
||||||
|
assert.strictEqual(createResultBody.createdNote.text, 'test');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Two concurrent flows', async () => {
|
||||||
|
const client = new AuthorizationCode(clientConfig);
|
||||||
|
|
||||||
|
const pkceAlice = await pkceChallenge(128);
|
||||||
|
const pkceBob = await pkceChallenge(128);
|
||||||
|
|
||||||
|
const responseAlice = await fetch(client.authorizeURL({
|
||||||
|
redirect_uri,
|
||||||
|
scope: 'write:notes',
|
||||||
|
state: 'state',
|
||||||
|
code_challenge: pkceAlice.code_challenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
} as AuthorizationParamsExtended));
|
||||||
|
assert.strictEqual(responseAlice.status, 200);
|
||||||
|
|
||||||
|
const responseBob = await fetch(client.authorizeURL({
|
||||||
|
redirect_uri,
|
||||||
|
scope: 'write:notes',
|
||||||
|
state: 'state',
|
||||||
|
code_challenge: pkceBob.code_challenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
} as AuthorizationParamsExtended));
|
||||||
|
assert.strictEqual(responseBob.status, 200);
|
||||||
|
|
||||||
|
const decisionResponseAlice = await fetchDecisionFromResponse(responseAlice, alice);
|
||||||
|
assert.strictEqual(decisionResponseAlice.status, 302);
|
||||||
|
|
||||||
|
const decisionResponseBob = await fetchDecisionFromResponse(responseBob, bob);
|
||||||
|
assert.strictEqual(decisionResponseBob.status, 302);
|
||||||
|
|
||||||
|
const locationHeaderAlice = decisionResponseAlice.headers.get('location');
|
||||||
|
assert.ok(locationHeaderAlice);
|
||||||
|
const locationAlice = new URL(locationHeaderAlice);
|
||||||
|
|
||||||
|
const locationHeaderBob = decisionResponseBob.headers.get('location');
|
||||||
|
assert.ok(locationHeaderBob);
|
||||||
|
const locationBob = new URL(locationHeaderBob);
|
||||||
|
|
||||||
|
const codeAlice = locationAlice.searchParams.get('code');
|
||||||
|
assert.ok(codeAlice);
|
||||||
|
const codeBob = locationBob.searchParams.get('code');
|
||||||
|
assert.ok(codeBob);
|
||||||
|
|
||||||
|
const tokenAlice = await client.getToken({
|
||||||
|
code: codeAlice,
|
||||||
|
redirect_uri,
|
||||||
|
code_verifier: pkceAlice.code_verifier,
|
||||||
|
} as AuthorizationTokenConfigExtended);
|
||||||
|
|
||||||
|
const tokenBob = await client.getToken({
|
||||||
|
code: codeBob,
|
||||||
|
redirect_uri,
|
||||||
|
code_verifier: pkceBob.code_verifier,
|
||||||
|
} as AuthorizationTokenConfigExtended);
|
||||||
|
|
||||||
|
const createResultAlice = await api('notes/create', { text: 'test' }, {
|
||||||
|
token: tokenAlice.token.access_token as string,
|
||||||
|
bearer: true,
|
||||||
|
});
|
||||||
|
assert.strictEqual(createResultAlice.status, 200);
|
||||||
|
|
||||||
|
const createResultBob = await api('notes/create', { text: 'test' }, {
|
||||||
|
token: tokenBob.token.access_token as string,
|
||||||
|
bearer: true,
|
||||||
|
});
|
||||||
|
assert.strictEqual(createResultAlice.status, 200);
|
||||||
|
|
||||||
|
const createResultBodyAlice = await createResultAlice.body as misskey.Endpoints['notes/create']['res'];
|
||||||
|
assert.strictEqual(createResultBodyAlice.createdNote.user.username, 'alice');
|
||||||
|
|
||||||
|
const createResultBodyBob = await createResultBob.body as misskey.Endpoints['notes/create']['res'];
|
||||||
|
assert.strictEqual(createResultBodyBob.createdNote.user.username, 'bob');
|
||||||
|
});
|
||||||
|
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc7636.html
|
||||||
|
describe('PKCE', () => {
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc7636.html#section-4.4.1
|
||||||
|
// '... the authorization endpoint MUST return the authorization
|
||||||
|
// error response with the "error" value set to "invalid_request".'
|
||||||
|
test('Require PKCE', async () => {
|
||||||
|
const client = new AuthorizationCode(clientConfig);
|
||||||
|
|
||||||
|
// Pattern 1: No PKCE fields at all
|
||||||
|
let response = await fetch(client.authorizeURL({
|
||||||
|
redirect_uri,
|
||||||
|
scope: 'write:notes',
|
||||||
|
state: 'state',
|
||||||
|
}), { redirect: 'manual' });
|
||||||
|
assertIndirectError(response, 'invalid_request');
|
||||||
|
|
||||||
|
// Pattern 2: Only code_challenge
|
||||||
|
response = await fetch(client.authorizeURL({
|
||||||
|
redirect_uri,
|
||||||
|
scope: 'write:notes',
|
||||||
|
state: 'state',
|
||||||
|
code_challenge: 'code',
|
||||||
|
} as AuthorizationParamsExtended), { redirect: 'manual' });
|
||||||
|
assertIndirectError(response, 'invalid_request');
|
||||||
|
|
||||||
|
// Pattern 3: Only code_challenge_method
|
||||||
|
response = await fetch(client.authorizeURL({
|
||||||
|
redirect_uri,
|
||||||
|
scope: 'write:notes',
|
||||||
|
state: 'state',
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
} as AuthorizationParamsExtended), { redirect: 'manual' });
|
||||||
|
assertIndirectError(response, 'invalid_request');
|
||||||
|
|
||||||
|
// Pattern 4: Unsupported code_challenge_method
|
||||||
|
response = await fetch(client.authorizeURL({
|
||||||
|
redirect_uri,
|
||||||
|
scope: 'write:notes',
|
||||||
|
state: 'state',
|
||||||
|
code_challenge: 'code',
|
||||||
|
code_challenge_method: 'SSSS',
|
||||||
|
} as AuthorizationParamsExtended), { redirect: 'manual' });
|
||||||
|
assertIndirectError(response, 'invalid_request');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use precomputed challenge/verifier set here for deterministic test
|
||||||
|
const code_challenge = '4w2GDuvaxXlw2l46k5PFIoIcTGHdzw2i3hrn-C_Q6f7u0-nTYKd-beVEYy9XinYsGtAix.Nnvr.GByD3lAii2ibPRsSDrZgIN0YQb.kfevcfR9aDKoTLyOUm4hW4ABhs';
|
||||||
|
const code_verifier = 'Ew8VSBiH59JirLlg7ocFpLQ6NXuFC1W_rn8gmRzBKc8';
|
||||||
|
|
||||||
|
const tests: Record<string, string | undefined> = {
|
||||||
|
'Code followed by some junk code': code_verifier + 'x',
|
||||||
|
'Clipped code': code_verifier.slice(0, 80),
|
||||||
|
'Some part of code is replaced': code_verifier.slice(0, -10) + 'x'.repeat(10),
|
||||||
|
'No verifier': undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Verify PKCE', () => {
|
||||||
|
for (const [title, wrong_verifier] of Object.entries(tests)) {
|
||||||
|
test(title, async () => {
|
||||||
|
const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge);
|
||||||
|
|
||||||
|
await assert.rejects(client.getToken({
|
||||||
|
code,
|
||||||
|
redirect_uri,
|
||||||
|
code_verifier: wrong_verifier,
|
||||||
|
} as AuthorizationTokenConfigExtended), (err: GetTokenError) => {
|
||||||
|
assert.strictEqual(err.data.payload.error, 'invalid_grant');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.2
|
||||||
|
// "If an authorization code is used more than once, the authorization server
|
||||||
|
// MUST deny the request and SHOULD revoke (when possible) all tokens
|
||||||
|
// previously issued based on that authorization code."
|
||||||
|
describe('Revoking authorization code', () => {
|
||||||
|
test('On success', async () => {
|
||||||
|
const { code_challenge, code_verifier } = await pkceChallenge(128);
|
||||||
|
const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge);
|
||||||
|
|
||||||
|
await client.getToken({
|
||||||
|
code,
|
||||||
|
redirect_uri,
|
||||||
|
code_verifier,
|
||||||
|
} as AuthorizationTokenConfigExtended);
|
||||||
|
|
||||||
|
await assert.rejects(client.getToken({
|
||||||
|
code,
|
||||||
|
redirect_uri,
|
||||||
|
code_verifier,
|
||||||
|
} as AuthorizationTokenConfigExtended), (err: GetTokenError) => {
|
||||||
|
assert.strictEqual(err.data.payload.error, 'invalid_grant');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('On failure', async () => {
|
||||||
|
const { code_challenge, code_verifier } = await pkceChallenge(128);
|
||||||
|
const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge);
|
||||||
|
|
||||||
|
await assert.rejects(client.getToken({ code, redirect_uri }), (err: GetTokenError) => {
|
||||||
|
assert.strictEqual(err.data.payload.error, 'invalid_grant');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(client.getToken({
|
||||||
|
code,
|
||||||
|
redirect_uri,
|
||||||
|
code_verifier,
|
||||||
|
} as AuthorizationTokenConfigExtended), (err: GetTokenError) => {
|
||||||
|
assert.strictEqual(err.data.payload.error, 'invalid_grant');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Revoke the already granted access token', async () => {
|
||||||
|
const { code_challenge, code_verifier } = await pkceChallenge(128);
|
||||||
|
const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge);
|
||||||
|
|
||||||
|
const token = await client.getToken({
|
||||||
|
code,
|
||||||
|
redirect_uri,
|
||||||
|
code_verifier,
|
||||||
|
} as AuthorizationTokenConfigExtended);
|
||||||
|
|
||||||
|
const createResult = await api('notes/create', { text: 'test' }, {
|
||||||
|
token: token.token.access_token as string,
|
||||||
|
bearer: true,
|
||||||
|
});
|
||||||
|
assert.strictEqual(createResult.status, 200);
|
||||||
|
|
||||||
|
await assert.rejects(client.getToken({
|
||||||
|
code,
|
||||||
|
redirect_uri,
|
||||||
|
code_verifier,
|
||||||
|
} as AuthorizationTokenConfigExtended), (err: GetTokenError) => {
|
||||||
|
assert.strictEqual(err.data.payload.error, 'invalid_grant');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const createResult2 = await api('notes/create', { text: 'test' }, {
|
||||||
|
token: token.token.access_token as string,
|
||||||
|
bearer: true,
|
||||||
|
});
|
||||||
|
assert.strictEqual(createResult2.status, 401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Cancellation', async () => {
|
||||||
|
const client = new AuthorizationCode(clientConfig);
|
||||||
|
|
||||||
|
const response = await fetch(client.authorizeURL({
|
||||||
|
redirect_uri,
|
||||||
|
scope: 'write:notes',
|
||||||
|
state: 'state',
|
||||||
|
code_challenge: 'code',
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
} as AuthorizationParamsExtended));
|
||||||
|
assert.strictEqual(response.status, 200);
|
||||||
|
|
||||||
|
const decisionResponse = await fetchDecisionFromResponse(response, alice, { cancel: true });
|
||||||
|
assert.strictEqual(decisionResponse.status, 302);
|
||||||
|
|
||||||
|
const locationHeader = decisionResponse.headers.get('location');
|
||||||
|
assert.ok(locationHeader);
|
||||||
|
|
||||||
|
const location = new URL(locationHeader);
|
||||||
|
assert.ok(!location.searchParams.has('code'));
|
||||||
|
assert.ok(location.searchParams.has('error'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc6749.html#section-3.3
|
||||||
|
describe('Scope', () => {
|
||||||
|
// "If the client omits the scope parameter when requesting
|
||||||
|
// authorization, the authorization server MUST either process the
|
||||||
|
// request using a pre-defined default value or fail the request
|
||||||
|
// indicating an invalid scope."
|
||||||
|
// (And Misskey does the latter)
|
||||||
|
test('Missing scope', async () => {
|
||||||
|
const client = new AuthorizationCode(clientConfig);
|
||||||
|
|
||||||
|
const response = await fetch(client.authorizeURL({
|
||||||
|
redirect_uri,
|
||||||
|
state: 'state',
|
||||||
|
code_challenge: 'code',
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
} as AuthorizationParamsExtended), { redirect: 'manual' });
|
||||||
|
assertIndirectError(response, 'invalid_scope');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Empty scope', async () => {
|
||||||
|
const client = new AuthorizationCode(clientConfig);
|
||||||
|
|
||||||
|
const response = await fetch(client.authorizeURL({
|
||||||
|
redirect_uri,
|
||||||
|
scope: '',
|
||||||
|
state: 'state',
|
||||||
|
code_challenge: 'code',
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
} as AuthorizationParamsExtended), { redirect: 'manual' });
|
||||||
|
assertIndirectError(response, 'invalid_scope');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Unknown scopes', async () => {
|
||||||
|
const client = new AuthorizationCode(clientConfig);
|
||||||
|
|
||||||
|
const response = await fetch(client.authorizeURL({
|
||||||
|
redirect_uri,
|
||||||
|
scope: 'test:unknown test:unknown2',
|
||||||
|
state: 'state',
|
||||||
|
code_challenge: 'code',
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
} as AuthorizationParamsExtended), { redirect: 'manual' });
|
||||||
|
assertIndirectError(response, 'invalid_scope');
|
||||||
|
});
|
||||||
|
|
||||||
|
// "If the issued access token scope
|
||||||
|
// is different from the one requested by the client, the authorization
|
||||||
|
// server MUST include the "scope" response parameter to inform the
|
||||||
|
// client of the actual scope granted."
|
||||||
|
// (Although Misskey always return scope, which is also fine)
|
||||||
|
test('Partially known scopes', async () => {
|
||||||
|
const { code_challenge, code_verifier } = await pkceChallenge(128);
|
||||||
|
|
||||||
|
// Just get the known scope for this case for backward compatibility
|
||||||
|
const { client, code } = await fetchAuthorizationCode(
|
||||||
|
alice,
|
||||||
|
'write:notes test:unknown test:unknown2',
|
||||||
|
code_challenge,
|
||||||
|
);
|
||||||
|
|
||||||
|
const token = await client.getToken({
|
||||||
|
code,
|
||||||
|
redirect_uri,
|
||||||
|
code_verifier,
|
||||||
|
} as AuthorizationTokenConfigExtended);
|
||||||
|
|
||||||
|
assert.strictEqual(token.token.scope, 'write:notes');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Known scopes', async () => {
|
||||||
|
const client = new AuthorizationCode(clientConfig);
|
||||||
|
|
||||||
|
const response = await fetch(client.authorizeURL({
|
||||||
|
redirect_uri,
|
||||||
|
scope: 'write:notes read:account',
|
||||||
|
state: 'state',
|
||||||
|
code_challenge: 'code',
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
} as AuthorizationParamsExtended));
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Duplicated scopes', async () => {
|
||||||
|
const { code_challenge, code_verifier } = await pkceChallenge(128);
|
||||||
|
|
||||||
|
const { client, code } = await fetchAuthorizationCode(
|
||||||
|
alice,
|
||||||
|
'write:notes write:notes read:account read:account',
|
||||||
|
code_challenge,
|
||||||
|
);
|
||||||
|
|
||||||
|
const token = await client.getToken({
|
||||||
|
code,
|
||||||
|
redirect_uri,
|
||||||
|
code_verifier,
|
||||||
|
} as AuthorizationTokenConfigExtended);
|
||||||
|
assert.strictEqual(token.token.scope, 'write:notes read:account');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Scope check by API', async () => {
|
||||||
|
const { code_challenge, code_verifier } = await pkceChallenge(128);
|
||||||
|
|
||||||
|
const { client, code } = await fetchAuthorizationCode(alice, 'read:account', code_challenge);
|
||||||
|
|
||||||
|
const token = await client.getToken({
|
||||||
|
code,
|
||||||
|
redirect_uri,
|
||||||
|
code_verifier,
|
||||||
|
} as AuthorizationTokenConfigExtended);
|
||||||
|
assert.strictEqual(typeof token.token.access_token, 'string');
|
||||||
|
|
||||||
|
const createResult = await api('notes/create', { text: 'test' }, {
|
||||||
|
token: token.token.access_token as string,
|
||||||
|
bearer: true,
|
||||||
|
});
|
||||||
|
assert.strictEqual(createResult.status, 403);
|
||||||
|
assert.ok(createResult.headers.get('WWW-Authenticate')?.startsWith('Bearer realm="Misskey", error="insufficient_scope", error_description'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc6749.html#section-3.1.2.4
|
||||||
|
// "If an authorization request fails validation due to a missing,
|
||||||
|
// invalid, or mismatching redirection URI, the authorization server
|
||||||
|
// SHOULD inform the resource owner of the error and MUST NOT
|
||||||
|
// automatically redirect the user-agent to the invalid redirection URI."
|
||||||
|
describe('Redirection', () => {
|
||||||
|
test('Invalid redirect_uri at authorization endpoint', async () => {
|
||||||
|
const client = new AuthorizationCode(clientConfig);
|
||||||
|
|
||||||
|
const response = await fetch(client.authorizeURL({
|
||||||
|
redirect_uri: 'http://127.0.0.2/',
|
||||||
|
scope: 'write:notes',
|
||||||
|
state: 'state',
|
||||||
|
code_challenge: 'code',
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
} as AuthorizationParamsExtended));
|
||||||
|
await assertDirectError(response, 400, 'invalid_request');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Invalid redirect_uri including the valid one at authorization endpoint', async () => {
|
||||||
|
const client = new AuthorizationCode(clientConfig);
|
||||||
|
|
||||||
|
const response = await fetch(client.authorizeURL({
|
||||||
|
redirect_uri: 'http://127.0.0.1/redirection',
|
||||||
|
scope: 'write:notes',
|
||||||
|
state: 'state',
|
||||||
|
code_challenge: 'code',
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
} as AuthorizationParamsExtended));
|
||||||
|
await assertDirectError(response, 400, 'invalid_request');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('No redirect_uri at authorization endpoint', async () => {
|
||||||
|
const client = new AuthorizationCode(clientConfig);
|
||||||
|
|
||||||
|
const response = await fetch(client.authorizeURL({
|
||||||
|
scope: 'write:notes',
|
||||||
|
state: 'state',
|
||||||
|
code_challenge: 'code',
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
} as AuthorizationParamsExtended));
|
||||||
|
await assertDirectError(response, 400, 'invalid_request');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Invalid redirect_uri at token endpoint', async () => {
|
||||||
|
const { code_challenge, code_verifier } = await pkceChallenge(128);
|
||||||
|
|
||||||
|
const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge);
|
||||||
|
|
||||||
|
await assert.rejects(client.getToken({
|
||||||
|
code,
|
||||||
|
redirect_uri: 'http://127.0.0.2/',
|
||||||
|
code_verifier,
|
||||||
|
} as AuthorizationTokenConfigExtended), (err: GetTokenError) => {
|
||||||
|
assert.strictEqual(err.data.payload.error, 'invalid_grant');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Invalid redirect_uri including the valid one at token endpoint', async () => {
|
||||||
|
const { code_challenge, code_verifier } = await pkceChallenge(128);
|
||||||
|
|
||||||
|
const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge);
|
||||||
|
|
||||||
|
await assert.rejects(client.getToken({
|
||||||
|
code,
|
||||||
|
redirect_uri: 'http://127.0.0.1/redirection',
|
||||||
|
code_verifier,
|
||||||
|
} as AuthorizationTokenConfigExtended), (err: GetTokenError) => {
|
||||||
|
assert.strictEqual(err.data.payload.error, 'invalid_grant');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('No redirect_uri at token endpoint', async () => {
|
||||||
|
const { code_challenge, code_verifier } = await pkceChallenge(128);
|
||||||
|
|
||||||
|
const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge);
|
||||||
|
|
||||||
|
await assert.rejects(client.getToken({
|
||||||
|
code,
|
||||||
|
code_verifier,
|
||||||
|
} as AuthorizationTokenConfigExtended), (err: GetTokenError) => {
|
||||||
|
assert.strictEqual(err.data.payload.error, 'invalid_grant');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc8414
|
||||||
|
test('Server metadata', async () => {
|
||||||
|
const response = await fetch(new URL('.well-known/oauth-authorization-server', host));
|
||||||
|
assert.strictEqual(response.status, 200);
|
||||||
|
|
||||||
|
const body = await response.json();
|
||||||
|
assert.strictEqual(body.issuer, 'http://misskey.local');
|
||||||
|
assert.ok(body.scopes_supported.includes('write:notes'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Any error on decision endpoint is solely on Misskey side and nothing to do with the client.
|
||||||
|
// Do not use indirect error here.
|
||||||
|
describe('Decision endpoint', () => {
|
||||||
|
test('No login token', async () => {
|
||||||
|
const client = new AuthorizationCode(clientConfig);
|
||||||
|
|
||||||
|
const response = await fetch(client.authorizeURL(basicAuthParams));
|
||||||
|
assert.strictEqual(response.status, 200);
|
||||||
|
|
||||||
|
const { transactionId } = getMeta(await response.text());
|
||||||
|
assert.ok(transactionId);
|
||||||
|
|
||||||
|
const decisionResponse = await fetch(new URL('/oauth/decision', host), {
|
||||||
|
method: 'post',
|
||||||
|
body: new URLSearchParams({
|
||||||
|
transaction_id: transactionId,
|
||||||
|
}),
|
||||||
|
redirect: 'manual',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await assertDirectError(decisionResponse, 400, 'invalid_request');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('No transaction ID', async () => {
|
||||||
|
const decisionResponse = await fetch(new URL('/oauth/decision', host), {
|
||||||
|
method: 'post',
|
||||||
|
body: new URLSearchParams({
|
||||||
|
login_token: alice.token,
|
||||||
|
}),
|
||||||
|
redirect: 'manual',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await assertDirectError(decisionResponse, 400, 'invalid_request');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Invalid transaction ID', async () => {
|
||||||
|
const decisionResponse = await fetch(new URL('/oauth/decision', host), {
|
||||||
|
method: 'post',
|
||||||
|
body: new URLSearchParams({
|
||||||
|
login_token: alice.token,
|
||||||
|
transaction_id: 'invalid_id',
|
||||||
|
}),
|
||||||
|
redirect: 'manual',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await assertDirectError(decisionResponse, 403, 'access_denied');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only authorization code grant is supported
|
||||||
|
describe('Grant type', () => {
|
||||||
|
test('Implicit grant is not supported', async () => {
|
||||||
|
const url = new URL('/oauth/authorize', host);
|
||||||
|
url.searchParams.append('response_type', 'token');
|
||||||
|
const response = await fetch(url);
|
||||||
|
assertDirectError(response, 501, 'unsupported_response_type');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Resource owner grant is not supported', async () => {
|
||||||
|
const client = new ResourceOwnerPassword({
|
||||||
|
...clientConfig,
|
||||||
|
auth: {
|
||||||
|
tokenHost: host,
|
||||||
|
tokenPath: '/oauth/token',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(client.getToken({
|
||||||
|
username: 'alice',
|
||||||
|
password: 'test',
|
||||||
|
}), (err: GetTokenError) => {
|
||||||
|
assert.strictEqual(err.data.payload.error, 'unsupported_grant_type');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Client credential grant is not supported', async () => {
|
||||||
|
const client = new ClientCredentials({
|
||||||
|
...clientConfig,
|
||||||
|
auth: {
|
||||||
|
tokenHost: host,
|
||||||
|
tokenPath: '/oauth/token',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(client.getToken({}), (err: GetTokenError) => {
|
||||||
|
assert.strictEqual(err.data.payload.error, 'unsupported_grant_type');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// https://indieauth.spec.indieweb.org/#client-information-discovery
|
||||||
|
describe('Client Information Discovery', () => {
|
||||||
|
describe('Redirection', () => {
|
||||||
|
const tests: Record<string, (reply: FastifyReply) => void> = {
|
||||||
|
'Read HTTP header': reply => {
|
||||||
|
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||||
|
reply.send(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<div class="h-app"><div class="p-name">Misklient
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
'Mixed links': reply => {
|
||||||
|
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||||
|
reply.send(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<link rel="redirect_uri" href="/redirect2" />
|
||||||
|
<div class="h-app"><div class="p-name">Misklient
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
'Multiple items in Link header': reply => {
|
||||||
|
reply.header('Link', '</redirect2>; rel="redirect_uri",</redirect>; rel="redirect_uri"');
|
||||||
|
reply.send(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<div class="h-app"><div class="p-name">Misklient
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
'Multiple items in HTML': reply => {
|
||||||
|
reply.send(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<link rel="redirect_uri" href="/redirect2" />
|
||||||
|
<link rel="redirect_uri" href="/redirect" />
|
||||||
|
<div class="h-app"><div class="p-name">Misklient
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [title, replyFunc] of Object.entries(tests)) {
|
||||||
|
test(title, async () => {
|
||||||
|
await fastify.close();
|
||||||
|
|
||||||
|
fastify = Fastify();
|
||||||
|
fastify.get('/', async (request, reply) => replyFunc(reply));
|
||||||
|
await fastify.listen({ port: clientPort });
|
||||||
|
|
||||||
|
const client = new AuthorizationCode(clientConfig);
|
||||||
|
|
||||||
|
const response = await fetch(client.authorizeURL({
|
||||||
|
redirect_uri,
|
||||||
|
scope: 'write:notes',
|
||||||
|
state: 'state',
|
||||||
|
code_challenge: 'code',
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
} as AuthorizationParamsExtended));
|
||||||
|
assert.strictEqual(response.status, 200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('No item', async () => {
|
||||||
|
await fastify.close();
|
||||||
|
|
||||||
|
fastify = Fastify();
|
||||||
|
fastify.get('/', async (request, reply) => {
|
||||||
|
reply.send(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<div class="h-app"><div class="p-name">Misklient
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
await fastify.listen({ port: clientPort });
|
||||||
|
|
||||||
|
const client = new AuthorizationCode(clientConfig);
|
||||||
|
|
||||||
|
const response = await fetch(client.authorizeURL({
|
||||||
|
redirect_uri,
|
||||||
|
scope: 'write:notes',
|
||||||
|
state: 'state',
|
||||||
|
code_challenge: 'code',
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
} as AuthorizationParamsExtended));
|
||||||
|
|
||||||
|
// direct error because there's no redirect URI to ping
|
||||||
|
await assertDirectError(response, 400, 'invalid_request');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Disallow loopback', async () => {
|
||||||
|
process.env.MISSKEY_TEST_CHECK_IP_RANGE = '1';
|
||||||
|
|
||||||
|
const client = new AuthorizationCode(clientConfig);
|
||||||
|
const response = await fetch(client.authorizeURL({
|
||||||
|
redirect_uri,
|
||||||
|
scope: 'write:notes',
|
||||||
|
state: 'state',
|
||||||
|
code_challenge: 'code',
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
} as AuthorizationParamsExtended));
|
||||||
|
await assertDirectError(response, 400, 'invalid_request');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Missing name', async () => {
|
||||||
|
await fastify.close();
|
||||||
|
|
||||||
|
fastify = Fastify();
|
||||||
|
fastify.get('/', async (request, reply) => {
|
||||||
|
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||||
|
reply.send();
|
||||||
|
});
|
||||||
|
await fastify.listen({ port: clientPort });
|
||||||
|
|
||||||
|
const client = new AuthorizationCode(clientConfig);
|
||||||
|
|
||||||
|
const response = await fetch(client.authorizeURL({
|
||||||
|
redirect_uri,
|
||||||
|
scope: 'write:notes',
|
||||||
|
state: 'state',
|
||||||
|
code_challenge: 'code',
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
} as AuthorizationParamsExtended));
|
||||||
|
assert.strictEqual(response.status, 200);
|
||||||
|
assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Unknown OAuth endpoint', async () => {
|
||||||
|
const response = await fetch(new URL('/oauth/foo', host));
|
||||||
|
assert.strictEqual(response.status, 404);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,14 +3,15 @@ process.env.NODE_ENV = 'test';
|
|||||||
import * as assert from 'assert';
|
import * as assert from 'assert';
|
||||||
import { signup, api, post, react, startServer, waitFire } from '../utils.js';
|
import { signup, api, post, react, startServer, waitFire } from '../utils.js';
|
||||||
import type { INestApplicationContext } from '@nestjs/common';
|
import type { INestApplicationContext } from '@nestjs/common';
|
||||||
|
import type * as misskey from 'misskey-js';
|
||||||
|
|
||||||
describe('Renote Mute', () => {
|
describe('Renote Mute', () => {
|
||||||
let app: INestApplicationContext;
|
let app: INestApplicationContext;
|
||||||
|
|
||||||
// alice mutes carol
|
// alice mutes carol
|
||||||
let alice: any;
|
let alice: misskey.entities.MeSignup;
|
||||||
let bob: any;
|
let bob: misskey.entities.MeSignup;
|
||||||
let carol: any;
|
let carol: misskey.entities.MeSignup;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await startServer();
|
app = await startServer();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as assert from 'assert';
|
|||||||
import { Following } from '@/models/entities/Following.js';
|
import { Following } from '@/models/entities/Following.js';
|
||||||
import { connectStream, signup, api, post, startServer, initTestDb, waitFire } from '../utils.js';
|
import { connectStream, signup, api, post, startServer, initTestDb, waitFire } from '../utils.js';
|
||||||
import type { INestApplicationContext } from '@nestjs/common';
|
import type { INestApplicationContext } from '@nestjs/common';
|
||||||
|
import type * as misskey from 'misskey-js';
|
||||||
|
|
||||||
describe('Streaming', () => {
|
describe('Streaming', () => {
|
||||||
let app: INestApplicationContext;
|
let app: INestApplicationContext;
|
||||||
@@ -26,13 +27,13 @@ describe('Streaming', () => {
|
|||||||
|
|
||||||
describe('Streaming', () => {
|
describe('Streaming', () => {
|
||||||
// Local users
|
// Local users
|
||||||
let ayano: any;
|
let ayano: misskey.entities.MeSignup;
|
||||||
let kyoko: any;
|
let kyoko: misskey.entities.MeSignup;
|
||||||
let chitose: any;
|
let chitose: misskey.entities.MeSignup;
|
||||||
|
|
||||||
// Remote users
|
// Remote users
|
||||||
let akari: any;
|
let akari: misskey.entities.MeSignup;
|
||||||
let chinatsu: any;
|
let chinatsu: misskey.entities.MeSignup;
|
||||||
|
|
||||||
let kyokoNote: any;
|
let kyokoNote: any;
|
||||||
let list: any;
|
let list: any;
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ process.env.NODE_ENV = 'test';
|
|||||||
import * as assert from 'assert';
|
import * as assert from 'assert';
|
||||||
import { signup, api, post, connectStream, startServer } from '../utils.js';
|
import { signup, api, post, connectStream, startServer } from '../utils.js';
|
||||||
import type { INestApplicationContext } from '@nestjs/common';
|
import type { INestApplicationContext } from '@nestjs/common';
|
||||||
|
import type * as misskey from 'misskey-js';
|
||||||
|
|
||||||
describe('Note thread mute', () => {
|
describe('Note thread mute', () => {
|
||||||
let app: INestApplicationContext;
|
let app: INestApplicationContext;
|
||||||
|
|
||||||
let alice: any;
|
let alice: misskey.entities.MeSignup;
|
||||||
let bob: any;
|
let bob: misskey.entities.MeSignup;
|
||||||
let carol: any;
|
let carol: misskey.entities.MeSignup;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await startServer();
|
app = await startServer();
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ process.env.NODE_ENV = 'test';
|
|||||||
import * as assert from 'assert';
|
import * as assert from 'assert';
|
||||||
import { signup, api, post, uploadUrl, startServer } from '../utils.js';
|
import { signup, api, post, uploadUrl, startServer } from '../utils.js';
|
||||||
import type { INestApplicationContext } from '@nestjs/common';
|
import type { INestApplicationContext } from '@nestjs/common';
|
||||||
|
import type * as misskey from 'misskey-js';
|
||||||
|
|
||||||
describe('users/notes', () => {
|
describe('users/notes', () => {
|
||||||
let app: INestApplicationContext;
|
let app: INestApplicationContext;
|
||||||
|
|
||||||
let alice: any;
|
let alice: misskey.entities.MeSignup;
|
||||||
let jpgNote: any;
|
let jpgNote: any;
|
||||||
let pngNote: any;
|
let pngNote: any;
|
||||||
let jpgPngNote: any;
|
let jpgPngNote: any;
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"declaration": false,
|
"declaration": false,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"target": "es2021",
|
"target": "ES2022",
|
||||||
"module": "es2020",
|
"module": "es2020",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node16",
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"removeComments": false,
|
"removeComments": false,
|
||||||
"noLib": false,
|
"noLib": false,
|
||||||
@@ -39,6 +39,6 @@
|
|||||||
"include": [
|
"include": [
|
||||||
"./**/*.ts",
|
"./**/*.ts",
|
||||||
"../src/**/*.test.ts",
|
"../src/**/*.test.ts",
|
||||||
"../src/@types/**/*.ts",
|
"../src/@types/**/*.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { jest } from '@jest/globals';
|
|||||||
import { ModuleMocker } from 'jest-mock';
|
import { ModuleMocker } from 'jest-mock';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import * as lolex from '@sinonjs/fake-timers';
|
import * as lolex from '@sinonjs/fake-timers';
|
||||||
import rndstr from 'rndstr';
|
|
||||||
import { GlobalModule } from '@/GlobalModule.js';
|
import { GlobalModule } from '@/GlobalModule.js';
|
||||||
import { RoleService } from '@/core/RoleService.js';
|
import { RoleService } from '@/core/RoleService.js';
|
||||||
import type { Role, RolesRepository, RoleAssignmentsRepository, UsersRepository, User } from '@/models/index.js';
|
import type { Role, RolesRepository, RoleAssignmentsRepository, UsersRepository, User } from '@/models/index.js';
|
||||||
@@ -14,6 +13,7 @@ import { genAid } from '@/misc/id/aid.js';
|
|||||||
import { CacheService } from '@/core/CacheService.js';
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
|
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||||
import { sleep } from '../utils.js';
|
import { sleep } from '../utils.js';
|
||||||
import type { TestingModule } from '@nestjs/testing';
|
import type { TestingModule } from '@nestjs/testing';
|
||||||
import type { MockFunctionMetadata } from 'jest-mock';
|
import type { MockFunctionMetadata } from 'jest-mock';
|
||||||
@@ -30,7 +30,7 @@ describe('RoleService', () => {
|
|||||||
let clock: lolex.InstalledClock;
|
let clock: lolex.InstalledClock;
|
||||||
|
|
||||||
function createUser(data: Partial<User> = {}) {
|
function createUser(data: Partial<User> = {}) {
|
||||||
const un = rndstr('a-z0-9', 16);
|
const un = secureRndstr(16);
|
||||||
return usersRepository.insert({
|
return usersRepository.insert({
|
||||||
id: genAid(new Date()),
|
id: genAid(new Date()),
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user