Compare commits
124 Commits
13.13.1
...
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 |
4
.github/ISSUE_TEMPLATE/01_bug-report.md
vendored
4
.github/ISSUE_TEMPLATE/01_bug-report.md
vendored
@@ -46,8 +46,10 @@ Please include errors from the developer console and/or server log files if you
|
||||
<!-- Example: Chrome 113.0.5672.126 -->
|
||||
* Server URL:
|
||||
<!-- Example: misskey.io -->
|
||||
* Misskey:
|
||||
13.x.x
|
||||
|
||||
### 🛰 Backend (for instance admin)
|
||||
### 🛰 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 -->
|
||||
|
21
CHANGELOG.md
21
CHANGELOG.md
@@ -12,6 +12,24 @@
|
||||
|
||||
-->
|
||||
|
||||
## 13.x.x (unreleased)
|
||||
|
||||
### Client
|
||||
- Fix: サーバーメトリクスが90度傾いている
|
||||
|
||||
## 13.13.2
|
||||
|
||||
### General
|
||||
- エラー時や項目が存在しないときなどのアイコン画像をサーバー管理者が設定できるように
|
||||
- ロールが付与されているユーザーリストを非公開にできるように
|
||||
- サーバーの負荷が非常に高いため、ユーザー統計表示機能を削除しました
|
||||
|
||||
### Client
|
||||
- Fix: タブがバックグラウンドでもstreamが切断されないように
|
||||
|
||||
### Server
|
||||
- Fix: キャッシュが溜まり続けないように
|
||||
|
||||
## 13.13.1
|
||||
|
||||
### Client
|
||||
@@ -96,11 +114,12 @@ Meilisearchの設定に`index`が必要になりました。値はMisskeyサー
|
||||
## 13.12.0
|
||||
|
||||
### NOTE
|
||||
- Node.js 18.6.0以上が必要になりました
|
||||
- Node.js 18.16.0以上が必要になりました
|
||||
|
||||
### General
|
||||
- アカウントの引っ越し(フォロワー引き継ぎ)に対応
|
||||
- Meilisearchを全文検索に使用できるようになりました
|
||||
* 「フォロワーのみ」の投稿は検索結果に表示されません。
|
||||
- 新規登録前に簡潔なルールをユーザーに表示できる、サーバールール機能を追加
|
||||
- ユーザーへの自分用メモ機能
|
||||
* ユーザーに対して、自分だけが見られるメモを追加できるようになりました。
|
||||
|
@@ -991,7 +991,7 @@ postToTheChannel: "In Kanal senden"
|
||||
cannotBeChangedLater: "Kann später nicht mehr geändert werden."
|
||||
reactionAcceptance: "Reaktionsannahme"
|
||||
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"
|
||||
nonSensitiveOnlyForLocalLikeOnlyForRemote: "Keine Sensitiven (Nur \"Gefällt mir\" von fremden Instanzen)"
|
||||
rolesAssignedToMe: "Mir zugewiesene Rollen"
|
||||
@@ -1062,6 +1062,7 @@ later: "Später"
|
||||
goToMisskey: "Zu Misskey"
|
||||
additionalEmojiDictionary: "Zusätzliche Emoji-Wörterbücher"
|
||||
installed: "Installiert"
|
||||
branding: "Branding"
|
||||
_initialAccountSetting:
|
||||
accountCreated: "Dein Konto wurde erfolgreich erstellt!"
|
||||
letsStartAccountSetup: "Lass uns nun dein Konto einrichten."
|
||||
@@ -1093,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."
|
||||
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."
|
||||
movedTo: "Umzugsziel:"
|
||||
movedTo: "Neues Konto:"
|
||||
_achievements:
|
||||
earnedAt: "Freigeschaltet am"
|
||||
_types:
|
||||
@@ -1347,7 +1348,7 @@ _role:
|
||||
condition: "Bedingung"
|
||||
isConditionalRole: "Dies ist eine konditionale 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"
|
||||
policies: "Richtlinien"
|
||||
baseRole: "Rollenvorlage"
|
||||
@@ -1356,8 +1357,8 @@ _role:
|
||||
iconUrl: "Icon-URL"
|
||||
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."
|
||||
isExplorable: "Rollenchronik 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."
|
||||
isExplorable: "Benutzerliste veröffentlichen"
|
||||
descriptionOfIsExplorable: "Ist dies aktiviert, so ist die Chronik dieser Rolle, sowie eine Liste der Benutzer mit dieser Rolle, frei zugänglich."
|
||||
displayOrder: "Position"
|
||||
descriptionOfDisplayOrder: "Je höher die Nummer, desto höher die UI-Position."
|
||||
canEditMembersByModerator: "Moderatoren können Benutzern diese Rolle zuweisen"
|
||||
|
@@ -991,7 +991,7 @@ postToTheChannel: "Post to channel"
|
||||
cannotBeChangedLater: "This cannot be changed later."
|
||||
reactionAcceptance: "Reaction Acceptance"
|
||||
likeOnly: "Only likes"
|
||||
likeOnlyForRemote: "Only likes for remote instances"
|
||||
likeOnlyForRemote: "All (Only likes for remote instances)"
|
||||
nonSensitiveOnly: "Non-sensitive only"
|
||||
nonSensitiveOnlyForLocalLikeOnlyForRemote: "Non-sensitive only (Only likes from remote)"
|
||||
rolesAssignedToMe: "Roles assigned to me"
|
||||
@@ -1062,6 +1062,7 @@ later: "Later"
|
||||
goToMisskey: "To Misskey"
|
||||
additionalEmojiDictionary: "Additional emoji dictionaries"
|
||||
installed: "Installed"
|
||||
branding: "Branding"
|
||||
_initialAccountSetting:
|
||||
accountCreated: "Your account was successfully created!"
|
||||
letsStartAccountSetup: "For starters, let's set up your profile."
|
||||
@@ -1093,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."
|
||||
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."
|
||||
movedTo: "Account to move to:"
|
||||
movedTo: "New account:"
|
||||
_achievements:
|
||||
earnedAt: "Unlocked at"
|
||||
_types:
|
||||
@@ -1347,7 +1348,7 @@ _role:
|
||||
condition: "Condition"
|
||||
isConditionalRole: "This is a conditional 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"
|
||||
policies: "Policies"
|
||||
baseRole: "Role template"
|
||||
@@ -1356,8 +1357,8 @@ _role:
|
||||
iconUrl: "Icon URL"
|
||||
asBadge: "Show as badge"
|
||||
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"
|
||||
descriptionOfIsExplorable: "This role's timeline will become publicly accessible if enabled. Timelines of non-public roles will not be made public even if set."
|
||||
isExplorable: "Make role explorable"
|
||||
descriptionOfIsExplorable: "This role's timeline and the list of users with this will be made public if enabled."
|
||||
displayOrder: "Position"
|
||||
descriptionOfDisplayOrder: "The higher the number, the higher its UI position."
|
||||
canEditMembersByModerator: "Allow moderators to edit the list of members for this role"
|
||||
|
1
locales/index.d.ts
vendored
1
locales/index.d.ts
vendored
@@ -1065,6 +1065,7 @@ export interface Locale {
|
||||
"goToMisskey": string;
|
||||
"additionalEmojiDictionary": string;
|
||||
"installed": string;
|
||||
"branding": string;
|
||||
"_initialAccountSetting": {
|
||||
"accountCreated": string;
|
||||
"letsStartAccountSetup": string;
|
||||
|
@@ -1062,6 +1062,7 @@ later: "あとで"
|
||||
goToMisskey: "Misskeyへ"
|
||||
additionalEmojiDictionary: "絵文字の追加辞書"
|
||||
installed: "インストール済み"
|
||||
branding: "ブランディング"
|
||||
|
||||
_initialAccountSetting:
|
||||
accountCreated: "アカウントの作成が完了しました!"
|
||||
@@ -1351,8 +1352,8 @@ _role:
|
||||
conditional: "コンディショナル"
|
||||
condition: "条件"
|
||||
isConditionalRole: "これはコンディショナルロールです。"
|
||||
isPublic: "ロールを公開"
|
||||
descriptionOfIsPublic: "ロールにアサインされたユーザーを誰でも見ることができます。また、ユーザーのプロフィールでこのロールが表示されます。"
|
||||
isPublic: "公開ロール"
|
||||
descriptionOfIsPublic: "ユーザーのプロフィールでこのロールが表示されます。"
|
||||
options: "オプション"
|
||||
policies: "ポリシー"
|
||||
baseRole: "ベースロール"
|
||||
@@ -1361,8 +1362,8 @@ _role:
|
||||
iconUrl: "アイコン画像のURL"
|
||||
asBadge: "バッジとして表示"
|
||||
descriptionOfAsBadge: "オンにすると、ユーザー名の横にロールのアイコンが表示されます。"
|
||||
isExplorable: "ロールタイムラインを公開"
|
||||
descriptionOfIsExplorable: "オンにすると、ロールのタイムラインを公開します。ロールの公開がオフの場合、タイムラインの公開はされません。"
|
||||
isExplorable: "ユーザーを見つけやすくする"
|
||||
descriptionOfIsExplorable: "オンにすると、「みつける」でメンバー一覧が公開されるほか、ロールのタイムラインが利用可能になります。"
|
||||
displayOrder: "表示順"
|
||||
descriptionOfDisplayOrder: "数値が大きいほどUI上で先頭に表示されます。"
|
||||
canEditMembersByModerator: "モデレーターのメンバー編集を許可"
|
||||
|
@@ -1,6 +1,7 @@
|
||||
---
|
||||
_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🚀."
|
||||
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"
|
||||
search: "Arama"
|
||||
notifications: "Bildirim"
|
||||
@@ -13,7 +14,9 @@ cancel: "İptal"
|
||||
enterUsername: "Kullanıcı adınızı giriniz"
|
||||
noNotes: "Notlar mevcut değil."
|
||||
noNotifications: "Bildirim bulunmuyor"
|
||||
instance: "Sunucu"
|
||||
settings: "Ayarlar"
|
||||
notificationSettings: "Bildirim Ayarları"
|
||||
basicSettings: "Temel Ayarlar"
|
||||
otherSettings: "Diğer Ayarlar"
|
||||
openInWindow: "Bir pencere ile aç"
|
||||
@@ -21,9 +24,11 @@ profile: "Profil"
|
||||
timeline: "Zaman çizelgesi"
|
||||
noAccountDescription: "Bu kullanıcı henüz biyografisini yazmadı"
|
||||
login: "Giriş Yap "
|
||||
loggingIn: "Oturum aç"
|
||||
logout: "Çıkış Yap"
|
||||
signup: "Kayıt Ol"
|
||||
uploading: "Yükleniyor"
|
||||
save: "Kaydet"
|
||||
users: "Kullanıcı"
|
||||
addUser: "Kullanıcı Ekle"
|
||||
favorite: "Favoriler"
|
||||
@@ -31,6 +36,7 @@ favorites: "Favoriler"
|
||||
unfavorite: "Favorilerden Kaldır"
|
||||
favorited: "Favorilerime eklendi."
|
||||
alreadyFavorited: "Zaten favorilerinizde kayıtlı."
|
||||
cantFavorite: "Favorilere kayıt yapılamadı"
|
||||
pin: "Sabitlenmiş"
|
||||
unpin: "Sabitlemeyi kaldır"
|
||||
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."
|
||||
addToList: "Listeye ekle"
|
||||
sendMessage: "Mesaj Gönder"
|
||||
copyRSS: "RSSKopyala"
|
||||
copyUsername: "Kullanıcı Adını Kopyala"
|
||||
copyUserId: "KullanıcıyıKopyala"
|
||||
copyNoteId: "Kimlik notunu kopyala"
|
||||
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ş"
|
||||
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"
|
||||
pinnedNotes: "Sabitlenen"
|
||||
userList: "Listeler"
|
||||
smtpUser: "Kullanıcı Adı"
|
||||
smtpPass: "Şifre"
|
||||
user: "Kullanıcı"
|
||||
searchByGoogle: "Arama"
|
||||
_theme:
|
||||
keys:
|
||||
renote: "vazgeçme"
|
||||
_sfx:
|
||||
note: "notlar"
|
||||
notification: "Bildirim"
|
||||
_widgets:
|
||||
profile: "Profil"
|
||||
notifications: "Bildirim"
|
||||
timeline: "Zaman çizelgesi"
|
||||
_cw:
|
||||
show: "Devamını yükle"
|
||||
_visibility:
|
||||
followers: "takipçi"
|
||||
_profile:
|
||||
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:
|
||||
_columns:
|
||||
notifications: "Bildirim"
|
||||
tl: "Zaman çizelgesi"
|
||||
list: "Listeler"
|
||||
|
@@ -1060,6 +1060,7 @@ cancelReactionConfirm: "要取消回应吗?"
|
||||
changeReactionConfirm: "要更改回应吗?"
|
||||
later: "一会再说"
|
||||
goToMisskey: "去往Misskey"
|
||||
additionalEmojiDictionary: "表情符号追加字典"
|
||||
installed: "已安装"
|
||||
_initialAccountSetting:
|
||||
accountCreated: "账户创建完成了!"
|
||||
|
@@ -1062,6 +1062,7 @@ later: "稍後再說"
|
||||
goToMisskey: "往Misskey"
|
||||
additionalEmojiDictionary: "表情符號的附加辭典"
|
||||
installed: "已安裝"
|
||||
branding: "品牌宣傳"
|
||||
_initialAccountSetting:
|
||||
accountCreated: "帳戶已建立完成!"
|
||||
letsStartAccountSetup: "來進行帳戶的初始設定吧。"
|
||||
|
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "misskey",
|
||||
"version": "13.13.1",
|
||||
"version": "13.13.2",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -56,11 +56,11 @@
|
||||
"devDependencies": {
|
||||
"@types/gulp": "4.0.10",
|
||||
"@types/gulp-rename": "2.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "5.59.8",
|
||||
"@typescript-eslint/parser": "5.59.8",
|
||||
"@typescript-eslint/eslint-plugin": "5.60.0",
|
||||
"@typescript-eslint/parser": "5.60.0",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "12.13.0",
|
||||
"eslint": "8.41.0",
|
||||
"cypress": "12.15.0",
|
||||
"eslint": "8.43.0",
|
||||
"start-server-and-test": "2.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
@@ -17,7 +17,7 @@
|
||||
"paths": {
|
||||
"@/*": ["*"]
|
||||
},
|
||||
"target": "es2021"
|
||||
"target": "es2022"
|
||||
},
|
||||
"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,32 +54,34 @@
|
||||
"@aws-sdk/client-s3": "3.321.1",
|
||||
"@aws-sdk/lib-storage": "3.321.1",
|
||||
"@aws-sdk/node-http-handler": "3.321.1",
|
||||
"@bull-board/api": "5.2.0",
|
||||
"@bull-board/fastify": "5.2.0",
|
||||
"@bull-board/ui": "5.2.0",
|
||||
"@bull-board/api": "5.5.3",
|
||||
"@bull-board/fastify": "5.5.3",
|
||||
"@bull-board/ui": "5.5.3",
|
||||
"@discordapp/twemoji": "14.1.2",
|
||||
"@fastify/accepts": "4.1.0",
|
||||
"@fastify/accepts": "4.2.0",
|
||||
"@fastify/cookie": "8.3.0",
|
||||
"@fastify/cors": "8.3.0",
|
||||
"@fastify/http-proxy": "9.1.0",
|
||||
"@fastify/multipart": "7.6.0",
|
||||
"@fastify/express": "^2.3.0",
|
||||
"@fastify/http-proxy": "9.2.1",
|
||||
"@fastify/multipart": "7.7.0",
|
||||
"@fastify/static": "6.10.2",
|
||||
"@fastify/view": "7.4.1",
|
||||
"@nestjs/common": "9.4.2",
|
||||
"@nestjs/core": "9.4.2",
|
||||
"@nestjs/testing": "9.4.2",
|
||||
"@nestjs/common": "10.0.3",
|
||||
"@nestjs/core": "10.0.3",
|
||||
"@nestjs/testing": "10.0.3",
|
||||
"@peertube/http-signature": "1.7.0",
|
||||
"@sinonjs/fake-timers": "10.2.0",
|
||||
"@sinonjs/fake-timers": "10.3.0",
|
||||
"@swc/cli": "0.1.62",
|
||||
"@swc/core": "1.3.61",
|
||||
"@swc/core": "1.3.66",
|
||||
"accepts": "1.3.8",
|
||||
"ajv": "8.12.0",
|
||||
"archiver": "5.3.1",
|
||||
"autwh": "0.1.0",
|
||||
"bcryptjs": "2.4.3",
|
||||
"blurhash": "2.0.5",
|
||||
"bullmq": "3.15.0",
|
||||
"cacheable-lookup": "6.1.0",
|
||||
"body-parser": "^1.20.2",
|
||||
"bullmq": "4.1.0",
|
||||
"cacheable-lookup": "7.0.0",
|
||||
"cbor": "9.0.0",
|
||||
"chalk": "5.2.0",
|
||||
"chalk-template": "0.4.0",
|
||||
@@ -90,23 +92,25 @@
|
||||
"date-fns": "2.30.0",
|
||||
"deep-email-validator": "0.1.21",
|
||||
"escape-regexp": "0.0.1",
|
||||
"fastify": "4.17.0",
|
||||
"fastify": "4.18.0",
|
||||
"feed": "4.2.2",
|
||||
"file-type": "18.4.0",
|
||||
"file-type": "18.5.0",
|
||||
"fluent-ffmpeg": "2.1.2",
|
||||
"form-data": "4.0.0",
|
||||
"got": "12.6.0",
|
||||
"got": "13.0.0",
|
||||
"happy-dom": "9.20.3",
|
||||
"hpagent": "1.2.0",
|
||||
"http-link-header": "^1.1.0",
|
||||
"ioredis": "5.3.2",
|
||||
"ip-cidr": "3.1.0",
|
||||
"ipaddr.js": "2.1.0",
|
||||
"is-svg": "4.3.2",
|
||||
"js-yaml": "4.1.0",
|
||||
"jsdom": "22.1.0",
|
||||
"json5": "2.2.3",
|
||||
"jsonld": "8.2.0",
|
||||
"jsrsasign": "10.8.6",
|
||||
"meilisearch": "0.32.5",
|
||||
"meilisearch": "0.33.0",
|
||||
"mfm-js": "0.23.3",
|
||||
"mime-types": "2.1.35",
|
||||
"misskey-js": "workspace:*",
|
||||
@@ -116,11 +120,13 @@
|
||||
"nodemailer": "6.9.3",
|
||||
"nsfwjs": "2.4.2",
|
||||
"oauth": "0.10.0",
|
||||
"oauth2orize": "^1.11.1",
|
||||
"oauth2orize-pkce": "^0.1.2",
|
||||
"os-utils": "0.0.14",
|
||||
"otpauth": "9.1.2",
|
||||
"parse5": "7.1.2",
|
||||
"pg": "8.11.0",
|
||||
"private-ip": "3.0.0",
|
||||
"pkce-challenge": "^4.0.1",
|
||||
"probe-image-size": "7.2.3",
|
||||
"promise-limit": "2.7.0",
|
||||
"pug": "3.0.2",
|
||||
@@ -129,36 +135,34 @@
|
||||
"qrcode": "1.5.3",
|
||||
"random-seed": "0.3.0",
|
||||
"ratelimiter": "3.4.1",
|
||||
"re2": "1.19.0",
|
||||
"re2": "1.19.1",
|
||||
"redis-lock": "0.1.4",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"rename": "1.0.4",
|
||||
"rndstr": "1.0.0",
|
||||
"rss-parser": "3.13.0",
|
||||
"rxjs": "7.8.1",
|
||||
"s-age": "1.1.2",
|
||||
"sanitize-html": "2.10.0",
|
||||
"seedrandom": "3.0.5",
|
||||
"semver": "7.5.1",
|
||||
"sanitize-html": "2.11.0",
|
||||
"semver": "7.5.3",
|
||||
"sharp": "0.32.1",
|
||||
"sharp-read-bmp": "github:misskey-dev/sharp-read-bmp",
|
||||
"slacc": "0.0.9",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"stringz": "2.1.0",
|
||||
"summaly": "github:misskey-dev/summaly",
|
||||
"systeminformation": "5.17.16",
|
||||
"systeminformation": "5.18.4",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tmp": "0.2.1",
|
||||
"tsc-alias": "1.8.6",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"twemoji-parser": "14.0.0",
|
||||
"typeorm": "0.3.16",
|
||||
"typeorm": "0.3.17",
|
||||
"typescript": "5.1.3",
|
||||
"ulid": "2.3.0",
|
||||
"unzipper": "0.10.14",
|
||||
"uuid": "9.0.0",
|
||||
"vary": "1.1.2",
|
||||
"web-push": "3.6.1",
|
||||
"web-push": "3.6.3",
|
||||
"ws": "8.13.0",
|
||||
"xev": "3.0.2"
|
||||
},
|
||||
@@ -168,22 +172,26 @@
|
||||
"@types/accepts": "1.3.5",
|
||||
"@types/archiver": "5.3.2",
|
||||
"@types/bcryptjs": "2.4.2",
|
||||
"@types/body-parser": "^1.19.2",
|
||||
"@types/cbor": "6.0.0",
|
||||
"@types/color-convert": "2.0.0",
|
||||
"@types/content-disposition": "0.5.5",
|
||||
"@types/escape-regexp": "0.0.1",
|
||||
"@types/fluent-ffmpeg": "2.1.21",
|
||||
"@types/http-link-header": "^1.0.3",
|
||||
"@types/jest": "29.5.2",
|
||||
"@types/js-yaml": "4.0.5",
|
||||
"@types/jsdom": "21.1.1",
|
||||
"@types/jsonld": "1.5.8",
|
||||
"@types/jsonld": "1.5.9",
|
||||
"@types/jsrsasign": "10.5.8",
|
||||
"@types/mime-types": "2.1.1",
|
||||
"@types/node": "20.2.5",
|
||||
"@types/ms": "^0.7.31",
|
||||
"@types/node": "20.3.1",
|
||||
"@types/node-fetch": "3.0.3",
|
||||
"@types/nodemailer": "6.4.8",
|
||||
"@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/punycode": "2.1.0",
|
||||
"@types/qrcode": "1.5.0",
|
||||
@@ -194,23 +202,25 @@
|
||||
"@types/sanitize-html": "2.9.0",
|
||||
"@types/semver": "7.5.0",
|
||||
"@types/sharp": "0.32.0",
|
||||
"@types/simple-oauth2": "^5.0.4",
|
||||
"@types/sinonjs__fake-timers": "8.1.2",
|
||||
"@types/tinycolor2": "1.4.3",
|
||||
"@types/tmp": "0.2.3",
|
||||
"@types/unzipper": "0.10.6",
|
||||
"@types/uuid": "9.0.1",
|
||||
"@types/uuid": "9.0.2",
|
||||
"@types/vary": "1.1.0",
|
||||
"@types/web-push": "3.3.2",
|
||||
"@types/websocket": "1.0.5",
|
||||
"@types/ws": "8.5.4",
|
||||
"@typescript-eslint/eslint-plugin": "5.59.8",
|
||||
"@typescript-eslint/parser": "5.59.8",
|
||||
"@types/ws": "8.5.5",
|
||||
"@typescript-eslint/eslint-plugin": "5.60.0",
|
||||
"@typescript-eslint/parser": "5.60.0",
|
||||
"aws-sdk-client-mock": "2.1.1",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint": "8.41.0",
|
||||
"eslint": "8.43.0",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"execa": "6.1.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
|
||||
public dispose(): void {
|
||||
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
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import { DataSource, In, IsNull } from 'typeorm';
|
||||
import * as Redis from 'ioredis';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
@@ -18,7 +18,7 @@ import type { Serialized } from '@/server/api/stream/types.js';
|
||||
const parseEmojiStrRegexp = /^(\w+)(?:@([\w.-]+))?$/;
|
||||
|
||||
@Injectable()
|
||||
export class CustomEmojiService {
|
||||
export class CustomEmojiService implements OnApplicationShutdown {
|
||||
private cache: MemoryKVCache<Emoji | null>;
|
||||
public localEmojisCache: RedisSingleCache<Map<string, Emoji>>;
|
||||
|
||||
@@ -349,4 +349,14 @@ export class CustomEmojiService {
|
||||
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 util from 'node:util';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import IPCIDR from 'ip-cidr';
|
||||
import PrivateIp from 'private-ip';
|
||||
import ipaddr from 'ipaddr.js';
|
||||
import chalk from 'chalk';
|
||||
import got, * as Got from 'got';
|
||||
import { parse } from 'content-disposition';
|
||||
@@ -123,15 +122,15 @@ export class DownloadService {
|
||||
public async downloadTextFile(url: string): Promise<string> {
|
||||
// Create temp file
|
||||
const [path, cleanup] = await createTemp();
|
||||
|
||||
|
||||
this.logger.info(`text file: Temp file is ${path}`);
|
||||
|
||||
|
||||
try {
|
||||
// write content at URL to temp file
|
||||
await this.downloadUrl(url, path);
|
||||
|
||||
|
||||
const text = await util.promisify(fs.readFile)(path, 'utf8');
|
||||
|
||||
|
||||
return text;
|
||||
} finally {
|
||||
cleanup();
|
||||
@@ -140,13 +139,14 @@ export class DownloadService {
|
||||
|
||||
@bindThis
|
||||
private isPrivateIp(ip: string): boolean {
|
||||
const parsedIp = ipaddr.parse(ip);
|
||||
|
||||
for (const net of this.config.allowedPrivateNetworks ?? []) {
|
||||
const cidr = new IPCIDR(net);
|
||||
if (cidr.contains(ip)) {
|
||||
if (parsedIp.match(ipaddr.parseCIDR(net))) {
|
||||
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 type { InstancesRepository } from '@/models/index.js';
|
||||
import type { Instance } from '@/models/entities/Instance.js';
|
||||
@@ -9,7 +9,7 @@ import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
@Injectable()
|
||||
export class FederatedInstanceService {
|
||||
export class FederatedInstanceService implements OnApplicationShutdown {
|
||||
public federatedInstanceCache: RedisKVCache<Instance | null>;
|
||||
|
||||
constructor(
|
||||
@@ -77,4 +77,14 @@ export class FederatedInstanceService {
|
||||
|
||||
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 type { Config } from '@/config.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { Role } from '@/models';
|
||||
import { Role } from '@/models/index.js';
|
||||
|
||||
@Injectable()
|
||||
export class GlobalEventService {
|
||||
|
@@ -5,7 +5,7 @@ import type { Config } from '@/config.js';
|
||||
import { genAid, parseAid } from '@/misc/id/aid.js';
|
||||
import { genMeid, parseMeid } from '@/misc/id/meid.js';
|
||||
import { genMeidg, parseMeidg } from '@/misc/id/meidg.js';
|
||||
import { genObjectId } from '@/misc/id/object-id.js';
|
||||
import { genObjectId, parseObjectId } from '@/misc/id/object-id.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { parseUlid } from '@/misc/id/ulid.js';
|
||||
|
||||
@@ -38,7 +38,7 @@ export class IdService {
|
||||
public parse(id: string): { date: Date; } {
|
||||
switch (this.method) {
|
||||
case 'aid': return parseAid(id);
|
||||
case 'objectid':
|
||||
case 'objectid': return parseObjectId(id);
|
||||
case 'meid': return parseMeid(id);
|
||||
case 'meidg': return parseMeidg(id);
|
||||
case 'ulid': return parseUlid(id);
|
||||
|
@@ -3,7 +3,7 @@ import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { KEYWORD } from 'color-convert/conversions';
|
||||
import type { KEYWORD } from 'color-convert/conversions.js';
|
||||
|
||||
@Injectable()
|
||||
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 * as Redis from 'ioredis';
|
||||
import { DI } from '@/di-symbols.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 type { SwSubscription, SwSubscriptionsRepository } from '@/models/index.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
@@ -42,7 +42,7 @@ function truncateBody<T extends keyof PushNotificationsTypes>(type: T, body: Pus
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PushNotificationService {
|
||||
export class PushNotificationService implements OnApplicationShutdown {
|
||||
private subscriptionsCache: RedisKVCache<SwSubscription[]>;
|
||||
|
||||
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) => {
|
||||
//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) => {
|
||||
//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 { IdService } from '@/core/IdService.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';
|
||||
|
||||
export type RolePolicies = {
|
||||
@@ -435,6 +435,7 @@ export class RoleService implements OnApplicationShutdown {
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.redisForSub.off('message', this.onMessage);
|
||||
this.roleAssignmentByUserIdCache.dispose();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import type { UserKeypairsRepository } from '@/models/index.js';
|
||||
@@ -8,7 +8,7 @@ import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserKeypairService {
|
||||
export class UserKeypairService implements OnApplicationShutdown {
|
||||
private cache: RedisKVCache<UserKeypair>;
|
||||
|
||||
constructor(
|
||||
@@ -31,4 +31,14 @@ export class UserKeypairService {
|
||||
public async getUserKeypair(userId: User['id']): Promise<UserKeypair> {
|
||||
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 { DI } from '@/di-symbols.js';
|
||||
import type { NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js';
|
||||
@@ -30,7 +30,7 @@ export type UriParseResult = {
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ApDbResolverService {
|
||||
export class ApDbResolverService implements OnApplicationShutdown {
|
||||
private publicKeyCache: MemoryKVCache<UserPublickey | null>;
|
||||
private publicKeyByUserIdCache: MemoryKVCache<UserPublickey | null>;
|
||||
|
||||
@@ -162,4 +162,15 @@ export class ApDbResolverService {
|
||||
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 { In, Not } from 'typeorm';
|
||||
import * as Redis from 'ioredis';
|
||||
import Ajv from 'ajv';
|
||||
import _Ajv from 'ajv';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
@@ -31,6 +31,7 @@ type IsMeAndIsUserDetailed<ExpectsMe extends boolean | null, Detailed extends bo
|
||||
Packed<'UserDetailed'> :
|
||||
Packed<'UserLite'>;
|
||||
|
||||
const Ajv = _Ajv.default;
|
||||
const ajv = new Ajv();
|
||||
|
||||
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 { bindThis } from '@/decorators.js';
|
||||
import { envOption } from './env.js';
|
||||
import type { KEYWORD } from 'color-convert/conversions';
|
||||
import type { KEYWORD } from 'color-convert/conversions.js';
|
||||
|
||||
type Context = {
|
||||
name: string;
|
||||
|
@@ -83,6 +83,16 @@ export class RedisKVCache<T> {
|
||||
|
||||
// TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public gc() {
|
||||
this.memoryCache.gc();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose() {
|
||||
this.memoryCache.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export class RedisSingleCache<T> {
|
||||
@@ -174,10 +184,15 @@ export class RedisSingleCache<T> {
|
||||
export class MemoryKVCache<T> {
|
||||
public cache: Map<string, { date: number; value: T; }>;
|
||||
private lifetime: number;
|
||||
private gcIntervalHandle: NodeJS.Timer;
|
||||
|
||||
constructor(lifetime: MemoryKVCache<never>['lifetime']) {
|
||||
this.cache = new Map();
|
||||
this.lifetime = lifetime;
|
||||
|
||||
this.gcIntervalHandle = setInterval(() => {
|
||||
this.gc();
|
||||
}, 1000 * 60 * 3);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -200,7 +215,7 @@ export class MemoryKVCache<T> {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public delete(key: string) {
|
||||
public delete(key: string): void {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
|
||||
@@ -255,6 +270,21 @@ export class MemoryKVCache<T> {
|
||||
}
|
||||
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> {
|
||||
|
@@ -1,3 +1,3 @@
|
||||
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';
|
||||
|
||||
export function getIpHash(ip: string) {
|
||||
export function getIpHash(ip: string): string {
|
||||
try {
|
||||
// because a single person may control many IPv6 addresses,
|
||||
// only a /64 subnet prefix of any IP will be taken into account.
|
||||
|
@@ -1,10 +1,9 @@
|
||||
import * as crypto from 'node:crypto';
|
||||
|
||||
const L_CHARS = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
export const L_CHARS = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
const LU_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
|
||||
export function secureRndstr(length = 32, useLU = true): string {
|
||||
const chars = useLU ? LU_CHARS : L_CHARS;
|
||||
export function secureRndstr(length = 32, { chars = LU_CHARS } = {}): string {
|
||||
const chars_len = chars.length;
|
||||
|
||||
let str = '';
|
||||
|
@@ -101,13 +101,25 @@ export class Meta {
|
||||
length: 1024,
|
||||
nullable: true,
|
||||
})
|
||||
public errorImageUrl: string | null;
|
||||
public iconUrl: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024,
|
||||
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', {
|
||||
default: true,
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import Ajv from 'ajv';
|
||||
import _Ajv from 'ajv';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import Logger from '@/logger.js';
|
||||
@@ -10,6 +10,8 @@ import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import { DBAntennaImportJobData } from '../types.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
|
||||
const Ajv = _Ajv.default;
|
||||
|
||||
const validate = new Ajv().compile({
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
@@ -36,6 +36,7 @@ import { UserListChannelService } from './api/stream/channels/user-list.js';
|
||||
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
|
||||
import { ClientLoggerService } from './web/ClientLoggerService.js';
|
||||
import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
|
||||
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -78,6 +79,7 @@ import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.
|
||||
ServerStatsChannelService,
|
||||
UserListChannelService,
|
||||
OpenApiServerService,
|
||||
OAuth2ProviderService,
|
||||
],
|
||||
exports: [
|
||||
ServerService,
|
||||
|
@@ -24,6 +24,7 @@ import { WellKnownServerService } from './WellKnownServerService.js';
|
||||
import { FileServerService } from './FileServerService.js';
|
||||
import { ClientServerService } from './web/ClientServerService.js';
|
||||
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
|
||||
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
|
||||
|
||||
const _dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
@@ -56,12 +57,13 @@ export class ServerService implements OnApplicationShutdown {
|
||||
private clientServerService: ClientServerService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private loggerService: LoggerService,
|
||||
private oauth2ProviderService: OAuth2ProviderService,
|
||||
) {
|
||||
this.logger = this.loggerService.getLogger('server', 'gray', false);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async launch() {
|
||||
public async launch(): Promise<void> {
|
||||
const fastify = Fastify({
|
||||
trustProxy: true,
|
||||
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.nodeinfoServerService.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) => {
|
||||
const path = request.params.path;
|
||||
|
@@ -53,44 +53,72 @@ export class ApiCallService implements OnApplicationShutdown {
|
||||
}, 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
|
||||
public handleRequest(
|
||||
endpoint: IEndpoint & { exec: any },
|
||||
request: FastifyRequest<{ Body: Record<string, unknown> | undefined, Querystring: Record<string, unknown> }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
): void {
|
||||
const body = request.method === 'GET'
|
||||
? request.query
|
||||
: 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') {
|
||||
reply.code(400);
|
||||
return;
|
||||
}
|
||||
this.authenticateService.authenticate(token).then(([user, app]) => {
|
||||
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}`);
|
||||
}
|
||||
this.send(reply, res);
|
||||
}).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) {
|
||||
this.logIp(request, user);
|
||||
}
|
||||
}).catch(err => {
|
||||
if (err instanceof AuthenticationError) {
|
||||
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());
|
||||
}
|
||||
this.#sendAuthenticationError(reply, err);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,7 +127,7 @@ export class ApiCallService implements OnApplicationShutdown {
|
||||
endpoint: IEndpoint & { exec: any },
|
||||
request: FastifyRequest<{ Body: Record<string, unknown>, Querystring: Record<string, unknown> }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
): Promise<void> {
|
||||
const multipartData = await request.file().catch(() => {
|
||||
/* 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;
|
||||
}
|
||||
|
||||
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') {
|
||||
reply.code(400);
|
||||
return;
|
||||
@@ -129,22 +160,14 @@ export class ApiCallService implements OnApplicationShutdown {
|
||||
}, request).then((res) => {
|
||||
this.send(reply, res);
|
||||
}).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) {
|
||||
this.logIp(request, user);
|
||||
}
|
||||
}).catch(err => {
|
||||
if (err instanceof AuthenticationError) {
|
||||
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());
|
||||
}
|
||||
this.#sendAuthenticationError(reply, err);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -213,7 +236,7 @@ export class ApiCallService implements OnApplicationShutdown {
|
||||
}
|
||||
|
||||
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;
|
||||
if (user) {
|
||||
limitActor = user.id;
|
||||
@@ -255,8 +278,8 @@ export class ApiCallService implements OnApplicationShutdown {
|
||||
throw new ApiError({
|
||||
message: 'Your account has been suspended.',
|
||||
code: 'YOUR_ACCOUNT_SUSPENDED',
|
||||
kind: 'permission',
|
||||
id: 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370',
|
||||
httpStatusCode: 403,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -266,8 +289,8 @@ export class ApiCallService implements OnApplicationShutdown {
|
||||
throw new ApiError({
|
||||
message: 'You have moved your account.',
|
||||
code: 'YOUR_ACCOUNT_MOVED',
|
||||
kind: 'permission',
|
||||
id: '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31',
|
||||
httpStatusCode: 403,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -278,6 +301,7 @@ export class ApiCallService implements OnApplicationShutdown {
|
||||
throw new ApiError({
|
||||
message: 'You are not assigned to a moderator role.',
|
||||
code: 'ROLE_PERMISSION_DENIED',
|
||||
kind: 'permission',
|
||||
id: 'd33d5333-db36-423d-a8f9-1a2b9549da41',
|
||||
});
|
||||
}
|
||||
@@ -285,6 +309,7 @@ export class ApiCallService implements OnApplicationShutdown {
|
||||
throw new ApiError({
|
||||
message: 'You are not assigned to an administrator role.',
|
||||
code: 'ROLE_PERMISSION_DENIED',
|
||||
kind: 'permission',
|
||||
id: 'c3d38592-54c0-429d-be96-5636b0431a61',
|
||||
});
|
||||
}
|
||||
@@ -296,6 +321,7 @@ export class ApiCallService implements OnApplicationShutdown {
|
||||
throw new ApiError({
|
||||
message: 'You are not assigned to a required role.',
|
||||
code: 'ROLE_PERMISSION_DENIED',
|
||||
kind: 'permission',
|
||||
id: '7f86f06f-7e15-4057-8561-f4b6d4ac755a',
|
||||
});
|
||||
}
|
||||
@@ -305,6 +331,7 @@ export class ApiCallService implements OnApplicationShutdown {
|
||||
throw new ApiError({
|
||||
message: 'Your app does not have the necessary permissions to use this endpoint.',
|
||||
code: 'PERMISSION_DENIED',
|
||||
kind: 'permission',
|
||||
id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838',
|
||||
});
|
||||
}
|
||||
@@ -317,7 +344,7 @@ export class ApiCallService implements OnApplicationShutdown {
|
||||
try {
|
||||
data[k] = JSON.parse(data[k]);
|
||||
} catch (e) {
|
||||
throw new ApiError({
|
||||
throw new ApiError({
|
||||
message: 'Invalid param.',
|
||||
code: 'INVALID_PARAM',
|
||||
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 type { AccessTokensRepository, AppsRepository, UsersRepository } from '@/models/index.js';
|
||||
import type { LocalUser } from '@/models/entities/User.js';
|
||||
@@ -17,7 +17,7 @@ export class AuthenticationError extends Error {
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthenticateService {
|
||||
export class AuthenticateService implements OnApplicationShutdown {
|
||||
private appCache: MemoryKVCache<App>;
|
||||
|
||||
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_search from './endpoints/users/search.js';
|
||||
import * as ep___users_show from './endpoints/users/show.js';
|
||||
import * as ep___users_stats from './endpoints/users/stats.js';
|
||||
import * as ep___users_achievements from './endpoints/users/achievements.js';
|
||||
import * as ep___users_updateMemo from './endpoints/users/update-memo.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_search: Provider = { provide: 'ep:users/search', useClass: ep___users_search.default };
|
||||
const $users_show: Provider = { provide: 'ep:users/show', useClass: ep___users_show.default };
|
||||
const $users_stats: Provider = { provide: 'ep:users/stats', useClass: ep___users_stats.default };
|
||||
const $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 $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_search,
|
||||
$users_show,
|
||||
$users_stats,
|
||||
$users_achievements,
|
||||
$users_updateMemo,
|
||||
$fetchRss,
|
||||
@@ -1356,7 +1353,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
||||
$users_searchByUsernameAndHost,
|
||||
$users_search,
|
||||
$users_show,
|
||||
$users_stats,
|
||||
$users_achievements,
|
||||
$users_updateMemo,
|
||||
$fetchRss,
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import rndstr from 'rndstr';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
@@ -16,6 +15,7 @@ import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { SigninService } from './SigninService.js';
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js';
|
||||
|
||||
@Injectable()
|
||||
export class SignupApiService {
|
||||
@@ -67,7 +67,7 @@ export class SignupApiService {
|
||||
const body = request.body;
|
||||
|
||||
const instance = await this.metaService.fetch(true);
|
||||
|
||||
|
||||
// Verify *Captcha
|
||||
// ただしテスト時はこの機構は障害となるため無効にする
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
@@ -76,7 +76,7 @@ export class SignupApiService {
|
||||
throw new FastifyReplyError(400, err);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (instance.enableRecaptcha && instance.recaptchaSecretKey) {
|
||||
await this.captchaService.verifyRecaptcha(instance.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => {
|
||||
throw new FastifyReplyError(400, err);
|
||||
@@ -89,44 +89,44 @@ export class SignupApiService {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const username = body['username'];
|
||||
const password = body['password'];
|
||||
const host: string | null = process.env.NODE_ENV === 'test' ? (body['host'] ?? null) : null;
|
||||
const invitationCode = body['invitationCode'];
|
||||
const emailAddress = body['emailAddress'];
|
||||
|
||||
|
||||
if (instance.emailRequiredForSignup) {
|
||||
if (emailAddress == null || typeof emailAddress !== 'string') {
|
||||
reply.code(400);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const res = await this.emailService.validateEmailForAccount(emailAddress);
|
||||
if (!res.available) {
|
||||
reply.code(400);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (instance.disableRegistration) {
|
||||
if (invitationCode == null || typeof invitationCode !== 'string') {
|
||||
reply.code(400);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const ticket = await this.registrationTicketsRepository.findOneBy({
|
||||
code: invitationCode,
|
||||
});
|
||||
|
||||
|
||||
if (ticket == null) {
|
||||
reply.code(400);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
this.registrationTicketsRepository.delete(ticket.id);
|
||||
}
|
||||
|
||||
|
||||
if (instance.emailRequiredForSignup) {
|
||||
if (await this.usersRepository.findOneBy({ usernameLower: username.toLowerCase(), host: IsNull() })) {
|
||||
throw new FastifyReplyError(400, 'DUPLICATED_USERNAME');
|
||||
@@ -142,7 +142,7 @@ export class SignupApiService {
|
||||
throw new FastifyReplyError(400, 'DENIED_USERNAME');
|
||||
}
|
||||
|
||||
const code = rndstr('a-z0-9', 16);
|
||||
const code = secureRndstr(16, { chars: L_CHARS });
|
||||
|
||||
// Generate hash of password
|
||||
const salt = await bcrypt.genSalt(8);
|
||||
@@ -170,12 +170,12 @@ export class SignupApiService {
|
||||
const { account, secret } = await this.signupService.signup({
|
||||
username, password, host,
|
||||
});
|
||||
|
||||
|
||||
const res = await this.userEntityService.pack(account, account, {
|
||||
detail: true,
|
||||
includeSecrets: true,
|
||||
});
|
||||
|
||||
|
||||
return {
|
||||
...res,
|
||||
token: secret,
|
||||
|
@@ -10,7 +10,7 @@ import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { NotificationService } from '@/core/NotificationService.js';
|
||||
import { bindThis } from '@/decorators.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 MainStreamConnection from './stream/index.js';
|
||||
import { ChannelsService } from './stream/ChannelsService.js';
|
||||
@@ -58,11 +58,21 @@ export class StreamingApiServerService {
|
||||
let user: LocalUser | 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 {
|
||||
[user, app] = await this.authenticateService.authenticate(q.get('i'));
|
||||
[user, app] = await this.authenticateService.authenticate(token);
|
||||
} catch (e) {
|
||||
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 {
|
||||
socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
|
||||
}
|
||||
@@ -128,26 +138,27 @@ export class StreamingApiServerService {
|
||||
ev.removeAllListeners();
|
||||
stream.dispose();
|
||||
this.redisForSub.off('message', onRedisMessage);
|
||||
this.#connections.delete(connection);
|
||||
if (userUpdateIntervalId) clearInterval(userUpdateIntervalId);
|
||||
});
|
||||
|
||||
connection.on('message', async (data) => {
|
||||
connection.on('pong', () => {
|
||||
this.#connections.set(connection, Date.now());
|
||||
if (data.toString() === 'ping') {
|
||||
connection.send('pong');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 一定期間通信が無いコネクションは実際には切断されている可能性があるため定期的にterminateする
|
||||
this.#cleanConnectionsIntervalId = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [connection, lastActive] of this.#connections.entries()) {
|
||||
if (now - lastActive > 1000 * 60 * 5) {
|
||||
if (now - lastActive > 1000 * 60 * 2) {
|
||||
connection.terminate();
|
||||
this.#connections.delete(connection);
|
||||
} else {
|
||||
connection.ping();
|
||||
}
|
||||
}
|
||||
}, 1000 * 60 * 5);
|
||||
}, 1000 * 60);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@@ -1,11 +1,13 @@
|
||||
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 { LocalUser } from '@/models/entities/User.js';
|
||||
import type { AccessToken } from '@/models/entities/AccessToken.js';
|
||||
import { ApiError } from './error.js';
|
||||
import type { IEndpointMeta } from './endpoints.js';
|
||||
|
||||
const Ajv = _Ajv.default;
|
||||
|
||||
const ajv = new Ajv({
|
||||
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_search from './endpoints/users/search.js';
|
||||
import * as ep___users_show from './endpoints/users/show.js';
|
||||
import * as ep___users_stats from './endpoints/users/stats.js';
|
||||
import * as ep___users_achievements from './endpoints/users/achievements.js';
|
||||
import * as ep___users_updateMemo from './endpoints/users/update-memo.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', ep___users_search],
|
||||
['users/show', ep___users_show],
|
||||
['users/stats', ep___users_stats],
|
||||
['users/achievements', ep___users_achievements],
|
||||
['users/update-memo', ep___users_updateMemo],
|
||||
['fetch-rss', ep___fetchRss],
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import rndstr from 'rndstr';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { DriveFilesRepository } from '@/models/index.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
@@ -61,10 +61,17 @@ export const meta = {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
errorImageUrl: {
|
||||
serverErrorImageUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
infoImageUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
notFoundImageUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
default: 'https://xn--931a.moe/aiart/yubitun.png',
|
||||
},
|
||||
iconUrl: {
|
||||
type: 'string',
|
||||
@@ -305,7 +312,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
themeColor: instance.themeColor,
|
||||
mascotImageUrl: instance.mascotImageUrl,
|
||||
bannerUrl: instance.bannerUrl,
|
||||
errorImageUrl: instance.errorImageUrl,
|
||||
serverErrorImageUrl: instance.serverErrorImageUrl,
|
||||
notFoundImageUrl: instance.notFoundImageUrl,
|
||||
infoImageUrl: instance.infoImageUrl,
|
||||
iconUrl: instance.iconUrl,
|
||||
backgroundImageUrl: instance.backgroundImageUrl,
|
||||
logoImageUrl: instance.logoImageUrl,
|
||||
|
@@ -1,9 +1,9 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import rndstr from 'rndstr';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { UsersRepository, UserProfilesRepository } from '@/models/index.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
@@ -54,7 +54,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
throw new Error('cannot reset password of root');
|
||||
}
|
||||
|
||||
const passwd = rndstr('a-zA-Z0-9', 8);
|
||||
const passwd = secureRndstr(8);
|
||||
|
||||
// Generate hash of password
|
||||
const hash = bcrypt.hashSync(passwd);
|
||||
|
@@ -32,7 +32,9 @@ export const paramDef = {
|
||||
themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' },
|
||||
mascotImageUrl: { 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 },
|
||||
backgroundImageUrl: { 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;
|
||||
}
|
||||
|
||||
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) {
|
||||
set.backgroundImageUrl = ps.backgroundImageUrl;
|
||||
}
|
||||
@@ -281,10 +295,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
set.smtpPass = ps.smtpPass;
|
||||
}
|
||||
|
||||
if (ps.errorImageUrl !== undefined) {
|
||||
set.errorImageUrl = ps.errorImageUrl;
|
||||
}
|
||||
|
||||
if (ps.enableServiceWorker !== undefined) {
|
||||
set.enableServiceWorker = ps.enableServiceWorker;
|
||||
}
|
||||
|
@@ -44,7 +44,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
// Generate secret
|
||||
const secret = secureRndstr(32, true);
|
||||
const secret = secureRndstr(32);
|
||||
|
||||
// for backward compatibility
|
||||
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);
|
||||
}
|
||||
|
||||
const accessToken = secureRndstr(32, true);
|
||||
const accessToken = secureRndstr(32);
|
||||
|
||||
// Fetch exist access token
|
||||
const exist = await this.accessTokensRepository.findOneBy({
|
||||
|
@@ -54,7 +54,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor (
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
|
||||
@Inject(DI.antennasRepository)
|
||||
private antennasRepository: AntennasRepository,
|
||||
|
||||
@@ -79,6 +79,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
this.queueService.createImportAntennasJob(me, antennas);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type Antenna = (_Antenna & { userListAccts: string[] | null })[];
|
||||
|
@@ -72,7 +72,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
const checkMoving = await this.accountMoveService.validateAlsoKnownAs(
|
||||
me,
|
||||
(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);
|
||||
|
||||
|
@@ -71,7 +71,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
const checkMoving = await this.accountMoveService.validateAlsoKnownAs(
|
||||
me,
|
||||
(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);
|
||||
|
||||
|
@@ -72,7 +72,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
const checkMoving = await this.accountMoveService.validateAlsoKnownAs(
|
||||
me,
|
||||
(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);
|
||||
|
||||
|
@@ -71,7 +71,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
const checkMoving = await this.accountMoveService.validateAlsoKnownAs(
|
||||
me,
|
||||
(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);
|
||||
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import rndstr from 'rndstr';
|
||||
import ms from 'ms';
|
||||
import bcrypt from 'bcryptjs';
|
||||
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 { DI } from '@/di-symbols.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -94,7 +94,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
this.globalEventService.publishMainStream(me.id, 'meUpdated', iObj);
|
||||
|
||||
if (ps.email != null) {
|
||||
const code = rndstr('a-z0-9', 16);
|
||||
const code = secureRndstr(16, { chars: L_CHARS });
|
||||
|
||||
await this.userProfilesRepository.update(me.id, {
|
||||
emailVerifyCode: code,
|
||||
|
@@ -1,9 +1,9 @@
|
||||
import rndstr from 'rndstr';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { RegistrationTicketsRepository } from '@/models/index.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['meta'],
|
||||
@@ -42,9 +42,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
private idService: IdService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const code = rndstr({
|
||||
length: 8,
|
||||
chars: '2-9A-HJ-NP-Z', // [0-9A-Z] w/o [01IO] (32 patterns)
|
||||
const code = secureRndstr(8, {
|
||||
chars: '23456789ABCDEFGHJKLMNPQRSTUVWXYZ', // [0-9A-Z] w/o [01IO] (32 patterns)
|
||||
});
|
||||
|
||||
await this.registrationTicketsRepository.insert({
|
||||
|
@@ -124,10 +124,17 @@ export const meta = {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
errorImageUrl: {
|
||||
serverErrorImageUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
default: 'https://xn--931a.moe/aiart/yubitun.png',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
infoImageUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
notFoundImageUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
iconUrl: {
|
||||
type: 'string',
|
||||
@@ -288,7 +295,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
themeColor: instance.themeColor,
|
||||
mascotImageUrl: instance.mascotImageUrl,
|
||||
bannerUrl: instance.bannerUrl,
|
||||
errorImageUrl: instance.errorImageUrl,
|
||||
infoImageUrl: instance.infoImageUrl,
|
||||
serverErrorImageUrl: instance.serverErrorImageUrl,
|
||||
notFoundImageUrl: instance.notFoundImageUrl,
|
||||
iconUrl: instance.iconUrl,
|
||||
backgroundImageUrl: instance.backgroundImageUrl,
|
||||
logoImageUrl: instance.logoImageUrl,
|
||||
|
@@ -49,7 +49,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
// Generate access token
|
||||
const accessToken = secureRndstr(32, true);
|
||||
const accessToken = secureRndstr(32);
|
||||
|
||||
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 { NoteDeleteService } from '@/core/NoteDeleteService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
import { GetterService } from '@/server/api/GetterService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import rndstr from 'rndstr';
|
||||
import ms from 'ms';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
@@ -8,6 +7,7 @@ import { IdService } from '@/core/IdService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { EmailService } from '@/core/EmailService.js';
|
||||
import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['reset password'],
|
||||
@@ -41,7 +41,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@@ -77,7 +77,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = rndstr('a-z0-9', 64);
|
||||
const token = secureRndstr(64, { chars: L_CHARS });
|
||||
|
||||
await this.passwordResetRequestsRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
|
@@ -30,6 +30,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const roles = await this.rolesRepository.findBy({
|
||||
isPublic: true,
|
||||
isExplorable: true,
|
||||
});
|
||||
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({
|
||||
id: ps.roleId,
|
||||
isPublic: true,
|
||||
isExplorable: true,
|
||||
});
|
||||
|
||||
if (role == null) {
|
||||
|
@@ -44,7 +44,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private queryService: QueryService,
|
||||
) {
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import * as sanitizeHtml from 'sanitize-html';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { UsersRepository, AbuseUserReportsRepository } from '@/models/index.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 type Connection from '.';
|
||||
import type Connection from './index.js';
|
||||
|
||||
/**
|
||||
* Stream channel
|
||||
|
@@ -12,7 +12,7 @@ import type { Page } from '@/models/entities/Page.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { Webhook } from '@/models/entities/Webhook.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 { EventEmitter } from 'events';
|
||||
|
||||
@@ -233,7 +233,7 @@ export type StreamMessages = {
|
||||
|
||||
// API event definitions
|
||||
// ストリームごとの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
|
||||
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
|
||||
// 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 { ClipEntityService } from '@/core/entities/ClipEntityService.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 { deepClone } from '@/misc/clone.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
@@ -117,6 +117,18 @@ export class ClientServerService {
|
||||
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
|
||||
public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
|
||||
fastify.register(fastifyCookie, {});
|
||||
@@ -341,12 +353,10 @@ export class ClientServerService {
|
||||
reply.header('Cache-Control', 'public, max-age=30');
|
||||
return await reply.view('base', {
|
||||
img: meta.bannerUrl,
|
||||
title: meta.name ?? 'Misskey',
|
||||
instanceName: meta.name ?? 'Misskey',
|
||||
url: this.config.url,
|
||||
title: meta.name ?? 'Misskey',
|
||||
desc: meta.description,
|
||||
icon: meta.iconUrl,
|
||||
themeColor: meta.themeColor,
|
||||
...this.generateCommonPugData(meta),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -431,9 +441,7 @@ export class ClientServerService {
|
||||
user, profile, me,
|
||||
avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user),
|
||||
sub: request.params.sub,
|
||||
instanceName: meta.name ?? 'Misskey',
|
||||
icon: meta.iconUrl,
|
||||
themeColor: meta.themeColor,
|
||||
...this.generateCommonPugData(meta),
|
||||
});
|
||||
} else {
|
||||
// リモートユーザーなので
|
||||
@@ -481,9 +489,7 @@ export class ClientServerService {
|
||||
avatarUrl: _note.user.avatarUrl,
|
||||
// TODO: Let locale changeable by instance setting
|
||||
summary: getNoteSummary(_note),
|
||||
instanceName: meta.name ?? 'Misskey',
|
||||
icon: meta.iconUrl,
|
||||
themeColor: meta.themeColor,
|
||||
...this.generateCommonPugData(meta),
|
||||
});
|
||||
} else {
|
||||
return await renderBase(reply);
|
||||
@@ -522,9 +528,7 @@ export class ClientServerService {
|
||||
page: _page,
|
||||
profile,
|
||||
avatarUrl: _page.user.avatarUrl,
|
||||
instanceName: meta.name ?? 'Misskey',
|
||||
icon: meta.iconUrl,
|
||||
themeColor: meta.themeColor,
|
||||
...this.generateCommonPugData(meta),
|
||||
});
|
||||
} else {
|
||||
return await renderBase(reply);
|
||||
@@ -550,9 +554,7 @@ export class ClientServerService {
|
||||
flash: _flash,
|
||||
profile,
|
||||
avatarUrl: _flash.user.avatarUrl,
|
||||
instanceName: meta.name ?? 'Misskey',
|
||||
icon: meta.iconUrl,
|
||||
themeColor: meta.themeColor,
|
||||
...this.generateCommonPugData(meta),
|
||||
});
|
||||
} else {
|
||||
return await renderBase(reply);
|
||||
@@ -578,9 +580,7 @@ export class ClientServerService {
|
||||
clip: _clip,
|
||||
profile,
|
||||
avatarUrl: _clip.user.avatarUrl,
|
||||
instanceName: meta.name ?? 'Misskey',
|
||||
icon: meta.iconUrl,
|
||||
themeColor: meta.themeColor,
|
||||
...this.generateCommonPugData(meta),
|
||||
});
|
||||
} else {
|
||||
return await renderBase(reply);
|
||||
@@ -604,9 +604,7 @@ export class ClientServerService {
|
||||
post: _post,
|
||||
profile,
|
||||
avatarUrl: _post.user.avatarUrl,
|
||||
instanceName: meta.name ?? 'Misskey',
|
||||
icon: meta.iconUrl,
|
||||
themeColor: meta.themeColor,
|
||||
...this.generateCommonPugData(meta),
|
||||
});
|
||||
} else {
|
||||
return await renderBase(reply);
|
||||
@@ -625,9 +623,7 @@ export class ClientServerService {
|
||||
reply.header('Cache-Control', 'public, max-age=15');
|
||||
return await reply.view('channel', {
|
||||
channel: _channel,
|
||||
instanceName: meta.name ?? 'Misskey',
|
||||
icon: meta.iconUrl,
|
||||
themeColor: meta.themeColor,
|
||||
...this.generateCommonPugData(meta),
|
||||
});
|
||||
} else {
|
||||
return await renderBase(reply);
|
||||
|
@@ -31,11 +31,11 @@ html
|
||||
link(rel='apple-touch-icon' href= icon || '/apple-touch-icon.png')
|
||||
link(rel='manifest' href='/manifest.json')
|
||||
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='https://xn--931a.moe/assets/not-found.jpg')
|
||||
link(rel='prefetch' href='https://xn--931a.moe/assets/error.jpg')
|
||||
link(rel='prefetch' href=serverErrorImageUrl)
|
||||
link(rel='prefetch' href=infoImageUrl)
|
||||
link(rel='prefetch' href=notFoundImageUrl)
|
||||
//- https://github.com/misskey-dev/misskey/issues/9842
|
||||
link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v2.21.0')
|
||||
link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v2.22.0')
|
||||
link(rel='modulepreload' href=`/vite/${clientEntry.file}`)
|
||||
|
||||
if !config.clientManifestExists
|
||||
|
@@ -5,8 +5,8 @@ block vars
|
||||
- const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`;
|
||||
- const url = `${config.url}/notes/${note.id}`;
|
||||
- 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 video = (note.files || []).find(file => file.type.startsWith('video/') && !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.isSensitive)
|
||||
|
||||
block title
|
||||
= `${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)
|
@@ -7,10 +7,11 @@ import * as OTPAuth from 'otpauth';
|
||||
import { loadConfig } from '../../src/config.js';
|
||||
import { signup, api, post, react, startServer, waitFire } from '../utils.js';
|
||||
import type { INestApplicationContext } from '@nestjs/common';
|
||||
import type * as misskey from 'misskey-js';
|
||||
|
||||
describe('2要素認証', () => {
|
||||
let app: INestApplicationContext;
|
||||
let alice: unknown;
|
||||
let alice: misskey.entities.MeSignup;
|
||||
|
||||
const config = loadConfig();
|
||||
const password = 'test';
|
||||
@@ -68,7 +69,7 @@ describe('2要素認証', () => {
|
||||
]));
|
||||
|
||||
// AuthenticatorAssertionResponse.authenticatorData
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData
|
||||
const credentialIdLength = Buffer.allocUnsafe(2);
|
||||
credentialIdLength.writeUInt16BE(param.credentialId.length);
|
||||
const authData = Buffer.concat([
|
||||
@@ -80,7 +81,7 @@ describe('2要素認証', () => {
|
||||
param.credentialId,
|
||||
credentialPublicKey,
|
||||
]);
|
||||
|
||||
|
||||
return {
|
||||
attestationObject: cbor.encode({
|
||||
fmt: 'none',
|
||||
@@ -98,7 +99,7 @@ describe('2要素認証', () => {
|
||||
name: param.keyName,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
const signinParam = (): {
|
||||
username: string,
|
||||
password: string,
|
||||
@@ -130,7 +131,7 @@ describe('2要素認証', () => {
|
||||
'hcaptcha-response'?: string | null,
|
||||
} => {
|
||||
// AuthenticatorAssertionResponse.authenticatorData
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData
|
||||
const authenticatorData = Buffer.concat([
|
||||
rpIdHash(),
|
||||
Buffer.from([0x05]), // flags(1)
|
||||
@@ -146,7 +147,7 @@ describe('2要素認証', () => {
|
||||
.update(clientDataJSONBuffer)
|
||||
.digest();
|
||||
const privateKey = crypto.createPrivateKey(pemToSign);
|
||||
const signature = crypto.createSign('SHA256')
|
||||
const signature = crypto.createSign('SHA256')
|
||||
.update(Buffer.concat([authenticatorData, hashedclientDataJSON]))
|
||||
.sign(privateKey);
|
||||
return {
|
||||
@@ -186,14 +187,14 @@ describe('2要素認証', () => {
|
||||
token: otpToken(registerResponse.body.secret),
|
||||
}, alice);
|
||||
assert.strictEqual(doneResponse.status, 204);
|
||||
|
||||
|
||||
const usersShowResponse = await api('/users/show', {
|
||||
username,
|
||||
}, alice);
|
||||
assert.strictEqual(usersShowResponse.status, 200);
|
||||
assert.strictEqual(usersShowResponse.body.twoFactorEnabled, true);
|
||||
|
||||
const signinResponse = await api('/signin', {
|
||||
|
||||
const signinResponse = await api('/signin', {
|
||||
...signinParam(),
|
||||
token: otpToken(registerResponse.body.secret),
|
||||
});
|
||||
@@ -211,7 +212,7 @@ describe('2要素認証', () => {
|
||||
token: otpToken(registerResponse.body.secret),
|
||||
}, alice);
|
||||
assert.strictEqual(doneResponse.status, 204);
|
||||
|
||||
|
||||
const registerKeyResponse = await api('/i/2fa/register-key', {
|
||||
password,
|
||||
}, alice);
|
||||
@@ -230,7 +231,7 @@ describe('2要素認証', () => {
|
||||
assert.strictEqual(keyDoneResponse.status, 200);
|
||||
assert.strictEqual(keyDoneResponse.body.id, credentialId.toString('hex'));
|
||||
assert.strictEqual(keyDoneResponse.body.name, keyName);
|
||||
|
||||
|
||||
const usersShowResponse = await api('/users/show', {
|
||||
username,
|
||||
});
|
||||
@@ -267,7 +268,7 @@ describe('2要素認証', () => {
|
||||
token: otpToken(registerResponse.body.secret),
|
||||
}, alice);
|
||||
assert.strictEqual(doneResponse.status, 204);
|
||||
|
||||
|
||||
const registerKeyResponse = await api('/i/2fa/register-key', {
|
||||
password,
|
||||
}, alice);
|
||||
@@ -282,7 +283,7 @@ describe('2要素認証', () => {
|
||||
credentialId,
|
||||
}), alice);
|
||||
assert.strictEqual(keyDoneResponse.status, 200);
|
||||
|
||||
|
||||
const passwordLessResponse = await api('/i/2fa/password-less', {
|
||||
value: true,
|
||||
}, alice);
|
||||
@@ -301,7 +302,7 @@ describe('2要素認証', () => {
|
||||
assert.strictEqual(signinResponse.status, 200);
|
||||
assert.strictEqual(signinResponse.body.i, undefined);
|
||||
|
||||
const signinResponse2 = await api('/signin', {
|
||||
const signinResponse2 = await api('/signin', {
|
||||
...signinWithSecurityKeyParam({
|
||||
keyName,
|
||||
challengeId: signinResponse.body.challengeId,
|
||||
@@ -324,7 +325,7 @@ describe('2要素認証', () => {
|
||||
token: otpToken(registerResponse.body.secret),
|
||||
}, alice);
|
||||
assert.strictEqual(doneResponse.status, 204);
|
||||
|
||||
|
||||
const registerKeyResponse = await api('/i/2fa/register-key', {
|
||||
password,
|
||||
}, alice);
|
||||
@@ -339,14 +340,14 @@ describe('2要素認証', () => {
|
||||
credentialId,
|
||||
}), alice);
|
||||
assert.strictEqual(keyDoneResponse.status, 200);
|
||||
|
||||
|
||||
const renamedKey = 'other-key';
|
||||
const updateKeyResponse = await api('/i/2fa/update-key', {
|
||||
name: renamedKey,
|
||||
credentialId: credentialId.toString('hex'),
|
||||
}, alice);
|
||||
assert.strictEqual(updateKeyResponse.status, 200);
|
||||
|
||||
|
||||
const iResponse = await api('/i', {
|
||||
}, alice);
|
||||
assert.strictEqual(iResponse.status, 200);
|
||||
@@ -366,7 +367,7 @@ describe('2要素認証', () => {
|
||||
token: otpToken(registerResponse.body.secret),
|
||||
}, alice);
|
||||
assert.strictEqual(doneResponse.status, 204);
|
||||
|
||||
|
||||
const registerKeyResponse = await api('/i/2fa/register-key', {
|
||||
password,
|
||||
}, alice);
|
||||
@@ -381,7 +382,7 @@ describe('2要素認証', () => {
|
||||
credentialId,
|
||||
}), alice);
|
||||
assert.strictEqual(keyDoneResponse.status, 200);
|
||||
|
||||
|
||||
// テストの実行順によっては複数残ってるので全部消す
|
||||
const iResponse = await api('/i', {
|
||||
}, alice);
|
||||
@@ -400,14 +401,14 @@ describe('2要素認証', () => {
|
||||
assert.strictEqual(usersShowResponse.status, 200);
|
||||
assert.strictEqual(usersShowResponse.body.securityKeys, false);
|
||||
|
||||
const signinResponse = await api('/signin', {
|
||||
const signinResponse = await api('/signin', {
|
||||
...signinParam(),
|
||||
token: otpToken(registerResponse.body.secret),
|
||||
});
|
||||
assert.strictEqual(signinResponse.status, 200);
|
||||
assert.notEqual(signinResponse.body.i, undefined);
|
||||
});
|
||||
|
||||
|
||||
test('が設定でき、設定解除できる。(パスワードのみでログインできる。)', async () => {
|
||||
const registerResponse = await api('/i/2fa/register', {
|
||||
password,
|
||||
@@ -418,7 +419,7 @@ describe('2要素認証', () => {
|
||||
token: otpToken(registerResponse.body.secret),
|
||||
}, alice);
|
||||
assert.strictEqual(doneResponse.status, 204);
|
||||
|
||||
|
||||
const usersShowResponse = await api('/users/show', {
|
||||
username,
|
||||
});
|
||||
|
@@ -32,7 +32,7 @@ describe('アンテナ', () => {
|
||||
// - srcのenumにgroupが残っている
|
||||
// - userGroupIdが残っている, isActiveがない
|
||||
type Antenna = misskey.entities.Antenna | Packed<'Antenna'>;
|
||||
type User = misskey.entities.MeDetailed & { token: string };
|
||||
type User = misskey.entities.MeSignup;
|
||||
type Note = misskey.entities.Note;
|
||||
|
||||
// アンテナを作成できる最小のパラメタ
|
||||
|
@@ -3,6 +3,7 @@ process.env.NODE_ENV = 'test';
|
||||
import * as assert from 'assert';
|
||||
import { signup, api, post, startServer } from '../utils.js';
|
||||
import type { INestApplicationContext } from '@nestjs/common';
|
||||
import type * as misskey from 'misskey-js';
|
||||
|
||||
describe('API visibility', () => {
|
||||
let app: INestApplicationContext;
|
||||
@@ -18,15 +19,15 @@ describe('API visibility', () => {
|
||||
describe('Note visibility', () => {
|
||||
//#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を飛ばされる人 */
|
||||
let target2: any;
|
||||
let target2: misskey.entities.MeSignup;
|
||||
|
||||
/** public-post */
|
||||
let pub: any;
|
||||
|
@@ -1,14 +1,16 @@
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
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 * as misskey from 'misskey-js';
|
||||
import { IncomingMessage } from 'http';
|
||||
|
||||
describe('API', () => {
|
||||
let app: INestApplicationContext;
|
||||
let alice: any;
|
||||
let bob: any;
|
||||
let carol: any;
|
||||
let alice: misskey.entities.MeSignup;
|
||||
let bob: misskey.entities.MeSignup;
|
||||
let carol: misskey.entities.MeSignup;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await startServer();
|
||||
@@ -80,4 +82,178 @@ describe('API', () => {
|
||||
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 { signup, api, post, startServer } from '../utils.js';
|
||||
import type { INestApplicationContext } from '@nestjs/common';
|
||||
import type * as misskey from 'misskey-js';
|
||||
|
||||
describe('Block', () => {
|
||||
let app: INestApplicationContext;
|
||||
|
||||
// alice blocks bob
|
||||
let alice: any;
|
||||
let bob: any;
|
||||
let carol: any;
|
||||
let alice: misskey.entities.MeSignup;
|
||||
let bob: misskey.entities.MeSignup;
|
||||
let carol: misskey.entities.MeSignup;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await startServer();
|
||||
|
@@ -13,12 +13,12 @@ import { paramDef as UnfavoriteParamDef } from '@/server/api/endpoints/clips/unf
|
||||
import { paramDef as AddNoteParamDef } from '@/server/api/endpoints/clips/add-note.js';
|
||||
import { paramDef as RemoveNoteParamDef } from '@/server/api/endpoints/clips/remove-note.js';
|
||||
import { paramDef as NotesParamDef } from '@/server/api/endpoints/clips/notes.js';
|
||||
import {
|
||||
signup,
|
||||
post,
|
||||
startServer,
|
||||
import {
|
||||
signup,
|
||||
post,
|
||||
startServer,
|
||||
api,
|
||||
successfulApiCall,
|
||||
successfulApiCall,
|
||||
failedApiCall,
|
||||
ApiRequest,
|
||||
hiddenNote,
|
||||
@@ -82,14 +82,14 @@ describe('クリップ', () => {
|
||||
const update = async (parameters: Partial<UpdateParam>, request: Partial<ApiRequest> = {}): Promise<Clip> => {
|
||||
const clip = await successfulApiCall<Clip>({
|
||||
endpoint: '/clips/update',
|
||||
parameters: {
|
||||
parameters: {
|
||||
name: 'updated',
|
||||
...parameters,
|
||||
},
|
||||
user: alice,
|
||||
...request,
|
||||
});
|
||||
|
||||
|
||||
// 入力が結果として入っていること。clipIdはidになるので消しておく
|
||||
delete (parameters as { clipId?: string }).clipId;
|
||||
assert.deepStrictEqual(clip, {
|
||||
@@ -98,7 +98,7 @@ describe('クリップ', () => {
|
||||
});
|
||||
return clip;
|
||||
};
|
||||
|
||||
|
||||
type DeleteParam = JTDDataType<typeof DeleteParamDef>;
|
||||
const deleteClip = async (parameters: DeleteParam, request: Partial<ApiRequest> = {}): Promise<void> => {
|
||||
return await successfulApiCall<void>({
|
||||
@@ -129,7 +129,7 @@ describe('クリップ', () => {
|
||||
...request,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const usersClips = async (request: Partial<ApiRequest>): Promise<Clip[]> => {
|
||||
return await successfulApiCall<Clip[]>({
|
||||
endpoint: '/users/clips',
|
||||
@@ -145,14 +145,14 @@ describe('クリップ', () => {
|
||||
bob = await signup({ username: 'bob' });
|
||||
|
||||
// FIXME: misskey-jsのNoteはoutdatedなので直接変換できない
|
||||
aliceNote = await post(alice, { text: 'test' }) as any;
|
||||
aliceHomeNote = await post(alice, { text: 'home only', visibility: 'home' }) as any;
|
||||
aliceFollowersNote = await post(alice, { text: 'followers only', visibility: 'followers' }) as any;
|
||||
aliceSpecifiedNote = await post(alice, { text: 'specified only', visibility: 'specified' }) as any;
|
||||
bobNote = await post(bob, { text: 'test' }) as any;
|
||||
bobHomeNote = await post(bob, { text: 'home only', visibility: 'home' }) as any;
|
||||
bobFollowersNote = await post(bob, { text: 'followers only', visibility: 'followers' }) as any;
|
||||
bobSpecifiedNote = await post(bob, { text: 'specified only', visibility: 'specified' }) as any;
|
||||
aliceNote = await post(alice, { text: 'test' }) as any;
|
||||
aliceHomeNote = await post(alice, { text: 'home only', visibility: 'home' }) as any;
|
||||
aliceFollowersNote = await post(alice, { text: 'followers only', visibility: 'followers' }) as any;
|
||||
aliceSpecifiedNote = await post(alice, { text: 'specified only', visibility: 'specified' }) as any;
|
||||
bobNote = await post(bob, { text: 'test' }) as any;
|
||||
bobHomeNote = await post(bob, { text: 'home only', visibility: 'home' }) as any;
|
||||
bobFollowersNote = await post(bob, { text: 'followers only', visibility: 'followers' }) as any;
|
||||
bobSpecifiedNote = await post(bob, { text: 'specified only', visibility: 'specified' }) as any;
|
||||
}, 1000 * 60 * 2);
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -172,7 +172,7 @@ describe('クリップ', () => {
|
||||
test('の作成ができる', async () => {
|
||||
const res = await create();
|
||||
// ISO 8601で日付が返ってくること
|
||||
assert.strictEqual(res.createdAt, new Date(res.createdAt).toISOString());
|
||||
assert.strictEqual(res.createdAt, new Date(res.createdAt).toISOString());
|
||||
assert.strictEqual(res.lastClippedAt, null);
|
||||
assert.strictEqual(res.name, 'test');
|
||||
assert.strictEqual(res.description, null);
|
||||
@@ -217,7 +217,7 @@ describe('クリップ', () => {
|
||||
];
|
||||
test.each(createClipDenyPattern)('の作成は$labelならできない', async ({ parameters }) => failedApiCall({
|
||||
endpoint: '/clips/create',
|
||||
parameters: {
|
||||
parameters: {
|
||||
...defaultCreate(),
|
||||
...parameters,
|
||||
},
|
||||
@@ -229,7 +229,7 @@ describe('クリップ', () => {
|
||||
}));
|
||||
|
||||
test('の更新ができる', async () => {
|
||||
const res = await update({
|
||||
const res = await update({
|
||||
clipId: (await create()).id,
|
||||
name: 'updated',
|
||||
description: 'new description',
|
||||
@@ -237,7 +237,7 @@ describe('クリップ', () => {
|
||||
});
|
||||
|
||||
// ISO 8601で日付が返ってくること
|
||||
assert.strictEqual(res.createdAt, new Date(res.createdAt).toISOString());
|
||||
assert.strictEqual(res.createdAt, new Date(res.createdAt).toISOString());
|
||||
assert.strictEqual(res.lastClippedAt, null);
|
||||
assert.strictEqual(res.name, 'updated');
|
||||
assert.strictEqual(res.description, 'new description');
|
||||
@@ -251,7 +251,7 @@ describe('クリップ', () => {
|
||||
name: 'updated',
|
||||
...parameters,
|
||||
}));
|
||||
|
||||
|
||||
test.each([
|
||||
{ label: 'clipIdがnull', parameters: { clipId: null } },
|
||||
{ label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: {
|
||||
@@ -265,7 +265,7 @@ describe('クリップ', () => {
|
||||
...createClipDenyPattern as any,
|
||||
])('の更新は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({
|
||||
endpoint: '/clips/update',
|
||||
parameters: {
|
||||
parameters: {
|
||||
clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id,
|
||||
name: 'updated',
|
||||
...parameters,
|
||||
@@ -279,7 +279,7 @@ describe('クリップ', () => {
|
||||
}));
|
||||
|
||||
test('の削除ができる', async () => {
|
||||
await deleteClip({
|
||||
await deleteClip({
|
||||
clipId: (await create()).id,
|
||||
});
|
||||
assert.deepStrictEqual(await list({}), []);
|
||||
@@ -297,7 +297,7 @@ describe('クリップ', () => {
|
||||
} },
|
||||
])('の削除は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({
|
||||
endpoint: '/clips/delete',
|
||||
parameters: {
|
||||
parameters: {
|
||||
clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id,
|
||||
...parameters,
|
||||
},
|
||||
@@ -329,14 +329,14 @@ describe('クリップ', () => {
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ label: 'clipId未指定', parameters: { clipId: undefined } },
|
||||
{ label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: {
|
||||
{ label: 'clipId未指定', parameters: { clipId: undefined } },
|
||||
{ label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: {
|
||||
code: 'NO_SUCH_CLIP',
|
||||
id: 'c3c5fe33-d62c-44d2-9ea5-d997703f5c20',
|
||||
} },
|
||||
])('のID指定取得は$labelならできない', async ({ parameters, assetion }) => failedApiCall({
|
||||
endpoint: '/clips/show',
|
||||
parameters: {
|
||||
parameters: {
|
||||
...parameters,
|
||||
},
|
||||
user: alice,
|
||||
@@ -361,14 +361,14 @@ describe('クリップ', () => {
|
||||
|
||||
// 返ってくる配列には順序保障がないのでidでソートして厳密比較
|
||||
assert.deepStrictEqual(
|
||||
res.sort(compareBy(s => s.id)),
|
||||
res.sort(compareBy(s => s.id)),
|
||||
clips.sort(compareBy(s => s.id)),
|
||||
);
|
||||
});
|
||||
|
||||
test('の一覧が取得できる(空)', async () => {
|
||||
const res = await usersClips({
|
||||
parameters: {
|
||||
parameters: {
|
||||
userId: alice.id,
|
||||
},
|
||||
});
|
||||
@@ -381,14 +381,14 @@ describe('クリップ', () => {
|
||||
])('の一覧が$label取得できる', async () => {
|
||||
const clips = await createMany({ isPublic: true });
|
||||
const res = await usersClips({
|
||||
parameters: {
|
||||
parameters: {
|
||||
userId: alice.id,
|
||||
},
|
||||
});
|
||||
|
||||
// 返ってくる配列には順序保障がないのでidでソートして厳密比較
|
||||
assert.deepStrictEqual(
|
||||
res.sort(compareBy<Clip>(s => s.id)),
|
||||
res.sort(compareBy<Clip>(s => s.id)),
|
||||
clips.sort(compareBy(s => s.id)));
|
||||
|
||||
// 認証状態で見たときだけisFavoritedが入っている
|
||||
@@ -421,7 +421,7 @@ describe('クリップ', () => {
|
||||
await create({ isPublic: false });
|
||||
const aliceClip = await create({ isPublic: true });
|
||||
const res = await usersClips({
|
||||
parameters: {
|
||||
parameters: {
|
||||
userId: alice.id,
|
||||
limit: 2,
|
||||
},
|
||||
@@ -433,7 +433,7 @@ describe('クリップ', () => {
|
||||
const clips = await createMany({ isPublic: true }, 7);
|
||||
clips.sort(compareBy(s => s.id));
|
||||
const res = await usersClips({
|
||||
parameters: {
|
||||
parameters: {
|
||||
userId: alice.id,
|
||||
sinceId: clips[1].id,
|
||||
untilId: clips[5].id,
|
||||
@@ -443,7 +443,7 @@ describe('クリップ', () => {
|
||||
|
||||
// Promise.allで返ってくる配列には順序保障がないのでidでソートして厳密比較
|
||||
assert.deepStrictEqual(
|
||||
res.sort(compareBy<Clip>(s => s.id)),
|
||||
res.sort(compareBy<Clip>(s => s.id)),
|
||||
[clips[2], clips[3], clips[4]], // sinceIdとuntilId自体は結果に含まれない
|
||||
clips[1].id + ' ... ' + clips[3].id + ' with ' + clips.map(s => s.id) + ' vs. ' + res.map(s => s.id));
|
||||
});
|
||||
@@ -454,7 +454,7 @@ describe('クリップ', () => {
|
||||
{ label: 'limit最大+1', parameters: { limit: 101 } },
|
||||
])('の一覧は$labelだと取得できない', async ({ parameters }) => failedApiCall({
|
||||
endpoint: '/users/clips',
|
||||
parameters: {
|
||||
parameters: {
|
||||
userId: alice.id,
|
||||
...parameters,
|
||||
},
|
||||
@@ -520,7 +520,7 @@ describe('クリップ', () => {
|
||||
...request,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
beforeEach(async () => {
|
||||
aliceClip = await create();
|
||||
});
|
||||
@@ -544,7 +544,7 @@ describe('クリップ', () => {
|
||||
assert.strictEqual(clip2.favoritedCount, 1);
|
||||
assert.strictEqual(clip2.isFavorited, false);
|
||||
});
|
||||
|
||||
|
||||
test('は1つのクリップに対して複数人が設定できる。', async () => {
|
||||
const publicClip = await create({ isPublic: true });
|
||||
await favorite({ clipId: publicClip.id }, { user: bob });
|
||||
@@ -552,7 +552,7 @@ describe('クリップ', () => {
|
||||
const clip = await show({ clipId: publicClip.id }, { user: bob });
|
||||
assert.strictEqual(clip.favoritedCount, 2);
|
||||
assert.strictEqual(clip.isFavorited, true);
|
||||
|
||||
|
||||
const clip2 = await show({ clipId: publicClip.id });
|
||||
assert.strictEqual(clip2.favoritedCount, 2);
|
||||
assert.strictEqual(clip2.isFavorited, true);
|
||||
@@ -581,7 +581,7 @@ describe('クリップ', () => {
|
||||
await favorite({ clipId: aliceClip.id });
|
||||
await failedApiCall({
|
||||
endpoint: '/clips/favorite',
|
||||
parameters: {
|
||||
parameters: {
|
||||
clipId: aliceClip.id,
|
||||
},
|
||||
user: alice,
|
||||
@@ -604,7 +604,7 @@ describe('クリップ', () => {
|
||||
} },
|
||||
])('の設定は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({
|
||||
endpoint: '/clips/favorite',
|
||||
parameters: {
|
||||
parameters: {
|
||||
clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id,
|
||||
...parameters,
|
||||
},
|
||||
@@ -615,7 +615,7 @@ describe('クリップ', () => {
|
||||
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
|
||||
...assertion,
|
||||
}));
|
||||
|
||||
|
||||
test('を設定解除できる。', async () => {
|
||||
await favorite({ clipId: aliceClip.id });
|
||||
await unfavorite({ clipId: aliceClip.id });
|
||||
@@ -641,7 +641,7 @@ describe('クリップ', () => {
|
||||
} },
|
||||
])('の設定解除は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({
|
||||
endpoint: '/clips/unfavorite',
|
||||
parameters: {
|
||||
parameters: {
|
||||
clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id,
|
||||
...parameters,
|
||||
},
|
||||
@@ -652,7 +652,7 @@ describe('クリップ', () => {
|
||||
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
|
||||
...assertion,
|
||||
}));
|
||||
|
||||
|
||||
test('を取得できる。', async () => {
|
||||
await favorite({ clipId: aliceClip.id });
|
||||
const favorited = await myFavorites();
|
||||
@@ -717,7 +717,7 @@ describe('クリップ', () => {
|
||||
const res = await show({ clipId: aliceClip.id });
|
||||
assert.strictEqual(res.lastClippedAt, new Date(res.lastClippedAt ?? '').toISOString());
|
||||
assert.deepStrictEqual(await notes({ clipId: aliceClip.id }), [aliceNote]);
|
||||
|
||||
|
||||
// 他人の非公開ノートも突っ込める
|
||||
await addNote({ clipId: aliceClip.id, noteId: bobHomeNote.id });
|
||||
await addNote({ clipId: aliceClip.id, noteId: bobFollowersNote.id });
|
||||
@@ -728,7 +728,7 @@ describe('クリップ', () => {
|
||||
await addNote({ clipId: aliceClip.id, noteId: aliceNote.id });
|
||||
await failedApiCall({
|
||||
endpoint: '/clips/add-note',
|
||||
parameters: {
|
||||
parameters: {
|
||||
clipId: aliceClip.id,
|
||||
noteId: aliceNote.id,
|
||||
},
|
||||
@@ -747,10 +747,10 @@ describe('クリップ', () => {
|
||||
text: `test ${i}`,
|
||||
}) as unknown)) as Note[];
|
||||
await Promise.all(noteList.map(s => addNote({ clipId: aliceClip.id, noteId: s.id })));
|
||||
|
||||
|
||||
await failedApiCall({
|
||||
endpoint: '/clips/add-note',
|
||||
parameters: {
|
||||
parameters: {
|
||||
clipId: aliceClip.id,
|
||||
noteId: aliceNote.id,
|
||||
},
|
||||
@@ -764,7 +764,7 @@ describe('クリップ', () => {
|
||||
|
||||
test('は他人のクリップへ追加できない。', async () => await failedApiCall({
|
||||
endpoint: '/clips/add-note',
|
||||
parameters: {
|
||||
parameters: {
|
||||
clipId: aliceClip.id,
|
||||
noteId: aliceNote.id,
|
||||
},
|
||||
@@ -776,9 +776,9 @@ describe('クリップ', () => {
|
||||
}));
|
||||
|
||||
test.each([
|
||||
{ label: 'clipId未指定', parameters: { clipId: undefined } },
|
||||
{ label: 'noteId未指定', parameters: { noteId: undefined } },
|
||||
{ label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: {
|
||||
{ label: 'clipId未指定', parameters: { clipId: undefined } },
|
||||
{ label: 'noteId未指定', parameters: { noteId: undefined } },
|
||||
{ label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: {
|
||||
code: 'NO_SUCH_CLIP',
|
||||
id: 'd6e76cc0-a1b5-4c7c-a287-73fa9c716dcf',
|
||||
} },
|
||||
@@ -792,7 +792,7 @@ describe('クリップ', () => {
|
||||
} },
|
||||
])('の追加は$labelだとできない', async ({ parameters, user, assetion }) => failedApiCall({
|
||||
endpoint: '/clips/add-note',
|
||||
parameters: {
|
||||
parameters: {
|
||||
clipId: aliceClip.id,
|
||||
noteId: aliceNote.id,
|
||||
...parameters,
|
||||
@@ -810,11 +810,11 @@ describe('クリップ', () => {
|
||||
await removeNote({ clipId: aliceClip.id, noteId: aliceNote.id });
|
||||
assert.deepStrictEqual(await notes({ clipId: aliceClip.id }), []);
|
||||
});
|
||||
|
||||
|
||||
test.each([
|
||||
{ label: 'clipId未指定', parameters: { clipId: undefined } },
|
||||
{ label: 'noteId未指定', parameters: { noteId: undefined } },
|
||||
{ label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: {
|
||||
{ label: 'clipId未指定', parameters: { clipId: undefined } },
|
||||
{ label: 'noteId未指定', parameters: { noteId: undefined } },
|
||||
{ label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: {
|
||||
code: 'NO_SUCH_CLIP',
|
||||
id: 'b80525c6-97f7-49d7-a42d-ebccd49cfd52', // add-noteと異なる
|
||||
} },
|
||||
@@ -828,7 +828,7 @@ describe('クリップ', () => {
|
||||
} },
|
||||
])('の削除は$labelだとできない', async ({ parameters, user, assetion }) => failedApiCall({
|
||||
endpoint: '/clips/remove-note',
|
||||
parameters: {
|
||||
parameters: {
|
||||
clipId: aliceClip.id,
|
||||
noteId: aliceNote.id,
|
||||
...parameters,
|
||||
@@ -848,12 +848,12 @@ describe('クリップ', () => {
|
||||
}
|
||||
|
||||
const res = await notes({ clipId: aliceClip.id });
|
||||
|
||||
|
||||
// 自分のノートは非公開でも入れられるし、見える
|
||||
// 他人の非公開ノートは入れられるけど、除外される
|
||||
const expects = [
|
||||
aliceNote, aliceHomeNote, aliceFollowersNote, aliceSpecifiedNote,
|
||||
bobNote, bobHomeNote,
|
||||
bobNote, bobHomeNote,
|
||||
];
|
||||
assert.deepStrictEqual(
|
||||
res.sort(compareBy(s => s.id)),
|
||||
@@ -867,7 +867,7 @@ describe('クリップ', () => {
|
||||
await addNote({ clipId: aliceClip.id, noteId: note.id });
|
||||
}
|
||||
|
||||
const res = await notes({
|
||||
const res = await notes({
|
||||
clipId: aliceClip.id,
|
||||
sinceId: noteList[2].id,
|
||||
limit: 3,
|
||||
@@ -892,7 +892,7 @@ describe('クリップ', () => {
|
||||
sinceId: noteList[1].id,
|
||||
untilId: noteList[4].id,
|
||||
});
|
||||
|
||||
|
||||
// Promise.allで返ってくる配列はID順で並んでないのでソートして厳密比較
|
||||
const expects = [noteList[2], noteList[3]];
|
||||
assert.deepStrictEqual(
|
||||
@@ -918,7 +918,7 @@ describe('クリップ', () => {
|
||||
|
||||
const res = await notes({ clipId: publicClip.id }, { user: undefined });
|
||||
const expects = [
|
||||
aliceNote, aliceHomeNote,
|
||||
aliceNote, aliceHomeNote,
|
||||
// 認証なしだと非公開ノートは結果には含むけどhideされる。
|
||||
hiddenNote(aliceFollowersNote), hiddenNote(aliceSpecifiedNote),
|
||||
];
|
||||
@@ -926,7 +926,7 @@ describe('クリップ', () => {
|
||||
res.sort(compareBy(s => s.id)),
|
||||
expects.sort(compareBy(s => s.id)));
|
||||
});
|
||||
|
||||
|
||||
test.todo('ブロック、ミュートされたユーザーからの設定&取得etc.');
|
||||
|
||||
test.each([
|
||||
@@ -947,7 +947,7 @@ describe('クリップ', () => {
|
||||
} },
|
||||
])('は$labelだと取得できない', async ({ parameters, user, assertion }) => failedApiCall({
|
||||
endpoint: '/clips/notes',
|
||||
parameters: {
|
||||
parameters: {
|
||||
clipId: aliceClip.id,
|
||||
...parameters,
|
||||
},
|
||||
|
@@ -4,17 +4,18 @@ import * as assert from 'assert';
|
||||
// node-fetch only supports it's own Blob yet
|
||||
// https://github.com/node-fetch/node-fetch/pull/1664
|
||||
import { Blob } from 'node-fetch';
|
||||
import { User } from '@/models/index.js';
|
||||
import { startServer, signup, post, api, uploadFile, simpleGet, initTestDb } from '../utils.js';
|
||||
import type { INestApplicationContext } from '@nestjs/common';
|
||||
import { User } from '@/models/index.js';
|
||||
import type * as misskey from 'misskey-js';
|
||||
|
||||
describe('Endpoints', () => {
|
||||
let app: INestApplicationContext;
|
||||
|
||||
let alice: any;
|
||||
let bob: any;
|
||||
let carol: any;
|
||||
let dave: any;
|
||||
let alice: misskey.entities.MeSignup;
|
||||
let bob: misskey.entities.MeSignup;
|
||||
let carol: misskey.entities.MeSignup;
|
||||
let dave: misskey.entities.MeSignup;
|
||||
|
||||
beforeAll(async () => {
|
||||
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 type { SimpleGetResponse } from '../utils.js';
|
||||
import type { INestApplicationContext } from '@nestjs/common';
|
||||
import type * as misskey from 'misskey-js';
|
||||
|
||||
// Request Accept
|
||||
const ONLY_AP = 'application/activity+json';
|
||||
@@ -19,7 +20,7 @@ const JSON_UTF8 = 'application/json; charset=utf-8';
|
||||
describe('Webリソース', () => {
|
||||
let app: INestApplicationContext;
|
||||
|
||||
let alice: any;
|
||||
let alice: misskey.entities.MeSignup;
|
||||
let aliceUploadedFile: any;
|
||||
let alicesPost: any;
|
||||
let alicePage: any;
|
||||
@@ -28,8 +29,8 @@ describe('Webリソース', () => {
|
||||
let aliceGalleryPost: any;
|
||||
let aliceChannel: any;
|
||||
|
||||
type Request = {
|
||||
path: string,
|
||||
type Request = {
|
||||
path: string,
|
||||
accept?: string,
|
||||
cookie?: string,
|
||||
};
|
||||
@@ -46,7 +47,7 @@ describe('Webリソース', () => {
|
||||
const notOk = async (param: Request & {
|
||||
status?: number,
|
||||
code?: string,
|
||||
}): Promise<SimpleGetResponse> => {
|
||||
}): Promise<SimpleGetResponse> => {
|
||||
const { path, accept, cookie, status, code } = param;
|
||||
const res = await simpleGet(path, accept, cookie);
|
||||
assert.notStrictEqual(res.status, 200);
|
||||
@@ -58,8 +59,8 @@ describe('Webリソース', () => {
|
||||
}
|
||||
return res;
|
||||
};
|
||||
|
||||
const notFound = async (param: Request): Promise<SimpleGetResponse> => {
|
||||
|
||||
const notFound = async (param: Request): Promise<SimpleGetResponse> => {
|
||||
return await notOk({
|
||||
...param,
|
||||
status: 404,
|
||||
@@ -94,23 +95,23 @@ describe('Webリソース', () => {
|
||||
{ path: '/', type: HTML },
|
||||
{ path: '/docs/ja-JP/about', type: HTML }, // "指定されたURLに該当するページはありませんでした。"
|
||||
// fastify-static gives charset=UTF-8 instead of utf-8 and that's okay
|
||||
{ path: '/api-doc', type: 'text/html; charset=UTF-8' },
|
||||
{ path: '/api.json', type: JSON_UTF8 },
|
||||
{ path: '/api-console', type: HTML },
|
||||
{ path: '/_info_card_', type: HTML },
|
||||
{ path: '/bios', type: HTML },
|
||||
{ path: '/cli', type: HTML },
|
||||
{ path: '/flush', type: HTML },
|
||||
{ path: '/api-doc', type: 'text/html; charset=UTF-8' },
|
||||
{ path: '/api.json', type: JSON_UTF8 },
|
||||
{ path: '/api-console', type: HTML },
|
||||
{ path: '/_info_card_', type: HTML },
|
||||
{ path: '/bios', type: HTML },
|
||||
{ path: '/cli', type: HTML },
|
||||
{ path: '/flush', type: HTML },
|
||||
{ path: '/robots.txt', type: 'text/plain; charset=UTF-8' },
|
||||
{ path: '/favicon.ico', type: 'image/vnd.microsoft.icon' },
|
||||
{ path: '/favicon.ico', type: 'image/vnd.microsoft.icon' },
|
||||
{ path: '/opensearch.xml', type: 'application/opensearchdescription+xml' },
|
||||
{ path: '/apple-touch-icon.png', type: 'image/png' },
|
||||
{ path: '/twemoji/2764.svg', type: 'image/svg+xml' },
|
||||
{ path: '/twemoji/2764-fe0f-200d-1f525.svg', type: 'image/svg+xml' },
|
||||
{ path: '/twemoji-badge/2764.png', type: 'image/png' },
|
||||
{ path: '/apple-touch-icon.png', type: 'image/png' },
|
||||
{ path: '/twemoji/2764.svg', type: 'image/svg+xml' },
|
||||
{ path: '/twemoji/2764-fe0f-200d-1f525.svg', type: 'image/svg+xml' },
|
||||
{ path: '/twemoji-badge/2764.png', type: 'image/png' },
|
||||
{ path: '/twemoji-badge/2764-fe0f-200d-1f525.png', type: 'image/png' },
|
||||
{ path: '/fluent-emoji/2764.png', type: 'image/png' },
|
||||
{ path: '/fluent-emoji/2764-fe0f-200d-1f525.png', type: 'image/png' },
|
||||
{ path: '/fluent-emoji/2764.png', type: 'image/png' },
|
||||
{ path: '/fluent-emoji/2764-fe0f-200d-1f525.png', type: 'image/png' },
|
||||
])('$path', (p) => {
|
||||
test('がGETできる。', async () => await ok({ ...p }));
|
||||
|
||||
@@ -120,58 +121,58 @@ describe('Webリソース', () => {
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{ path: '/twemoji/2764.png' },
|
||||
{ path: '/twemoji/2764-fe0f-200d-1f525.png' },
|
||||
{ path: '/twemoji-badge/2764.svg' },
|
||||
{ path: '/twemoji/2764.png' },
|
||||
{ path: '/twemoji/2764-fe0f-200d-1f525.png' },
|
||||
{ path: '/twemoji-badge/2764.svg' },
|
||||
{ path: '/twemoji-badge/2764-fe0f-200d-1f525.svg' },
|
||||
{ path: '/fluent-emoji/2764.svg' },
|
||||
{ path: '/fluent-emoji/2764-fe0f-200d-1f525.svg' },
|
||||
{ path: '/fluent-emoji/2764.svg' },
|
||||
{ path: '/fluent-emoji/2764-fe0f-200d-1f525.svg' },
|
||||
])('$path', ({ path }) => {
|
||||
test('はGETできない。', async () => await notFound({ path }));
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{ ext: 'rss', type: 'application/rss+xml; charset=utf-8' },
|
||||
{ ext: 'atom', type: 'application/atom+xml; charset=utf-8' },
|
||||
{ ext: 'json', type: 'application/json; charset=utf-8' },
|
||||
{ ext: 'rss', type: 'application/rss+xml; charset=utf-8' },
|
||||
{ ext: 'atom', type: 'application/atom+xml; charset=utf-8' },
|
||||
{ ext: 'json', type: 'application/json; charset=utf-8' },
|
||||
])('/@:username.$ext', ({ ext, type }) => {
|
||||
const path = (username: string): string => `/@${username}.${ext}`;
|
||||
|
||||
test('がGETできる。', async () => await ok({
|
||||
test('がGETできる。', async () => await ok({
|
||||
path: path(alice.username),
|
||||
type,
|
||||
}));
|
||||
|
||||
test('は存在しないユーザーはGETできない。', async () => await notOk({
|
||||
test('は存在しないユーザーはGETできない。', async () => await notOk({
|
||||
path: path('nonexisting'),
|
||||
status: 404,
|
||||
status: 404,
|
||||
}));
|
||||
});
|
||||
|
||||
describe.each([{ path: '/api/foo' }])('$path', ({ path }) => {
|
||||
test('はGETできない。', async () => await notOk({
|
||||
test('はGETできない。', async () => await notOk({
|
||||
path,
|
||||
status: 404,
|
||||
status: 404,
|
||||
code: 'UNKNOWN_API_ENDPOINT',
|
||||
}));
|
||||
});
|
||||
|
||||
describe.each([{ path: '/queue' }])('$path', ({ path }) => {
|
||||
test('はadminでなければGETできない。', async () => await notOk({
|
||||
test('はadminでなければGETできない。', async () => await notOk({
|
||||
path,
|
||||
status: 500, // FIXME? 403ではない。
|
||||
}));
|
||||
|
||||
test('はadminならGETできる。', async () => await ok({
|
||||
|
||||
test('はadminならGETできる。', async () => await ok({
|
||||
path,
|
||||
cookie: cookie(alice),
|
||||
}));
|
||||
}));
|
||||
});
|
||||
|
||||
describe.each([{ path: '/streaming' }])('$path', ({ path }) => {
|
||||
test('はGETできない。', async () => await notOk({
|
||||
test('はGETできない。', async () => await notOk({
|
||||
path,
|
||||
status: 503,
|
||||
status: 503,
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -183,21 +184,21 @@ describe('Webリソース', () => {
|
||||
{ accept: UNSPECIFIED },
|
||||
])('(Acceptヘッダ: $accept)', ({ accept }) => {
|
||||
test('はHTMLとしてGETできる。', async () => {
|
||||
const res = await ok({
|
||||
path: path(alice.username),
|
||||
accept,
|
||||
const res = await ok({
|
||||
path: path(alice.username),
|
||||
accept,
|
||||
type: HTML,
|
||||
});
|
||||
assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username);
|
||||
assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id);
|
||||
|
||||
|
||||
// TODO ogタグの検証
|
||||
// TODO profile.noCrawleの検証
|
||||
// TODO twitter:creatorの検証
|
||||
// TODO <link rel="me" ...>の検証
|
||||
});
|
||||
test('はHTMLとしてGETできる。(存在しないIDでも。)', async () => await ok({
|
||||
path: path('xxxxxxxxxx'),
|
||||
test('はHTMLとしてGETできる。(存在しないIDでも。)', async () => await ok({
|
||||
path: path('xxxxxxxxxx'),
|
||||
type: HTML,
|
||||
}));
|
||||
});
|
||||
@@ -207,22 +208,22 @@ describe('Webリソース', () => {
|
||||
{ accept: PREFER_AP },
|
||||
])('(Acceptヘッダ: $accept)', ({ accept }) => {
|
||||
test('はActivityPubとしてGETできる。', async () => {
|
||||
const res = await ok({
|
||||
path: path(alice.username),
|
||||
accept,
|
||||
const res = await ok({
|
||||
path: path(alice.username),
|
||||
accept,
|
||||
type: AP,
|
||||
});
|
||||
assert.strictEqual(res.body.type, 'Person');
|
||||
});
|
||||
|
||||
test('は存在しないIDのときActivityPubとしてGETできない。', async () => await notFound({
|
||||
path: path('xxxxxxxxxx'),
|
||||
test('は存在しないIDのときActivityPubとしてGETできない。', async () => await notFound({
|
||||
path: path('xxxxxxxxxx'),
|
||||
accept,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
describe.each([
|
||||
// 実際のハンドルはフロントエンド(index.vue)で行われる
|
||||
{ sub: 'home' },
|
||||
{ sub: 'notes' },
|
||||
@@ -236,32 +237,32 @@ describe('Webリソース', () => {
|
||||
const path = (username: string): string => `/@${username}/${sub}`;
|
||||
|
||||
test('はHTMLとしてGETできる。', async () => {
|
||||
const res = await ok({
|
||||
path: path(alice.username),
|
||||
const res = await ok({
|
||||
path: path(alice.username),
|
||||
});
|
||||
assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username);
|
||||
assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('/@:user/pages/:page', () => {
|
||||
const path = (username: string, pagename: string): string => `/@${username}/pages/${pagename}`;
|
||||
|
||||
test('はHTMLとしてGETできる。', async () => {
|
||||
const res = await ok({
|
||||
path: path(alice.username, alicePage.name),
|
||||
const res = await ok({
|
||||
path: path(alice.username, alicePage.name),
|
||||
});
|
||||
assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username);
|
||||
assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id);
|
||||
assert.strictEqual(metaTag(res, 'misskey:page-id'), alicePage.id);
|
||||
|
||||
|
||||
// TODO ogタグの検証
|
||||
// TODO profile.noCrawleの検証
|
||||
// TODO twitter:creatorの検証
|
||||
});
|
||||
|
||||
test('はGETできる。(存在しないIDでも。)', async () => await ok({
|
||||
path: path(alice.username, 'xxxxxxxxxx'),
|
||||
|
||||
test('はGETできる。(存在しないIDでも。)', async () => await ok({
|
||||
path: path(alice.username, 'xxxxxxxxxx'),
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -278,7 +279,7 @@ describe('Webリソース', () => {
|
||||
assert.strictEqual(res.location, `/@${alice.username}`);
|
||||
});
|
||||
|
||||
test('は存在しないユーザーはGETできない。', async () => await notFound({
|
||||
test('は存在しないユーザーはGETできない。', async () => await notFound({
|
||||
path: path('xxxxxxxx'),
|
||||
}));
|
||||
});
|
||||
@@ -288,24 +289,24 @@ describe('Webリソース', () => {
|
||||
{ accept: PREFER_AP },
|
||||
])('(Acceptヘッダ: $accept)', ({ accept }) => {
|
||||
test('はActivityPubとしてGETできる。', async () => {
|
||||
const res = await ok({
|
||||
path: path(alice.id),
|
||||
accept,
|
||||
const res = await ok({
|
||||
path: path(alice.id),
|
||||
accept,
|
||||
type: AP,
|
||||
});
|
||||
assert.strictEqual(res.body.type, 'Person');
|
||||
});
|
||||
|
||||
test('は存在しないIDのときActivityPubとしてGETできない。', async () => await notOk({
|
||||
test('は存在しないIDのときActivityPubとしてGETできない。', async () => await notOk({
|
||||
path: path('xxxxxxxx'),
|
||||
accept,
|
||||
status: 404,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('/users/inbox', () => {
|
||||
test('がGETできる。(POST専用だけど4xx/5xxにならずHTMLが返ってくる)', async () => await ok({
|
||||
test('がGETできる。(POST専用だけど4xx/5xxにならずHTMLが返ってくる)', async () => await ok({
|
||||
path: '/inbox',
|
||||
}));
|
||||
|
||||
@@ -315,7 +316,7 @@ describe('Webリソース', () => {
|
||||
describe('/users/:id/inbox', () => {
|
||||
const path = (id: string): string => `/users/${id}/inbox`;
|
||||
|
||||
test('がGETできる。(POST専用だけど4xx/5xxにならずHTMLが返ってくる)', async () => await ok({
|
||||
test('がGETできる。(POST専用だけど4xx/5xxにならずHTMLが返ってくる)', async () => await ok({
|
||||
path: path(alice.id),
|
||||
}));
|
||||
|
||||
@@ -326,14 +327,14 @@ describe('Webリソース', () => {
|
||||
const path = (id: string): string => `/users/${id}/outbox`;
|
||||
|
||||
test('がGETできる。', async () => {
|
||||
const res = await ok({
|
||||
path: path(alice.id),
|
||||
const res = await ok({
|
||||
path: path(alice.id),
|
||||
type: AP,
|
||||
});
|
||||
assert.strictEqual(res.body.type, 'OrderedCollection');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('/notes/:id', () => {
|
||||
const path = (noteId: string): string => `/notes/${noteId}`;
|
||||
|
||||
@@ -342,22 +343,22 @@ describe('Webリソース', () => {
|
||||
{ accept: UNSPECIFIED },
|
||||
])('(Acceptヘッダ: $accept)', ({ accept }) => {
|
||||
test('はHTMLとしてGETできる。', async () => {
|
||||
const res = await ok({
|
||||
path: path(alicesPost.id),
|
||||
accept,
|
||||
const res = await ok({
|
||||
path: path(alicesPost.id),
|
||||
accept,
|
||||
type: HTML,
|
||||
});
|
||||
assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username);
|
||||
assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id);
|
||||
assert.strictEqual(metaTag(res, 'misskey:note-id'), alicesPost.id);
|
||||
|
||||
assert.strictEqual(metaTag(res, 'misskey:note-id'), alicesPost.id);
|
||||
|
||||
// TODO ogタグの検証
|
||||
// TODO profile.noCrawleの検証
|
||||
// TODO twitter:creatorの検証
|
||||
});
|
||||
|
||||
test('はHTMLとしてGETできる。(存在しないIDでも。)', async () => await ok({
|
||||
path: path('xxxxxxxxxx'),
|
||||
test('はHTMLとしてGETできる。(存在しないIDでも。)', async () => await ok({
|
||||
path: path('xxxxxxxxxx'),
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -366,48 +367,48 @@ describe('Webリソース', () => {
|
||||
{ accept: PREFER_AP },
|
||||
])('(Acceptヘッダ: $accept)', ({ accept }) => {
|
||||
test('はActivityPubとしてGETできる。', async () => {
|
||||
const res = await ok({
|
||||
path: path(alicesPost.id),
|
||||
const res = await ok({
|
||||
path: path(alicesPost.id),
|
||||
accept,
|
||||
type: AP,
|
||||
});
|
||||
assert.strictEqual(res.body.type, 'Note');
|
||||
});
|
||||
|
||||
test('は存在しないIDのときActivityPubとしてGETできない。', async () => await notFound({
|
||||
path: path('xxxxxxxxxx'),
|
||||
test('は存在しないIDのときActivityPubとしてGETできない。', async () => await notFound({
|
||||
path: path('xxxxxxxxxx'),
|
||||
accept,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('/play/:id', () => {
|
||||
const path = (playid: string): string => `/play/${playid}`;
|
||||
|
||||
test('がGETできる。', async () => {
|
||||
const res = await ok({
|
||||
path: path(alicePlay.id),
|
||||
const res = await ok({
|
||||
path: path(alicePlay.id),
|
||||
});
|
||||
assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username);
|
||||
assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id);
|
||||
assert.strictEqual(metaTag(res, 'misskey:flash-id'), alicePlay.id);
|
||||
|
||||
|
||||
// TODO ogタグの検証
|
||||
// TODO profile.noCrawleの検証
|
||||
// TODO twitter:creatorの検証
|
||||
});
|
||||
|
||||
test('がGETできる。(存在しないIDでも。)', async () => await ok({
|
||||
path: path('xxxxxxxxxx'),
|
||||
test('がGETできる。(存在しないIDでも。)', async () => await ok({
|
||||
path: path('xxxxxxxxxx'),
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
describe('/clips/:clip', () => {
|
||||
const path = (clip: string): string => `/clips/${clip}`;
|
||||
|
||||
test('がGETできる。', async () => {
|
||||
const res = await ok({
|
||||
path: path(aliceClip.id),
|
||||
const res = await ok({
|
||||
path: path(aliceClip.id),
|
||||
});
|
||||
assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username);
|
||||
assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id);
|
||||
@@ -416,9 +417,9 @@ describe('Webリソース', () => {
|
||||
// TODO ogタグの検証
|
||||
// TODO profile.noCrawleの検証
|
||||
});
|
||||
|
||||
test('がGETできる。(存在しないIDでも。)', async () => await ok({
|
||||
path: path('xxxxxxxxxx'),
|
||||
|
||||
test('がGETできる。(存在しないIDでも。)', async () => await ok({
|
||||
path: path('xxxxxxxxxx'),
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -426,8 +427,8 @@ describe('Webリソース', () => {
|
||||
const path = (post: string): string => `/gallery/${post}`;
|
||||
|
||||
test('がGETできる。', async () => {
|
||||
const res = await ok({
|
||||
path: path(aliceGalleryPost.id),
|
||||
const res = await ok({
|
||||
path: path(aliceGalleryPost.id),
|
||||
});
|
||||
assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username);
|
||||
assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id);
|
||||
@@ -436,26 +437,26 @@ describe('Webリソース', () => {
|
||||
// TODO profile.noCrawleの検証
|
||||
// TODO twitter:creatorの検証
|
||||
});
|
||||
|
||||
test('がGETできる。(存在しないIDでも。)', async () => await ok({
|
||||
path: path('xxxxxxxxxx'),
|
||||
|
||||
test('がGETできる。(存在しないIDでも。)', async () => await ok({
|
||||
path: path('xxxxxxxxxx'),
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
describe('/channels/:channel', () => {
|
||||
const path = (channel: string): string => `/channels/${channel}`;
|
||||
|
||||
test('はGETできる。', async () => {
|
||||
const res = await ok({
|
||||
path: path(aliceChannel.id),
|
||||
path: path(aliceChannel.id),
|
||||
});
|
||||
|
||||
// FIXME: misskey関連のmetaタグの設定がない
|
||||
// TODO ogタグの検証
|
||||
});
|
||||
|
||||
test('がGETできる。(存在しないIDでも。)', async () => await ok({
|
||||
path: path('xxxxxxxxxx'),
|
||||
|
||||
test('がGETできる。(存在しないIDでも。)', async () => await ok({
|
||||
path: path('xxxxxxxxxx'),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
@@ -3,12 +3,13 @@ process.env.NODE_ENV = 'test';
|
||||
import * as assert from 'assert';
|
||||
import { signup, api, startServer, simpleGet } from '../utils.js';
|
||||
import type { INestApplicationContext } from '@nestjs/common';
|
||||
import type * as misskey from 'misskey-js';
|
||||
|
||||
describe('FF visibility', () => {
|
||||
let app: INestApplicationContext;
|
||||
|
||||
let alice: any;
|
||||
let bob: any;
|
||||
let alice: misskey.entities.MeSignup;
|
||||
let bob: misskey.entities.MeSignup;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await startServer();
|
||||
|
@@ -1,12 +1,13 @@
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import * as assert from 'assert';
|
||||
import rndstr from 'rndstr';
|
||||
import { loadConfig } from '@/config.js';
|
||||
import { User, UsersRepository } from '@/models/index.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 type { INestApplicationContext } from '@nestjs/common';
|
||||
import type * as misskey from 'misskey-js';
|
||||
|
||||
describe('Account Move', () => {
|
||||
let app: INestApplicationContext;
|
||||
@@ -14,12 +15,12 @@ describe('Account Move', () => {
|
||||
let url: URL;
|
||||
|
||||
let root: any;
|
||||
let alice: any;
|
||||
let bob: any;
|
||||
let carol: any;
|
||||
let dave: any;
|
||||
let eve: any;
|
||||
let frank: any;
|
||||
let alice: misskey.entities.MeSignup;
|
||||
let bob: misskey.entities.MeSignup;
|
||||
let carol: misskey.entities.MeSignup;
|
||||
let dave: misskey.entities.MeSignup;
|
||||
let eve: misskey.entities.MeSignup;
|
||||
let frank: misskey.entities.MeSignup;
|
||||
|
||||
let Users: UsersRepository;
|
||||
|
||||
@@ -162,7 +163,7 @@ describe('Account Move', () => {
|
||||
alsoKnownAs: [`@alice@${url.hostname}`],
|
||||
}, root);
|
||||
const listRoot = await api('/users/lists/create', {
|
||||
name: rndstr('0-9a-z', 8),
|
||||
name: secureRndstr(8),
|
||||
}, root);
|
||||
await api('/users/lists/push', {
|
||||
listId: listRoot.body.id,
|
||||
@@ -176,9 +177,9 @@ describe('Account Move', () => {
|
||||
userId: eve.id,
|
||||
}, alice);
|
||||
const antenna = await api('/antennas/create', {
|
||||
name: rndstr('0-9a-z', 8),
|
||||
name: secureRndstr(8),
|
||||
src: 'home',
|
||||
keywords: [rndstr('0-9a-z', 8)],
|
||||
keywords: [secureRndstr(8)],
|
||||
excludeKeywords: [],
|
||||
users: [],
|
||||
caseSensitive: false,
|
||||
@@ -210,7 +211,7 @@ describe('Account Move', () => {
|
||||
userId: dave.id,
|
||||
}, eve);
|
||||
const listEve = await api('/users/lists/create', {
|
||||
name: rndstr('0-9a-z', 8),
|
||||
name: secureRndstr(8),
|
||||
}, eve);
|
||||
await api('/users/lists/push', {
|
||||
listId: listEve.body.id,
|
||||
@@ -419,9 +420,9 @@ describe('Account Move', () => {
|
||||
test('Prohibit access after moving: /antennas/update', async () => {
|
||||
const res = await api('/antennas/update', {
|
||||
antennaId,
|
||||
name: rndstr('0-9a-z', 8),
|
||||
name: secureRndstr(8),
|
||||
src: 'users',
|
||||
keywords: [rndstr('0-9a-z', 8)],
|
||||
keywords: [secureRndstr(8)],
|
||||
excludeKeywords: [],
|
||||
users: [eve.id],
|
||||
caseSensitive: false,
|
||||
|
@@ -3,14 +3,15 @@ process.env.NODE_ENV = 'test';
|
||||
import * as assert from 'assert';
|
||||
import { signup, api, post, react, startServer, waitFire } from '../utils.js';
|
||||
import type { INestApplicationContext } from '@nestjs/common';
|
||||
import type * as misskey from 'misskey-js';
|
||||
|
||||
describe('Mute', () => {
|
||||
let app: INestApplicationContext;
|
||||
|
||||
// alice mutes carol
|
||||
let alice: any;
|
||||
let bob: any;
|
||||
let carol: any;
|
||||
let alice: misskey.entities.MeSignup;
|
||||
let bob: misskey.entities.MeSignup;
|
||||
let carol: misskey.entities.MeSignup;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await startServer();
|
||||
|
@@ -4,13 +4,14 @@ import * as assert from 'assert';
|
||||
import { Note } from '@/models/entities/Note.js';
|
||||
import { signup, post, uploadUrl, startServer, initTestDb, api, uploadFile } from '../utils.js';
|
||||
import type { INestApplicationContext } from '@nestjs/common';
|
||||
import type * as misskey from 'misskey-js';
|
||||
|
||||
describe('Note', () => {
|
||||
let app: INestApplicationContext;
|
||||
let Notes: any;
|
||||
|
||||
let alice: any;
|
||||
let bob: any;
|
||||
let alice: misskey.entities.MeSignup;
|
||||
let bob: misskey.entities.MeSignup;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await startServer();
|
||||
@@ -378,7 +379,7 @@ describe('Note', () => {
|
||||
},
|
||||
},
|
||||
}, alice);
|
||||
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
|
||||
const assign = await api('admin/roles/assign', {
|
||||
|
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 { signup, api, post, react, startServer, waitFire } from '../utils.js';
|
||||
import type { INestApplicationContext } from '@nestjs/common';
|
||||
import type * as misskey from 'misskey-js';
|
||||
|
||||
describe('Renote Mute', () => {
|
||||
let app: INestApplicationContext;
|
||||
|
||||
// alice mutes carol
|
||||
let alice: any;
|
||||
let bob: any;
|
||||
let carol: any;
|
||||
let alice: misskey.entities.MeSignup;
|
||||
let bob: misskey.entities.MeSignup;
|
||||
let carol: misskey.entities.MeSignup;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await startServer();
|
||||
|
@@ -4,6 +4,7 @@ import * as assert from 'assert';
|
||||
import { Following } from '@/models/entities/Following.js';
|
||||
import { connectStream, signup, api, post, startServer, initTestDb, waitFire } from '../utils.js';
|
||||
import type { INestApplicationContext } from '@nestjs/common';
|
||||
import type * as misskey from 'misskey-js';
|
||||
|
||||
describe('Streaming', () => {
|
||||
let app: INestApplicationContext;
|
||||
@@ -26,13 +27,13 @@ describe('Streaming', () => {
|
||||
|
||||
describe('Streaming', () => {
|
||||
// Local users
|
||||
let ayano: any;
|
||||
let kyoko: any;
|
||||
let chitose: any;
|
||||
let ayano: misskey.entities.MeSignup;
|
||||
let kyoko: misskey.entities.MeSignup;
|
||||
let chitose: misskey.entities.MeSignup;
|
||||
|
||||
// Remote users
|
||||
let akari: any;
|
||||
let chinatsu: any;
|
||||
let akari: misskey.entities.MeSignup;
|
||||
let chinatsu: misskey.entities.MeSignup;
|
||||
|
||||
let kyokoNote: any;
|
||||
let list: any;
|
||||
|
@@ -3,13 +3,14 @@ process.env.NODE_ENV = 'test';
|
||||
import * as assert from 'assert';
|
||||
import { signup, api, post, connectStream, startServer } from '../utils.js';
|
||||
import type { INestApplicationContext } from '@nestjs/common';
|
||||
import type * as misskey from 'misskey-js';
|
||||
|
||||
describe('Note thread mute', () => {
|
||||
let app: INestApplicationContext;
|
||||
|
||||
let alice: any;
|
||||
let bob: any;
|
||||
let carol: any;
|
||||
let alice: misskey.entities.MeSignup;
|
||||
let bob: misskey.entities.MeSignup;
|
||||
let carol: misskey.entities.MeSignup;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await startServer();
|
||||
|
@@ -3,11 +3,12 @@ process.env.NODE_ENV = 'test';
|
||||
import * as assert from 'assert';
|
||||
import { signup, api, post, uploadUrl, startServer } from '../utils.js';
|
||||
import type { INestApplicationContext } from '@nestjs/common';
|
||||
import type * as misskey from 'misskey-js';
|
||||
|
||||
describe('users/notes', () => {
|
||||
let app: INestApplicationContext;
|
||||
|
||||
let alice: any;
|
||||
let alice: misskey.entities.MeSignup;
|
||||
let jpgNote: any;
|
||||
let pngNote: any;
|
||||
let jpgPngNote: any;
|
||||
|
@@ -4,14 +4,14 @@ import * as assert from 'assert';
|
||||
import { inspect } from 'node:util';
|
||||
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import {
|
||||
signup,
|
||||
post,
|
||||
import {
|
||||
signup,
|
||||
post,
|
||||
page,
|
||||
role,
|
||||
startServer,
|
||||
startServer,
|
||||
api,
|
||||
successfulApiCall,
|
||||
successfulApiCall,
|
||||
failedApiCall,
|
||||
uploadFile,
|
||||
} from '../utils.js';
|
||||
@@ -36,19 +36,19 @@ describe('ユーザー', () => {
|
||||
badgeRoles: any[],
|
||||
};
|
||||
|
||||
type UserDetailedNotMe = UserLite &
|
||||
type UserDetailedNotMe = UserLite &
|
||||
misskey.entities.UserDetailed & {
|
||||
roles: any[],
|
||||
};
|
||||
|
||||
type MeDetailed = UserDetailedNotMe &
|
||||
type MeDetailed = UserDetailedNotMe &
|
||||
misskey.entities.MeDetailed & {
|
||||
achievements: object[],
|
||||
loggedInDays: number,
|
||||
policies: object,
|
||||
};
|
||||
|
||||
type User = MeDetailed & { token: string };
|
||||
|
||||
type User = MeDetailed & { token: string };
|
||||
|
||||
const show = async (id: string, me = root): Promise<MeDetailed | UserDetailedNotMe> => {
|
||||
return successfulApiCall({ endpoint: 'users/show', parameters: { userId: id }, user: me }) as any;
|
||||
@@ -159,7 +159,7 @@ describe('ユーザー', () => {
|
||||
mutedInstances: user.mutedInstances,
|
||||
mutingNotificationTypes: user.mutingNotificationTypes,
|
||||
emailNotificationTypes: user.emailNotificationTypes,
|
||||
achievements: user.achievements,
|
||||
achievements: user.achievements,
|
||||
loggedInDays: user.loggedInDays,
|
||||
policies: user.policies,
|
||||
...(security ? {
|
||||
@@ -222,11 +222,11 @@ describe('ユーザー', () => {
|
||||
beforeAll(async () => {
|
||||
root = await signup({ username: 'root' });
|
||||
alice = await signup({ username: 'alice' });
|
||||
aliceNote = await post(alice, { text: 'test' }) as any;
|
||||
aliceNote = await post(alice, { text: 'test' }) as any;
|
||||
alicePage = await page(alice);
|
||||
aliceList = (await api('users/list/create', { name: 'aliceList' }, alice)).body;
|
||||
bob = await signup({ username: 'bob' });
|
||||
bobNote = await post(bob, { text: 'test' }) as any;
|
||||
bobNote = await post(bob, { text: 'test' }) as any;
|
||||
carol = await signup({ username: 'carol' });
|
||||
dave = await signup({ username: 'dave' });
|
||||
ellen = await signup({ username: 'ellen' });
|
||||
@@ -236,10 +236,10 @@ describe('ユーザー', () => {
|
||||
usersReplying = await [...Array(10)].map((_, i) => i).reduce(async (acc, i) => {
|
||||
const u = await signup({ username: `replying${i}` });
|
||||
for (let j = 0; j < 10 - i; j++) {
|
||||
const p = await post(u, { text: `test${j}` });
|
||||
const p = await post(u, { text: `test${j}` });
|
||||
await post(alice, { text: `@${u.username} test${j}`, replyId: p.id });
|
||||
}
|
||||
|
||||
|
||||
return (await acc).concat(u);
|
||||
}, Promise.resolve([] as User[]));
|
||||
|
||||
@@ -376,7 +376,7 @@ describe('ユーザー', () => {
|
||||
assert.strictEqual(response.securityKeys, false);
|
||||
assert.deepStrictEqual(response.roles, []);
|
||||
assert.strictEqual(response.memo, null);
|
||||
|
||||
|
||||
// MeDetailedOnly
|
||||
assert.strictEqual(response.avatarId, null);
|
||||
assert.strictEqual(response.bannerId, null);
|
||||
@@ -406,7 +406,7 @@ describe('ユーザー', () => {
|
||||
assert.deepStrictEqual(response.emailNotificationTypes, ['follow', 'receiveFollowRequest']);
|
||||
assert.deepStrictEqual(response.achievements, []);
|
||||
assert.deepStrictEqual(response.loggedInDays, 0);
|
||||
assert.deepStrictEqual(response.policies, DEFAULT_POLICIES);
|
||||
assert.deepStrictEqual(response.policies, DEFAULT_POLICIES);
|
||||
assert.notStrictEqual(response.email, undefined);
|
||||
assert.strictEqual(response.emailVerified, false);
|
||||
assert.deepStrictEqual(response.securityKeysList, []);
|
||||
@@ -499,8 +499,8 @@ describe('ユーザー', () => {
|
||||
const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters, user: alice });
|
||||
assert.match(response.avatarUrl ?? '.', /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/);
|
||||
assert.match(response.avatarBlurhash ?? '.', /[ -~]{54}/);
|
||||
const expected = {
|
||||
...meDetailed(alice, true),
|
||||
const expected = {
|
||||
...meDetailed(alice, true),
|
||||
avatarId: aliceFile.id,
|
||||
avatarBlurhash: response.avatarBlurhash,
|
||||
avatarUrl: response.avatarUrl,
|
||||
@@ -509,8 +509,8 @@ describe('ユーザー', () => {
|
||||
|
||||
const parameters2 = { avatarId: null };
|
||||
const response2 = await successfulApiCall({ endpoint: 'i/update', parameters: parameters2, user: alice });
|
||||
const expected2 = {
|
||||
...meDetailed(alice, true),
|
||||
const expected2 = {
|
||||
...meDetailed(alice, true),
|
||||
avatarId: null,
|
||||
avatarBlurhash: null,
|
||||
avatarUrl: alice.avatarUrl, // 解除した場合、identiconになる
|
||||
@@ -524,8 +524,8 @@ describe('ユーザー', () => {
|
||||
const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters, user: alice });
|
||||
assert.match(response.bannerUrl ?? '.', /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/);
|
||||
assert.match(response.bannerBlurhash ?? '.', /[ -~]{54}/);
|
||||
const expected = {
|
||||
...meDetailed(alice, true),
|
||||
const expected = {
|
||||
...meDetailed(alice, true),
|
||||
bannerId: aliceFile.id,
|
||||
bannerBlurhash: response.bannerBlurhash,
|
||||
bannerUrl: response.bannerUrl,
|
||||
@@ -534,8 +534,8 @@ describe('ユーザー', () => {
|
||||
|
||||
const parameters2 = { bannerId: null };
|
||||
const response2 = await successfulApiCall({ endpoint: 'i/update', parameters: parameters2, user: alice });
|
||||
const expected2 = {
|
||||
...meDetailed(alice, true),
|
||||
const expected2 = {
|
||||
...meDetailed(alice, true),
|
||||
bannerId: null,
|
||||
bannerBlurhash: null,
|
||||
bannerUrl: null,
|
||||
@@ -551,7 +551,7 @@ describe('ユーザー', () => {
|
||||
const response = await successfulApiCall({ endpoint: 'i/pin', parameters, user: alice });
|
||||
const expected = { ...meDetailed(alice, false), pinnedNoteIds: [aliceNote.id], pinnedNotes: [aliceNote] };
|
||||
assert.deepStrictEqual(response, expected);
|
||||
|
||||
|
||||
const response2 = await successfulApiCall({ endpoint: 'i/unpin', parameters, user: alice });
|
||||
const expected2 = meDetailed(alice, false);
|
||||
assert.deepStrictEqual(response2, expected2);
|
||||
@@ -612,7 +612,7 @@ describe('ユーザー', () => {
|
||||
});
|
||||
test.todo('をリスト形式で取得することができる(リモート, hostname指定)');
|
||||
test.todo('をリスト形式で取得することができる(pagenation)');
|
||||
|
||||
|
||||
//#endregion
|
||||
//#region ユーザー情報(users/show)
|
||||
|
||||
@@ -684,9 +684,9 @@ describe('ユーザー', () => {
|
||||
const parameters = { userIds: [bob.id, alice.id, carol.id] };
|
||||
const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: alice });
|
||||
const expected = [
|
||||
await successfulApiCall({ endpoint: 'users/show', parameters: { userId: bob.id }, user: alice }),
|
||||
await successfulApiCall({ endpoint: 'users/show', parameters: { userId: alice.id }, user: alice }),
|
||||
await successfulApiCall({ endpoint: 'users/show', parameters: { userId: carol.id }, user: alice }),
|
||||
await successfulApiCall({ endpoint: 'users/show', parameters: { userId: bob.id }, user: alice }),
|
||||
await successfulApiCall({ endpoint: 'users/show', parameters: { userId: alice.id }, user: alice }),
|
||||
await successfulApiCall({ endpoint: 'users/show', parameters: { userId: carol.id }, user: alice }),
|
||||
];
|
||||
assert.deepStrictEqual(response, expected);
|
||||
});
|
||||
@@ -701,7 +701,7 @@ describe('ユーザー', () => {
|
||||
// BUG サスペンドユーザーを一般ユーザーから見るとrootユーザーが返ってくる
|
||||
//{ label: 'サスペンドユーザーが(一般ユーザーが見るときは)含まれない', user: (): User => userSuspended, me: (): User => bob, excluded: true },
|
||||
{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
|
||||
{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
|
||||
{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
|
||||
] as const)('をID指定のリスト形式で取得することができ、結果に$label', async ({ user, me, excluded }) => {
|
||||
const parameters = { userIds: [user().id] };
|
||||
const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: me?.() ?? alice });
|
||||
@@ -734,7 +734,7 @@ describe('ユーザー', () => {
|
||||
{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
|
||||
{ label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true },
|
||||
{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
|
||||
{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
|
||||
{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
|
||||
] as const)('を検索することができ、結果に$labelが含まれる', async ({ user, excluded }) => {
|
||||
const parameters = { query: user().username, limit: 1 };
|
||||
const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice });
|
||||
@@ -747,7 +747,7 @@ describe('ユーザー', () => {
|
||||
//#endregion
|
||||
//#region ID指定検索(users/search-by-username-and-host)
|
||||
|
||||
test.each([
|
||||
test.each([
|
||||
{ label: '自分', parameters: { username: 'alice' }, user: (): User[] => [alice] },
|
||||
{ label: '自分かつusernameが大文字', parameters: { username: 'ALICE' }, user: (): User[] => [alice] },
|
||||
{ label: 'ローカルのフォロイーでノートなし', parameters: { username: 'userFollowedByAlice' }, user: (): User[] => [userFollowedByAlice] },
|
||||
@@ -786,7 +786,7 @@ describe('ユーザー', () => {
|
||||
test('がよくリプライをするユーザーのリストを取得できる', async () => {
|
||||
const parameters = { userId: alice.id, limit: 5 };
|
||||
const response = await successfulApiCall({ endpoint: 'users/get-frequently-replied-users', parameters, user: alice });
|
||||
const expected = await Promise.all(usersReplying.slice(0, parameters.limit).map(async (s, i) => ({
|
||||
const expected = await Promise.all(usersReplying.slice(0, parameters.limit).map(async (s, i) => ({
|
||||
user: await show(s.id, alice),
|
||||
weight: (usersReplying.length - i) / usersReplying.length,
|
||||
})));
|
||||
|
@@ -9,9 +9,9 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"declaration": false,
|
||||
"sourceMap": true,
|
||||
"target": "es2021",
|
||||
"target": "ES2022",
|
||||
"module": "es2020",
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "node16",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"removeComments": false,
|
||||
"noLib": false,
|
||||
@@ -39,6 +39,6 @@
|
||||
"include": [
|
||||
"./**/*.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 { Test } from '@nestjs/testing';
|
||||
import * as lolex from '@sinonjs/fake-timers';
|
||||
import rndstr from 'rndstr';
|
||||
import { GlobalModule } from '@/GlobalModule.js';
|
||||
import { RoleService } from '@/core/RoleService.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 { IdService } from '@/core/IdService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||
import { sleep } from '../utils.js';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import type { MockFunctionMetadata } from 'jest-mock';
|
||||
@@ -30,7 +30,7 @@ describe('RoleService', () => {
|
||||
let clock: lolex.InstalledClock;
|
||||
|
||||
function createUser(data: Partial<User> = {}) {
|
||||
const un = rndstr('a-z0-9', 16);
|
||||
const un = secureRndstr(16);
|
||||
return usersRepository.insert({
|
||||
id: genAid(new Date()),
|
||||
createdAt: new Date(),
|
||||
@@ -106,19 +106,19 @@ describe('RoleService', () => {
|
||||
});
|
||||
|
||||
describe('getUserPolicies', () => {
|
||||
test('instance default policies', async () => {
|
||||
test('instance default policies', async () => {
|
||||
const user = await createUser();
|
||||
metaService.fetch.mockResolvedValue({
|
||||
policies: {
|
||||
canManageCustomEmojis: false,
|
||||
},
|
||||
} as any);
|
||||
|
||||
|
||||
const result = await roleService.getUserPolicies(user.id);
|
||||
|
||||
|
||||
expect(result.canManageCustomEmojis).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
test('instance default policies 2', async () => {
|
||||
const user = await createUser();
|
||||
metaService.fetch.mockResolvedValue({
|
||||
@@ -126,12 +126,12 @@ describe('RoleService', () => {
|
||||
canManageCustomEmojis: true,
|
||||
},
|
||||
} as any);
|
||||
|
||||
|
||||
const result = await roleService.getUserPolicies(user.id);
|
||||
|
||||
|
||||
expect(result.canManageCustomEmojis).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
test('with role', async () => {
|
||||
const user = await createUser();
|
||||
const role = await createRole({
|
||||
@@ -150,9 +150,9 @@ describe('RoleService', () => {
|
||||
canManageCustomEmojis: false,
|
||||
},
|
||||
} as any);
|
||||
|
||||
|
||||
const result = await roleService.getUserPolicies(user.id);
|
||||
|
||||
|
||||
expect(result.canManageCustomEmojis).toBe(true);
|
||||
});
|
||||
|
||||
@@ -185,9 +185,9 @@ describe('RoleService', () => {
|
||||
driveCapacityMb: 50,
|
||||
},
|
||||
} as any);
|
||||
|
||||
|
||||
const result = await roleService.getUserPolicies(user.id);
|
||||
|
||||
|
||||
expect(result.driveCapacityMb).toBe(100);
|
||||
});
|
||||
|
||||
@@ -226,7 +226,7 @@ describe('RoleService', () => {
|
||||
canManageCustomEmojis: false,
|
||||
},
|
||||
} as any);
|
||||
|
||||
|
||||
const user1Policies = await roleService.getUserPolicies(user1.id);
|
||||
const user2Policies = await roleService.getUserPolicies(user2.id);
|
||||
expect(user1Policies.canManageCustomEmojis).toBe(false);
|
||||
@@ -251,7 +251,7 @@ describe('RoleService', () => {
|
||||
canManageCustomEmojis: false,
|
||||
},
|
||||
} as any);
|
||||
|
||||
|
||||
const result = await roleService.getUserPolicies(user.id);
|
||||
expect(result.canManageCustomEmojis).toBe(true);
|
||||
|
||||
|
@@ -1,7 +1,6 @@
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import * as assert from 'assert';
|
||||
import rndstr from 'rndstr';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
@@ -13,13 +12,14 @@ import { CoreModule } from '@/core/CoreModule.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type { IActor } from '@/core/activitypub/type.js';
|
||||
import { MockResolver } from '../misc/mock-resolver.js';
|
||||
import { Note } from '@/models/index.js';
|
||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||
import { MockResolver } from '../misc/mock-resolver.js';
|
||||
|
||||
const host = 'https://host1.test';
|
||||
|
||||
function createRandomActor(): IActor & { id: string } {
|
||||
const preferredUsername = `${rndstr('A-Z', 4)}${rndstr('a-z', 4)}`;
|
||||
const preferredUsername = secureRndstr(8);
|
||||
const actorId = `${host}/users/${preferredUsername.toLowerCase()}`;
|
||||
|
||||
return {
|
||||
@@ -61,7 +61,7 @@ describe('ActivityPub', () => {
|
||||
|
||||
const post = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
id: `${host}/users/${rndstr('0-9a-z', 8)}`,
|
||||
id: `${host}/users/${secureRndstr(8)}`,
|
||||
type: 'Note',
|
||||
attributedTo: actor.id,
|
||||
to: 'https://www.w3.org/ns/activitystreams#Public',
|
||||
@@ -94,7 +94,7 @@ describe('ActivityPub', () => {
|
||||
test('Truncate long name', async () => {
|
||||
const actor = {
|
||||
...createRandomActor(),
|
||||
name: rndstr('0-9a-z', 129),
|
||||
name: secureRndstr(129),
|
||||
};
|
||||
|
||||
resolver._register(actor.id, actor);
|
||||
|
@@ -2,7 +2,7 @@ import * as assert from 'node:assert';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { isAbsolute, basename } from 'node:path';
|
||||
import { inspect } from 'node:util';
|
||||
import WebSocket from 'ws';
|
||||
import WebSocket, { ClientOptions } from 'ws';
|
||||
import fetch, { Blob, File, RequestInit } from 'node-fetch';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
@@ -13,14 +13,19 @@ import type * as misskey from 'misskey-js';
|
||||
|
||||
export { server as startServer } from '@/boot/common.js';
|
||||
|
||||
interface UserToken {
|
||||
token: string;
|
||||
bearer?: boolean;
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
export const port = config.port;
|
||||
|
||||
export const cookie = (me: any): string => {
|
||||
export const cookie = (me: UserToken): string => {
|
||||
return `token=${me.token};`;
|
||||
};
|
||||
|
||||
export const api = async (endpoint: string, params: any, me?: any) => {
|
||||
export const api = async (endpoint: string, params: any, me?: UserToken) => {
|
||||
const normalized = endpoint.replace(/^\//, '');
|
||||
return await request(`api/${normalized}`, params, me);
|
||||
};
|
||||
@@ -28,7 +33,7 @@ export const api = async (endpoint: string, params: any, me?: any) => {
|
||||
export type ApiRequest = {
|
||||
endpoint: string,
|
||||
parameters: object,
|
||||
user: object | undefined,
|
||||
user: UserToken | undefined,
|
||||
};
|
||||
|
||||
export const successfulApiCall = async <T, >(request: ApiRequest, assertion: {
|
||||
@@ -55,35 +60,41 @@ export const failedApiCall = async <T, >(request: ApiRequest, assertion: {
|
||||
return res.body;
|
||||
};
|
||||
|
||||
const request = async (path: string, params: any, me?: any): Promise<{ body: any, status: number }> => {
|
||||
const auth = me ? {
|
||||
i: me.token,
|
||||
} : {};
|
||||
const request = async (path: string, params: any, me?: UserToken): Promise<{ status: number, headers: Headers, body: any }> => {
|
||||
const bodyAuth: Record<string, string> = {};
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (me?.bearer) {
|
||||
headers.Authorization = `Bearer ${me.token}`;
|
||||
} else if (me) {
|
||||
bodyAuth.i = me.token;
|
||||
}
|
||||
|
||||
const res = await relativeFetch(path, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(Object.assign(auth, params)),
|
||||
headers,
|
||||
body: JSON.stringify(Object.assign(bodyAuth, params)),
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
const status = res.status;
|
||||
const body = res.headers.get('content-type') === 'application/json; charset=utf-8'
|
||||
? await res.json()
|
||||
: null;
|
||||
|
||||
return {
|
||||
body, status,
|
||||
status: res.status,
|
||||
headers: res.headers,
|
||||
body,
|
||||
};
|
||||
};
|
||||
|
||||
const relativeFetch = async (path: string, init?: RequestInit | undefined) => {
|
||||
export const relativeFetch = async (path: string, init?: RequestInit | undefined) => {
|
||||
return await fetch(new URL(path, `http://127.0.0.1:${port}/`).toString(), init);
|
||||
};
|
||||
|
||||
export const signup = async (params?: any): Promise<any> => {
|
||||
export const signup = async (params?: Partial<misskey.Endpoints['signup']['req']>): Promise<NonNullable<misskey.Endpoints['signup']['res']>> => {
|
||||
const q = Object.assign({
|
||||
username: 'test',
|
||||
password: 'test',
|
||||
@@ -94,7 +105,7 @@ export const signup = async (params?: any): Promise<any> => {
|
||||
return res.body;
|
||||
};
|
||||
|
||||
export const post = async (user: any, params?: misskey.Endpoints['notes/create']['req']): Promise<misskey.entities.Note> => {
|
||||
export const post = async (user: UserToken, params?: misskey.Endpoints['notes/create']['req']): Promise<misskey.entities.Note> => {
|
||||
const q = params;
|
||||
|
||||
const res = await api('notes/create', q, user);
|
||||
@@ -117,21 +128,21 @@ export const hiddenNote = (note: any): any => {
|
||||
return temp;
|
||||
};
|
||||
|
||||
export const react = async (user: any, note: any, reaction: string): Promise<any> => {
|
||||
export const react = async (user: UserToken, note: any, reaction: string): Promise<any> => {
|
||||
await api('notes/reactions/create', {
|
||||
noteId: note.id,
|
||||
reaction: reaction,
|
||||
}, user);
|
||||
};
|
||||
|
||||
export const userList = async (user: any, userList: any = {}): Promise<any> => {
|
||||
export const userList = async (user: UserToken, userList: any = {}): Promise<any> => {
|
||||
const res = await api('users/lists/create', {
|
||||
name: 'test',
|
||||
}, user);
|
||||
return res.body;
|
||||
};
|
||||
|
||||
export const page = async (user: any, page: any = {}): Promise<any> => {
|
||||
export const page = async (user: UserToken, page: any = {}): Promise<any> => {
|
||||
const res = await api('pages/create', {
|
||||
alignCenter: false,
|
||||
content: [
|
||||
@@ -154,7 +165,7 @@ export const page = async (user: any, page: any = {}): Promise<any> => {
|
||||
return res.body;
|
||||
};
|
||||
|
||||
export const play = async (user: any, play: any = {}): Promise<any> => {
|
||||
export const play = async (user: UserToken, play: any = {}): Promise<any> => {
|
||||
const res = await api('flash/create', {
|
||||
permissions: [],
|
||||
script: 'test',
|
||||
@@ -165,7 +176,7 @@ export const play = async (user: any, play: any = {}): Promise<any> => {
|
||||
return res.body;
|
||||
};
|
||||
|
||||
export const clip = async (user: any, clip: any = {}): Promise<any> => {
|
||||
export const clip = async (user: UserToken, clip: any = {}): Promise<any> => {
|
||||
const res = await api('clips/create', {
|
||||
description: null,
|
||||
isPublic: true,
|
||||
@@ -175,7 +186,7 @@ export const clip = async (user: any, clip: any = {}): Promise<any> => {
|
||||
return res.body;
|
||||
};
|
||||
|
||||
export const galleryPost = async (user: any, channel: any = {}): Promise<any> => {
|
||||
export const galleryPost = async (user: UserToken, channel: any = {}): Promise<any> => {
|
||||
const res = await api('gallery/posts/create', {
|
||||
description: null,
|
||||
fileIds: [],
|
||||
@@ -186,7 +197,7 @@ export const galleryPost = async (user: any, channel: any = {}): Promise<any> =>
|
||||
return res.body;
|
||||
};
|
||||
|
||||
export const channel = async (user: any, channel: any = {}): Promise<any> => {
|
||||
export const channel = async (user: UserToken, channel: any = {}): Promise<any> => {
|
||||
const res = await api('channels/create', {
|
||||
bannerId: null,
|
||||
description: null,
|
||||
@@ -196,7 +207,7 @@ export const channel = async (user: any, channel: any = {}): Promise<any> => {
|
||||
return res.body;
|
||||
};
|
||||
|
||||
export const role = async (user: any, role: any = {}, policies: any = {}): Promise<any> => {
|
||||
export const role = async (user: UserToken, role: any = {}, policies: any = {}): Promise<any> => {
|
||||
const res = await api('admin/roles/create', {
|
||||
asBadge: false,
|
||||
canEditMembersByModerator: false,
|
||||
@@ -213,8 +224,8 @@ export const role = async (user: any, role: any = {}, policies: any = {}): Promi
|
||||
isPublic: false,
|
||||
name: 'New Role',
|
||||
target: 'manual',
|
||||
policies: {
|
||||
...Object.entries(DEFAULT_POLICIES).map(([k, v]) => [k, {
|
||||
policies: {
|
||||
...Object.entries(DEFAULT_POLICIES).map(([k, v]) => [k, {
|
||||
priority: 0,
|
||||
useDefault: true,
|
||||
value: v,
|
||||
@@ -239,7 +250,7 @@ interface UploadOptions {
|
||||
* Upload file
|
||||
* @param user User
|
||||
*/
|
||||
export const uploadFile = async (user: any, { path, name, blob }: UploadOptions = {}): Promise<any> => {
|
||||
export const uploadFile = async (user?: UserToken, { path, name, blob }: UploadOptions = {}): Promise<{ status: number, headers: Headers, body: misskey.Endpoints['drive/files/create']['res'] | null }> => {
|
||||
const absPath = path == null
|
||||
? new URL('resources/Lenna.jpg', import.meta.url)
|
||||
: isAbsolute(path.toString())
|
||||
@@ -247,7 +258,6 @@ export const uploadFile = async (user: any, { path, name, blob }: UploadOptions
|
||||
: new URL(path, new URL('resources/', import.meta.url));
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('i', user.token);
|
||||
formData.append('file', blob ??
|
||||
new File([await readFile(absPath)], basename(absPath.toString())));
|
||||
formData.append('force', 'true');
|
||||
@@ -255,20 +265,29 @@ export const uploadFile = async (user: any, { path, name, blob }: UploadOptions
|
||||
formData.append('name', name);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (user?.bearer) {
|
||||
headers.Authorization = `Bearer ${user.token}`;
|
||||
} else if (user) {
|
||||
formData.append('i', user.token);
|
||||
}
|
||||
|
||||
const res = await relativeFetch('api/drive/files/create', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers,
|
||||
});
|
||||
|
||||
const body = res.status !== 204 ? await res.json() : null;
|
||||
const body = res.status !== 204 ? await res.json() as misskey.Endpoints['drive/files/create']['res'] : null;
|
||||
|
||||
return {
|
||||
status: res.status,
|
||||
headers: res.headers,
|
||||
body,
|
||||
};
|
||||
};
|
||||
|
||||
export const uploadUrl = async (user: any, url: string) => {
|
||||
export const uploadUrl = async (user: UserToken, url: string) => {
|
||||
let file: any;
|
||||
const marker = Math.random().toString();
|
||||
|
||||
@@ -290,10 +309,18 @@ export const uploadUrl = async (user: any, url: string) => {
|
||||
return file;
|
||||
};
|
||||
|
||||
export function connectStream(user: any, channel: string, listener: (message: Record<string, any>) => any, params?: any): Promise<WebSocket> {
|
||||
export function connectStream(user: UserToken, channel: string, listener: (message: Record<string, any>) => any, params?: any): Promise<WebSocket> {
|
||||
return new Promise((res, rej) => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}/streaming?i=${user.token}`);
|
||||
const url = new URL(`ws://127.0.0.1:${port}/streaming`);
|
||||
const options: ClientOptions = {};
|
||||
if (user.bearer) {
|
||||
options.headers = { Authorization: `Bearer ${user.token}` };
|
||||
} else {
|
||||
url.searchParams.set('i', user.token);
|
||||
}
|
||||
const ws = new WebSocket(url, options);
|
||||
|
||||
ws.on('unexpected-response', (req, res) => rej(res));
|
||||
ws.on('open', () => {
|
||||
ws.on('message', data => {
|
||||
const msg = JSON.parse(data.toString());
|
||||
@@ -317,7 +344,7 @@ export function connectStream(user: any, channel: string, listener: (message: Re
|
||||
});
|
||||
}
|
||||
|
||||
export const waitFire = async (user: any, channel: string, trgr: () => any, cond: (msg: Record<string, any>) => boolean, params?: any) => {
|
||||
export const waitFire = async (user: UserToken, channel: string, trgr: () => any, cond: (msg: Record<string, any>) => boolean, params?: any) => {
|
||||
return new Promise<boolean>(async (res, rej) => {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
|
||||
@@ -351,11 +378,11 @@ export const waitFire = async (user: any, channel: string, trgr: () => any, cond
|
||||
});
|
||||
};
|
||||
|
||||
export type SimpleGetResponse = {
|
||||
status: number,
|
||||
body: any | JSDOM | null,
|
||||
type: string | null,
|
||||
location: string | null
|
||||
export type SimpleGetResponse = {
|
||||
status: number,
|
||||
body: any | JSDOM | null,
|
||||
type: string | null,
|
||||
location: string | null
|
||||
};
|
||||
export const simpleGet = async (path: string, accept = '*/*', cookie: any = undefined): Promise<SimpleGetResponse> => {
|
||||
const res = await relativeFetch(path, {
|
||||
@@ -374,9 +401,9 @@ export const simpleGet = async (path: string, accept = '*/*', cookie: any = unde
|
||||
'text/html; charset=utf-8',
|
||||
];
|
||||
|
||||
const body =
|
||||
jsonTypes.includes(res.headers.get('content-type') ?? '') ? await res.json() :
|
||||
htmlTypes.includes(res.headers.get('content-type') ?? '') ? new JSDOM(await res.text()) :
|
||||
const body =
|
||||
jsonTypes.includes(res.headers.get('content-type') ?? '') ? await res.json() :
|
||||
htmlTypes.includes(res.headers.get('content-type') ?? '') ? new JSDOM(await res.text()) :
|
||||
null;
|
||||
|
||||
return {
|
||||
|
@@ -9,9 +9,9 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"declaration": false,
|
||||
"sourceMap": false,
|
||||
"target": "es2021",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node16",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"removeComments": false,
|
||||
"noLib": false,
|
||||
|
@@ -20,29 +20,29 @@
|
||||
"@rollup/plugin-replace": "5.0.2",
|
||||
"@rollup/pluginutils": "5.0.2",
|
||||
"@syuilo/aiscript": "0.13.3",
|
||||
"@tabler/icons-webfont": "2.21.0",
|
||||
"@tabler/icons-webfont": "2.22.0",
|
||||
"@vitejs/plugin-vue": "4.2.3",
|
||||
"@vue-macros/reactivity-transform": "0.3.9",
|
||||
"@vue-macros/reactivity-transform": "0.3.10",
|
||||
"@vue/compiler-sfc": "3.3.4",
|
||||
"astring": "1.8.6",
|
||||
"autosize": "6.0.1",
|
||||
"broadcast-channel": "5.1.0",
|
||||
"browser-image-resizer": "github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3",
|
||||
"buraha": "github:misskey-dev/buraha",
|
||||
"buraha": "0.0.1",
|
||||
"canvas-confetti": "1.6.0",
|
||||
"chart.js": "4.3.0",
|
||||
"chartjs-adapter-date-fns": "3.0.0",
|
||||
"chartjs-chart-matrix": "2.0.1",
|
||||
"chartjs-plugin-gradient": "0.6.1",
|
||||
"chartjs-plugin-zoom": "2.0.1",
|
||||
"chromatic": "6.18.0",
|
||||
"chromatic": "6.19.9",
|
||||
"compare-versions": "5.0.3",
|
||||
"cropperjs": "2.0.0-beta.2",
|
||||
"cropperjs": "2.0.0-beta.3",
|
||||
"date-fns": "2.30.0",
|
||||
"escape-regexp": "0.0.1",
|
||||
"estree-walker": "^3.0.3",
|
||||
"eventemitter3": "5.0.1",
|
||||
"gsap": "3.11.5",
|
||||
"gsap": "3.12.1",
|
||||
"idb-keyval": "6.2.1",
|
||||
"insert-text-at-cursor": "0.3.0",
|
||||
"is-file-animated": "1.0.2",
|
||||
@@ -54,12 +54,10 @@
|
||||
"prismjs": "1.29.0",
|
||||
"punycode": "2.3.0",
|
||||
"querystring": "0.2.1",
|
||||
"rndstr": "1.0.0",
|
||||
"rollup": "3.23.0",
|
||||
"rollup": "3.25.1",
|
||||
"s-age": "1.1.2",
|
||||
"sanitize-html": "2.10.0",
|
||||
"sass": "1.62.1",
|
||||
"seedrandom": "3.0.5",
|
||||
"sanitize-html": "2.11.0",
|
||||
"sass": "1.63.6",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"syuilo-password-strength": "0.0.1",
|
||||
"textarea-caret": "3.1.0",
|
||||
@@ -104,31 +102,30 @@
|
||||
"@types/gulp-rename": "2.0.2",
|
||||
"@types/matter-js": "0.18.5",
|
||||
"@types/micromatch": "4.0.2",
|
||||
"@types/node": "20.2.5",
|
||||
"@types/node": "20.3.1",
|
||||
"@types/punycode": "2.1.0",
|
||||
"@types/sanitize-html": "2.9.0",
|
||||
"@types/seedrandom": "3.0.5",
|
||||
"@types/testing-library__jest-dom": "^5.14.6",
|
||||
"@types/throttle-debounce": "5.0.0",
|
||||
"@types/tinycolor2": "1.4.3",
|
||||
"@types/uuid": "9.0.1",
|
||||
"@types/uuid": "9.0.2",
|
||||
"@types/websocket": "1.0.5",
|
||||
"@types/ws": "8.5.4",
|
||||
"@typescript-eslint/eslint-plugin": "5.59.8",
|
||||
"@typescript-eslint/parser": "5.59.8",
|
||||
"@vitest/coverage-c8": "0.31.4",
|
||||
"@types/ws": "8.5.5",
|
||||
"@typescript-eslint/eslint-plugin": "5.60.0",
|
||||
"@typescript-eslint/parser": "5.60.0",
|
||||
"@vitest/coverage-v8": "0.32.2",
|
||||
"@vue/runtime-core": "3.3.4",
|
||||
"acorn": "^8.8.2",
|
||||
"acorn": "8.9.0",
|
||||
"chokidar-cli": "3.0.0",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "12.13.0",
|
||||
"eslint": "8.41.0",
|
||||
"cypress": "12.15.0",
|
||||
"eslint": "8.43.0",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"eslint-plugin-vue": "9.14.1",
|
||||
"eslint-plugin-vue": "9.15.0",
|
||||
"fast-glob": "3.2.12",
|
||||
"happy-dom": "9.20.3",
|
||||
"micromatch": "3.1.10",
|
||||
"msw": "1.2.1",
|
||||
"msw": "1.2.2",
|
||||
"msw-storybook-addon": "1.8.0",
|
||||
"prettier": "2.8.8",
|
||||
"react": "18.2.0",
|
||||
@@ -138,9 +135,9 @@
|
||||
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
||||
"summaly": "github:misskey-dev/summaly",
|
||||
"vite-plugin-turbosnap": "1.0.2",
|
||||
"vitest": "0.31.4",
|
||||
"vitest": "0.32.2",
|
||||
"vitest-fetch-mock": "0.2.2",
|
||||
"vue-eslint-parser": "9.3.0",
|
||||
"vue-tsc": "1.6.5"
|
||||
"vue-eslint-parser": "9.3.1",
|
||||
"vue-tsc": "1.8.1"
|
||||
}
|
||||
}
|
||||
|
@@ -4,6 +4,8 @@
|
||||
ref="el" class="_button"
|
||||
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]"
|
||||
:type="type"
|
||||
:name="name"
|
||||
:value="value"
|
||||
@click="emit('click', $event)"
|
||||
@mousedown="onMousedown"
|
||||
>
|
||||
@@ -44,6 +46,8 @@ const props = defineProps<{
|
||||
large?: boolean;
|
||||
transparent?: boolean;
|
||||
asLike?: boolean;
|
||||
name?: string;
|
||||
value?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
@@ -2,7 +2,7 @@
|
||||
<MkPagination :pagination="pagination">
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
|
||||
<img :src="infoImageUrl" class="_ghost"/>
|
||||
<div>{{ i18n.ts.notFound }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -17,6 +17,7 @@
|
||||
import MkChannelPreview from '@/components/MkChannelPreview.vue';
|
||||
import MkPagination, { Paging } from '@/components/MkPagination.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import { infoImageUrl } from '@/instance';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
pagination: Paging;
|
||||
|
@@ -2,7 +2,7 @@
|
||||
<MkPagination ref="pagingComponent" :pagination="pagination">
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
|
||||
<img :src="infoImageUrl" class="_ghost"/>
|
||||
<div>{{ i18n.ts.noNotes }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -32,6 +32,7 @@ import MkNote from '@/components/MkNote.vue';
|
||||
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
|
||||
import MkPagination, { Paging } from '@/components/MkPagination.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import { infoImageUrl } from '@/instance';
|
||||
|
||||
const props = defineProps<{
|
||||
pagination: Paging;
|
||||
|
@@ -2,7 +2,7 @@
|
||||
<MkPagination ref="pagingComponent" :pagination="pagination">
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
|
||||
<img :src="infoImageUrl" class="_ghost"/>
|
||||
<div>{{ i18n.ts.noNotifications }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -26,6 +26,7 @@ import { useStream } from '@/stream';
|
||||
import { $i } from '@/account';
|
||||
import { i18n } from '@/i18n';
|
||||
import { notificationTypes } from '@/const';
|
||||
import { infoImageUrl } from '@/instance';
|
||||
|
||||
const props = defineProps<{
|
||||
includeTypes?: typeof notificationTypes[number][];
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user