Merge branch 'develop' into beta

This commit is contained in:
ThatOneCalculator 2023-03-14 16:28:43 -07:00
commit 104d50e431
406 changed files with 7551 additions and 23920 deletions

View file

@ -96,6 +96,9 @@ id: 'aid'
# Max note length, should be < 8000. # Max note length, should be < 8000.
#maxNoteLength: 3000 #maxNoteLength: 3000
# Maximum lenght of an image caption or file comment (default 1500, max 8192)
#maxCaptionLength: 1500
# Whether disable HSTS # Whether disable HSTS
#disableHsts: true #disableHsts: true

2
.gitignore vendored
View file

@ -42,6 +42,8 @@ api-docs.json
files files
ormconfig.json ormconfig.json
packages/backend/assets/instance.css packages/backend/assets/instance.css
packages/backend/assets/sounds/None.mp3
# blender backups # blender backups
*.blend1 *.blend1

View file

@ -31,7 +31,7 @@ FROM node:19-alpine
WORKDIR /calckey WORKDIR /calckey
# Install runtime dependencies # Install runtime dependencies
RUN apk add --no-cache --no-progress tini ffmpeg RUN apk add --no-cache --no-progress tini ffmpeg vips-dev
COPY . ./ COPY . ./

View file

@ -66,7 +66,7 @@ If you have access to a server that supports one of the sources below, I recomme
### 🐋 Docker ### 🐋 Docker
[How to run Calckey with Docker](./docker-README.md). [How to run Calckey with Docker](./docs/docker.md).
## 🧑‍💻 Dependencies ## 🧑‍💻 Dependencies
@ -124,6 +124,8 @@ psql postgres -c "create database calckey with encoding = 'UTF8';"
- To add custom CSS for all users, edit `./custom/assets/instance.css`. - To add custom CSS for all users, edit `./custom/assets/instance.css`.
- To add static assets (such as images for the splash screen), place them in the `./custom/assets/` directory. They'll then be available on `https://yourinstance.tld/static-assets/filename.ext`. - To add static assets (such as images for the splash screen), place them in the `./custom/assets/` directory. They'll then be available on `https://yourinstance.tld/static-assets/filename.ext`.
- To add custom locales, place them in the `./custom/locales/` directory. If you name your custom locale the same as an existing locale, it will overwrite it. If you give it a unique name, it will be added to the list. Also make sure that the first part of the filename matches the locale you're basing it on. (Example: `en-FOO.yml`) - To add custom locales, place them in the `./custom/locales/` directory. If you name your custom locale the same as an existing locale, it will overwrite it. If you give it a unique name, it will be added to the list. Also make sure that the first part of the filename matches the locale you're basing it on. (Example: `en-FOO.yml`)
- To add custom error images, place them in the `./custom/assets/badges` directory, replacing the files already there.
- To add custom sounds, place only mp3 files in the `./custom/assets/sounds` directory.
- To update custom assets without rebuilding, just run `pnpm run gulp`. - To update custom assets without rebuilding, just run `pnpm run gulp`.
## 🧑‍🔬 Configuring a new instance ## 🧑‍🔬 Configuring a new instance
@ -134,12 +136,7 @@ psql postgres -c "create database calckey with encoding = 'UTF8';"
## 🚚 Migrating from Misskey to Calckey ## 🚚 Migrating from Misskey to Calckey
> ⚠️ Because of their changes, migrating from Foundkey is not supported. For migrating from Misskey v13, Misskey v12, and Foundkey, read [this document](./docs/migrate.md).
```sh
cp ../misskey/.config/default.yml ./.config/default.yml # replace `../misskey/` with misskey path, add `docker.env` if you use Docker
cp -r ../misskey/files .
```
## 🍀 NGINX ## 🍀 NGINX

BIN
custom/assets/badges/error.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
custom/assets/badges/info.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
custom/assets/badges/not-found.png (Stored with Git LFS) Normal file

Binary file not shown.

58
docs/migrate.md Normal file
View file

@ -0,0 +1,58 @@
# 🚚 Migrating from Misskey to Calckey
## Misskey v13 and above
```sh
wget -O mkv13.patch https://codeberg.org/calckey/calckey/raw/branch/develop/docs/mkv13.patch
git apply mkv13.patch
cd packages/backend
LINE_NUM="$(npx typeorm migration:show -d ormconfig.js | grep -n activeEmailValidation1657346559800 | cut -d ':' -f 1)"
NUM_MIGRATIONS="$(npx typeorm migration:show -d ormconfig.js | tail -n+"$LINE_NUM" | grep '\[X\]' | nl)"
for i in $(seq 1 $NUM_MIGRAIONS); do
npx typeorm migration:revert -d ormconfig.js
done
git remote set-url origin https://codeberg.org/calckey/calckey.git
git fetch
git checkout main # or beta or develop
git pull --ff
NODE_ENV=production pnpm run migrate
# build using prefered method
```
## Misskey v12.119 and before
```sh
git remote set-url origin https://codeberg.org/calckey/calckey.git
git fetch
git checkout main # or beta or develop
git pull --ff
NODE_ENV=production pnpm run migrate
# build using prefered method
```
## Foundkey
```sh
cd packages/backend
LINE_NUM="$(npx typeorm migration:show -d ormconfig.js | grep -n uniformThemecolor1652859567549 | cut -d ':' -f 1)"
NUM_MIGRATIONS="$(npx typeorm migration:show -d ormconfig.js | tail -n+"$LINE_NUM" | grep '\[X\]' | nl)"
for i in $(seq 1 $NUM_MIGRAIONS); do
npx typeorm migration:revert -d ormconfig.js
done
git remote set-url origin https://codeberg.org/calckey/calckey.git
git fetch
git checkout main # or beta or develop
git pull --ff
NODE_ENV=production pnpm run migrate
# build using prefered method
```

45
docs/mkv13.patch Normal file
View file

@ -0,0 +1,45 @@
diff --git a/packages/backend/migration/1672704017999-remove-lastCommunicatedAt.js b/packages/backend/migration/1672704017999-remove-lastCommunicatedAt.js
index 38a676985..c4ae690e0 100644
--- a/packages/backend/migration/1672704017999-remove-lastCommunicatedAt.js
+++ b/packages/backend/migration/1672704017999-remove-lastCommunicatedAt.js
@@ -6,6 +6,8 @@ export class removeLastCommunicatedAt1672704017999 {
}
async down(queryRunner) {
- await queryRunner.query(`ALTER TABLE "instance" ADD "lastCommunicatedAt" TIMESTAMP WITH TIME ZONE NOT NULL`);
+ await queryRunner.query(`ALTER TABLE "instance" ADD "lastCommunicatedAt" TIMESTAMP WITH TIME ZONE`);
+ await queryRunner.query(`UPDATE "instance" SET "lastCommunicatedAt" = COALESCE("infoUpdatedAt", "caughtAt")`);
+ await queryRunner.query(`ALTER TABLE "instance" ALTER COLUMN "lastCommunicatedAt" SET NOT NULL`);
}
}
diff --git a/packages/backend/migration/1673336077243-PollChoiceLength.js b/packages/backend/migration/1673336077243-PollChoiceLength.js
index 810c626e0..5809528cb 100644
--- a/packages/backend/migration/1673336077243-PollChoiceLength.js
+++ b/packages/backend/migration/1673336077243-PollChoiceLength.js
@@ -6,6 +6,6 @@ export class PollChoiceLength1673336077243 {
}
async down(queryRunner) {
- await queryRunner.query(`ALTER TABLE "poll" ALTER COLUMN "choices" TYPE character varying(128) array`);
+ //await queryRunner.query(`ALTER TABLE "poll" ALTER COLUMN "choices" TYPE character varying(128) array`);
}
}
diff --git a/packages/backend/migration/1674118260469-achievement.js b/packages/backend/migration/1674118260469-achievement.js
index 131ab96f8..57a922f83 100644
--- a/packages/backend/migration/1674118260469-achievement.js
+++ b/packages/backend/migration/1674118260469-achievement.js
@@ -18,12 +18,13 @@ export class achievement1674118260469 {
async down(queryRunner) {
await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'pollEnded')`);
+ await queryRunner.query(`CREATE TYPE "public"."notification_type_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`);
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" TYPE "public"."user_profile_mutingnotificationtypes_enum_old"[] USING "mutingNotificationTypes"::"text"::"public"."user_profile_mutingnotificationtypes_enum_old"[]`);
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" SET DEFAULT '{}'`);
await queryRunner.query(`DROP TYPE "public"."user_profile_mutingnotificationtypes_enum"`);
await queryRunner.query(`ALTER TYPE "public"."user_profile_mutingnotificationtypes_enum_old" RENAME TO "user_profile_mutingnotificationtypes_enum"`);
- await queryRunner.query(`CREATE TYPE "public"."notification_type_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`);
+ await queryRunner.query(`DELETE FROM "public"."notification" WHERE "type" = 'achievementEarned'`);
await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum_old" USING "type"::"text"::"public"."notification_type_enum_old"`);
await queryRunner.query(`DROP TYPE "public"."notification_type_enum"`);
await queryRunner.query(`ALTER TYPE "public"."notification_type_enum_old" RENAME TO "notification_type_enum"`);

View file

@ -15,18 +15,15 @@ gulp.task('copy:backend:views', () =>
gulp.src('./packages/backend/src/server/web/views/**/*').pipe(gulp.dest('./packages/backend/built/server/web/views')) gulp.src('./packages/backend/src/server/web/views/**/*').pipe(gulp.dest('./packages/backend/built/server/web/views'))
); );
gulp.task('copy:backend:custom', () => gulp.task('copy:backend:custom', () =>
gulp.src('./custom/assets/*').pipe(gulp.dest('./packages/backend/assets/')) gulp.src('./custom/assets/**/*').pipe(gulp.dest('./packages/backend/assets/'))
); );
gulp.task('copy:client:fonts', () => gulp.task('copy:client:fonts', () =>
gulp.src('./packages/client/node_modules/three/examples/fonts/**/*').pipe(gulp.dest('./built/_client_dist_/fonts/')) gulp.src('./packages/client/node_modules/three/examples/fonts/**/*').pipe(gulp.dest('./built/_client_dist_/fonts/'))
); );
gulp.task('copy:client:phosphor', () =>
gulp.src('./node_modules/phosphor-icons/src/fonts/*').pipe(gulp.dest('./built/_client_dist_/phosphor/'))
);
gulp.task('copy:client:locales', cb => { gulp.task('copy:client:locales', cb => {
fs.mkdirSync('./built/_client_dist_/locales', { recursive: true }); fs.mkdirSync('./built/_client_dist_/locales', { recursive: true });
@ -58,7 +55,7 @@ gulp.task('build:backend:style', () => {
}); });
gulp.task('build', gulp.parallel( gulp.task('build', gulp.parallel(
'copy:client:locales', 'copy:backend:views', 'copy:backend:custom', 'build:backend:script', 'build:backend:style', 'copy:client:fonts', 'copy:client:phosphor' 'copy:client:locales', 'copy:backend:views', 'copy:backend:custom', 'build:backend:script', 'build:backend:style', 'copy:client:fonts'
)); ));
gulp.task('default', gulp.task('build')); gulp.task('default', gulp.task('build'));

View file

@ -69,8 +69,8 @@ exportRequested: "Has sol·licitat una exportació. Això pot trigar una estona.
importRequested: "Has sol·licitat una importació. Això pot trigar una estona." importRequested: "Has sol·licitat una importació. Això pot trigar una estona."
lists: "Llistes" lists: "Llistes"
noLists: "No tens cap llista" noLists: "No tens cap llista"
note: "Nota" note: "Post"
notes: "Notes" notes: "Posts"
following: "Seguint" following: "Seguint"
followers: "Seguidors" followers: "Seguidors"
followsYou: "Et segueix" followsYou: "Et segueix"
@ -141,7 +141,7 @@ _theme:
mention: "Menció" mention: "Menció"
renote: "Renotar" renote: "Renotar"
_sfx: _sfx:
note: "Notes" note: "Posts"
notification: "Notificacions" notification: "Notificacions"
_2fa: _2fa:
step2Url: "També pots inserir aquest enllaç i utilitzes una aplicació d'escriptori:" step2Url: "També pots inserir aquest enllaç i utilitzes una aplicació d'escriptori:"

View file

@ -32,6 +32,7 @@ uploading: "Uploading..."
save: "Save" save: "Save"
users: "Users" users: "Users"
addUser: "Add a user" addUser: "Add a user"
addInstance: "Add an instance"
favorite: "Add to bookmarks" favorite: "Add to bookmarks"
favorites: "Bookmarks" favorites: "Bookmarks"
unfavorite: "Remove from bookmarks" unfavorite: "Remove from bookmarks"
@ -160,6 +161,7 @@ proxyAccount: "Proxy account"
proxyAccountDescription: "A proxy account is an account that acts as a remote follower for users under certain conditions. For example, when a user adds a remote user to the list, the remote user's activity will not be delivered to the instance if no local user is following that user, so the proxy account will follow instead." proxyAccountDescription: "A proxy account is an account that acts as a remote follower for users under certain conditions. For example, when a user adds a remote user to the list, the remote user's activity will not be delivered to the instance if no local user is following that user, so the proxy account will follow instead."
host: "Host" host: "Host"
selectUser: "Select a user" selectUser: "Select a user"
selectInstance: "Select an instance"
recipient: "Recipient(s)" recipient: "Recipient(s)"
annotation: "Comments" annotation: "Comments"
federation: "Federation" federation: "Federation"
@ -197,6 +199,7 @@ muteAndBlock: "Mutes and Blocks"
mutedUsers: "Muted users" mutedUsers: "Muted users"
blockedUsers: "Blocked users" blockedUsers: "Blocked users"
noUsers: "There are no users" noUsers: "There are no users"
noInstances: "There are no instances"
editProfile: "Edit profile" editProfile: "Edit profile"
noteDeleteConfirm: "Are you sure you want to delete this post?" noteDeleteConfirm: "Are you sure you want to delete this post?"
pinLimitExceeded: "You cannot pin any more posts" pinLimitExceeded: "You cannot pin any more posts"
@ -363,6 +366,7 @@ notifyAntenna: "Notify about new posts"
withFileAntenna: "Only posts with files" withFileAntenna: "Only posts with files"
enableServiceworker: "Enable Push-Notifications for your Browser" enableServiceworker: "Enable Push-Notifications for your Browser"
antennaUsersDescription: "List one username per line" antennaUsersDescription: "List one username per line"
antennaInstancesDescription: "List one instance host per line"
caseSensitive: "Case sensitive" caseSensitive: "Case sensitive"
withReplies: "Include replies" withReplies: "Include replies"
connectedTo: "Following account(s) are connected" connectedTo: "Following account(s) are connected"
@ -1294,12 +1298,14 @@ _auth:
pleaseGoBack: "Please go back to the application" pleaseGoBack: "Please go back to the application"
callback: "Returning to the application" callback: "Returning to the application"
denied: "Access denied" denied: "Access denied"
copyAsk: "Please paste the following authorization code to the application"
_antennaSources: _antennaSources:
all: "All posts" all: "All posts"
homeTimeline: "Posts from followed users" homeTimeline: "Posts from followed users"
users: "Posts from specific users" users: "Posts from specific users"
userList: "Posts from a specified list of users" userList: "Posts from a specified list of users"
userGroup: "Posts from users in a specified group" userGroup: "Posts from users in a specified group"
instances: "Posts from all users on an instance"
_weekday: _weekday:
sunday: "Sunday" sunday: "Sunday"
monday: "Monday" monday: "Monday"
@ -1394,6 +1400,7 @@ _profile:
metadataContent: "Content" metadataContent: "Content"
changeAvatar: "Change avatar" changeAvatar: "Change avatar"
changeBanner: "Change banner" changeBanner: "Change banner"
locationDescription: "If entered properly, this will display your local time to other users."
_exportOrImport: _exportOrImport:
allNotes: "All posts" allNotes: "All posts"
followingList: "Followed users" followingList: "Followed users"
@ -1799,7 +1806,7 @@ _apps:
pwa: "Install PWA" pwa: "Install PWA"
kaiteki: "Kaiteki" kaiteki: "Kaiteki"
milktea: "Milktea" milktea: "Milktea"
subwayTooter: "Subway Tooter" missLi: "MissLi"
kimis: "Kimis" mona: "Mona"
theDesk: "TheDesk" theDesk: "TheDesk"
lesskey: "Lesskey" lesskey: "Lesskey"

View file

@ -70,8 +70,8 @@ exportRequested: "Vous avez demandé une exportation. Lopération pourrait pr
importRequested: "Vous avez initié un import. Cela pourrait prendre un peu de temps." importRequested: "Vous avez initié un import. Cela pourrait prendre un peu de temps."
lists: "Listes" lists: "Listes"
noLists: "Vous navez aucune liste" noLists: "Vous navez aucune liste"
note: "Notes" note: "Post"
notes: "Notes" notes: "Posts"
following: "Abonnements" following: "Abonnements"
followers: "Abonné·e·s" followers: "Abonné·e·s"
followsYou: "Vous suit" followsYou: "Vous suit"

View file

@ -1,7 +1,7 @@
---
_lang_: "日本語" _lang_: "日本語"
headlineMisskey: "ずっと無料でオープンソースの非中央集権型ソーシャルメディアプラットフォーム🚀"
headlineMisskey: "ノートでつながるネットワーク" introMisskey: "ようこそCalckeyは、オープンソースの非中央集権型ソーシャルメディアプラットフォームです。\nいま起こっていることを共有したり、あなたについて皆に発信しよう📡\n「リアクション」機能で、皆の投稿に素早く反応を追加することもできます👍\n新しい世界を探検しよう🚀"
introMisskey: "ようこそMisskeyは、オープンソースの分散型マイクロブログサービスです。\n「ート」を作成して、いま起こっていることを共有したり、あなたについて皆に発信しよう📡\n「リアクション」機能で、皆のートに素早く反応を追加することもできます👍\n新しい世界を探検しよう🚀"
monthAndDay: "{month}月 {day}日" monthAndDay: "{month}月 {day}日"
search: "検索" search: "検索"
notifications: "通知" notifications: "通知"
@ -10,11 +10,11 @@ password: "パスワード"
forgotPassword: "パスワードを忘れた" forgotPassword: "パスワードを忘れた"
fetchingAsApObject: "連合に照会中" fetchingAsApObject: "連合に照会中"
ok: "OK" ok: "OK"
gotIt: "わかった" gotIt: "わかった"
cancel: "キャンセル" cancel: "キャンセル"
enterUsername: "ユーザー名を入力" enterUsername: "ユーザー名を入力"
renotedBy: "{user}がRenote" renotedBy: "{user}がブースト"
noNotes: "ノートはありません" noNotes: "投稿はありません"
noNotifications: "通知はありません" noNotifications: "通知はありません"
instance: "インスタンス" instance: "インスタンス"
settings: "設定" settings: "設定"
@ -32,6 +32,7 @@ uploading: "アップロード中"
save: "保存" save: "保存"
users: "ユーザー" users: "ユーザー"
addUser: "ユーザーを追加" addUser: "ユーザーを追加"
addInstance: "インスタンスを追加"
favorite: "お気に入り" favorite: "お気に入り"
favorites: "お気に入り" favorites: "お気に入り"
unfavorite: "お気に入り解除" unfavorite: "お気に入り解除"
@ -44,7 +45,7 @@ copyContent: "内容をコピー"
copyLink: "リンクをコピー" copyLink: "リンクをコピー"
delete: "削除" delete: "削除"
deleteAndEdit: "削除して編集" deleteAndEdit: "削除して編集"
deleteAndEditConfirm: "このートを削除してもう一度編集しますかこのートへのリアクション、Renote、返信も全て削除されます。" deleteAndEditConfirm: "この投稿を削除してもう一度編集しますか?この投稿へのリアクション、ブースト、返信も全て削除されます。"
addToList: "リストに追加" addToList: "リストに追加"
sendMessage: "メッセージを送信" sendMessage: "メッセージを送信"
copyUsername: "ユーザー名をコピー" copyUsername: "ユーザー名をコピー"
@ -64,14 +65,14 @@ import: "インポート"
export: "エクスポート" export: "エクスポート"
files: "ファイル" files: "ファイル"
download: "ダウンロード" download: "ダウンロード"
driveFileDeleteConfirm: "ファイル「{name}」を削除しますか?このファイルを添付したノートも消えます。" driveFileDeleteConfirm: "ファイル「{name}」を削除しますか?このファイルを添付した投稿も消えます。"
unfollowConfirm: "{name}のフォローを解除しますか?" unfollowConfirm: "{name}のフォローを解除しますか?"
exportRequested: "エクスポートをリクエストしました。これには時間がかかる場合があります。エクスポートが終わると、「ドライブ」に追加されます。" exportRequested: "エクスポートをリクエストしました。これには時間がかかる場合があります。エクスポートが終わると、「ドライブ」に追加されます。"
importRequested: "インポートをリクエストしました。これには時間がかかる場合があります。" importRequested: "インポートをリクエストしました。これには時間がかかる場合があります。"
lists: "リスト" lists: "リスト"
noLists: "リストはありません" noLists: "リストはありません"
note: "ノート" note: "投稿"
notes: "ノート" notes: "投稿"
following: "フォロー" following: "フォロー"
followers: "フォロワー" followers: "フォロワー"
followsYou: "フォローされています" followsYou: "フォローされています"
@ -94,13 +95,13 @@ followRequests: "フォロー申請"
unfollow: "フォロー解除" unfollow: "フォロー解除"
followRequestPending: "フォロー許可待ち" followRequestPending: "フォロー許可待ち"
enterEmoji: "絵文字を入力" enterEmoji: "絵文字を入力"
renote: "Renote" renote: "ブースト"
unrenote: "Renote解除" unrenote: "ブースト解除"
renoted: "Renoteしました。" renoted: "ブーストしました。"
cantRenote: "この投稿はRenoteできません。" cantRenote: "この投稿はブーストできません。"
cantReRenote: "RenoteをRenoteすることはできません。" cantReRenote: "ブーストをブーストすることはできません。"
quote: "引用" quote: "引用"
pinnedNote: "ピン留めされたノート" pinnedNote: "ピン留めされた投稿"
pinned: "ピン留め" pinned: "ピン留め"
you: "あなた" you: "あなた"
clickToShow: "クリックして表示" clickToShow: "クリックして表示"
@ -139,11 +140,11 @@ settingGuide: "おすすめ設定"
cacheRemoteFiles: "リモートのファイルをキャッシュする" cacheRemoteFiles: "リモートのファイルをキャッシュする"
cacheRemoteFilesDescription: "この設定を無効にすると、リモートファイルをキャッシュせず直リンクするようになります。サーバーのストレージを節約できますが、サムネイルが生成されないので通信量が増加します。" cacheRemoteFilesDescription: "この設定を無効にすると、リモートファイルをキャッシュせず直リンクするようになります。サーバーのストレージを節約できますが、サムネイルが生成されないので通信量が増加します。"
flagAsBot: "Botとして設定" flagAsBot: "Botとして設定"
flagAsBotDescription: "このアカウントがプログラムによって運用される場合は、このフラグをオンにします。オンにすると、反応の連鎖を防ぐためのフラグとして他の開発者に役立ったり、Misskeyのシステム上での扱いがBotに合ったものになります。" flagAsBotDescription: "このアカウントがプログラムによって運用される場合は、このフラグをオンにします。オンにすると、反応の連鎖を防ぐためのフラグとして他の開発者に役立ったり、Calckeyのシステム上での扱いがBotに合ったものになります。"
flagAsCat: "Catとして設定" flagAsCat: "あなたは…猫?😺"
flagAsCatDescription: "このアカウントが猫であることを示す場合は、このフラグをオンにします。" flagAsCatDescription: "このアカウントが猫であることを示す場合は、このフラグをオンにします。"
flagShowTimelineReplies: "タイムラインにノートへの返信を表示する" flagShowTimelineReplies: "タイムラインに投稿の返信を表示する"
flagShowTimelineRepliesDescription: "オンにすると、タイムラインにユーザーのノート以外にもそのユーザーの他のノートへの返信を表示します。" flagShowTimelineRepliesDescription: "オンにすると、タイムラインにユーザーの投稿以外にもそのユーザーの他の投稿への返信を表示します。"
autoAcceptFollowed: "フォロー中ユーザーからのフォロリクを自動承認" autoAcceptFollowed: "フォロー中ユーザーからのフォロリクを自動承認"
addAccount: "アカウントを追加" addAccount: "アカウントを追加"
loginFailed: "ログインに失敗しました" loginFailed: "ログインに失敗しました"
@ -160,6 +161,7 @@ proxyAccount: "プロキシアカウント"
proxyAccountDescription: "プロキシアカウントは、特定の条件下でユーザーのリモートフォローを代行するアカウントです。例えば、ユーザーがリモートユーザーをリストに入れたとき、リストに入れられたユーザーを誰もフォローしていないとアクティビティがインスタンスに配達されないため、代わりにプロキシアカウントがフォローするようにします。" proxyAccountDescription: "プロキシアカウントは、特定の条件下でユーザーのリモートフォローを代行するアカウントです。例えば、ユーザーがリモートユーザーをリストに入れたとき、リストに入れられたユーザーを誰もフォローしていないとアクティビティがインスタンスに配達されないため、代わりにプロキシアカウントがフォローするようにします。"
host: "ホスト" host: "ホスト"
selectUser: "ユーザーを選択" selectUser: "ユーザーを選択"
selectInstance: "インスタンスを選択"
recipient: "宛先" recipient: "宛先"
annotation: "注釈" annotation: "注釈"
federation: "連合" federation: "連合"
@ -197,10 +199,11 @@ muteAndBlock: "ミュートとブロック"
mutedUsers: "ミュートしたユーザー" mutedUsers: "ミュートしたユーザー"
blockedUsers: "ブロックしたユーザー" blockedUsers: "ブロックしたユーザー"
noUsers: "ユーザーはいません" noUsers: "ユーザーはいません"
noInstances: "インスタンスはありません"
editProfile: "プロフィールを編集" editProfile: "プロフィールを編集"
noteDeleteConfirm: "このノートを削除しますか?" noteDeleteConfirm: "この投稿を削除しますか?"
pinLimitExceeded: "これ以上ピン留めできません" pinLimitExceeded: "これ以上ピン留めできません"
intro: "Misskeyのインストールが完了しました管理者アカウントを作成しましょう。" intro: "Calckeyのインストールが完了しました管理者アカウントを作成しましょう。"
done: "完了" done: "完了"
processing: "処理中" processing: "処理中"
preview: "プレビュー" preview: "プレビュー"
@ -325,7 +328,7 @@ connectService: "接続する"
disconnectService: "切断する" disconnectService: "切断する"
enableLocalTimeline: "ローカルタイムラインを有効にする" enableLocalTimeline: "ローカルタイムラインを有効にする"
enableGlobalTimeline: "グローバルタイムラインを有効にする" enableGlobalTimeline: "グローバルタイムラインを有効にする"
enableRecommendedTimeline: "推奨されるタイムラインを有効にする" enableRecommendedTimeline: "おすすめタイムラインを有効にする"
disablingTimelinesInfo: "これらのタイムラインを無効化しても、利便性のため管理者およびモデレーターは引き続き利用することができます。" disablingTimelinesInfo: "これらのタイムラインを無効化しても、利便性のため管理者およびモデレーターは引き続き利用することができます。"
registration: "登録" registration: "登録"
enableRegistration: "誰でも新規登録できるようにする" enableRegistration: "誰でも新規登録できるようにする"
@ -342,7 +345,7 @@ pinnedUsersDescription: "「みつける」ページなどにピン留めした
pinnedPages: "ピン留めページ" pinnedPages: "ピン留めページ"
pinnedPagesDescription: "インスタンスのトップページにピン留めしたいページのパスを改行で区切って記述します。" pinnedPagesDescription: "インスタンスのトップページにピン留めしたいページのパスを改行で区切って記述します。"
pinnedClipId: "ピン留めするクリップのID" pinnedClipId: "ピン留めするクリップのID"
pinnedNotes: "ピン留めされたノート" pinnedNotes: "ピン留めされた投稿"
hcaptcha: "hCaptcha" hcaptcha: "hCaptcha"
enableHcaptcha: "hCaptchaを有効にする" enableHcaptcha: "hCaptchaを有効にする"
hcaptchaSiteKey: "サイトキー" hcaptchaSiteKey: "サイトキー"
@ -359,10 +362,11 @@ antennaSource: "受信ソース"
antennaKeywords: "受信キーワード" antennaKeywords: "受信キーワード"
antennaExcludeKeywords: "除外キーワード" antennaExcludeKeywords: "除外キーワード"
antennaKeywordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります" antennaKeywordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります"
notifyAntenna: "新しいノートを通知する" notifyAntenna: "新しい投稿を通知する"
withFileAntenna: "ファイルが添付されたノートのみ" withFileAntenna: "ファイルが添付された投稿のみ"
enableServiceworker: "ブラウザへのプッシュ通知を有効にする" enableServiceworker: "ブラウザへのプッシュ通知を有効にする"
antennaUsersDescription: "ユーザー名を改行で区切って指定します" antennaUsersDescription: "ユーザー名を改行で区切って指定します"
antennaInstancesDescription: "インスタンスを改行で区切って指定します"
caseSensitive: "大文字小文字を区別する" caseSensitive: "大文字小文字を区別する"
withReplies: "返信を含む" withReplies: "返信を含む"
connectedTo: "次のアカウントに接続されています" connectedTo: "次のアカウントに接続されています"
@ -393,7 +397,7 @@ securityKeyName: "キーの名前"
registerSecurityKey: "セキュリティキーを登録する" registerSecurityKey: "セキュリティキーを登録する"
lastUsed: "最後の使用" lastUsed: "最後の使用"
unregister: "登録を解除" unregister: "登録を解除"
passwordLessLogin: "パスワード無しログイン" passwordLessLogin: "パスワード無しログイン"
resetPassword: "パスワードをリセット" resetPassword: "パスワードをリセット"
newPasswordIs: "新しいパスワードは「{password}」です" newPasswordIs: "新しいパスワードは「{password}」です"
reduceUiAnimation: "UIのアニメーションを減らす" reduceUiAnimation: "UIのアニメーションを減らす"
@ -422,9 +426,9 @@ messagingWithGroup: "グループでチャット"
title: "タイトル" title: "タイトル"
text: "テキスト" text: "テキスト"
enable: "有効にする" enable: "有効にする"
next: "次" next: "次"
retype: "再入力" retype: "再入力"
noteOf: "{user}のノート" noteOf: "{user}の投稿"
inviteToGroup: "グループに招待" inviteToGroup: "グループに招待"
quoteAttached: "引用付き" quoteAttached: "引用付き"
quoteQuestion: "引用として添付しますか?" quoteQuestion: "引用として添付しますか?"
@ -482,8 +486,8 @@ accountSettings: "アカウント設定"
promotion: "プロモーション" promotion: "プロモーション"
promote: "プロモート" promote: "プロモート"
numberOfDays: "日数" numberOfDays: "日数"
hideThisNote: "このノートを非表示" hideThisNote: "この投稿を非表示"
showFeaturedNotesInTimeline: "タイムラインにおすすめのノートを表示する" showFeaturedNotesInTimeline: "タイムラインにおすすめの投稿を表示する"
objectStorage: "オブジェクトストレージ" objectStorage: "オブジェクトストレージ"
useObjectStorage: "オブジェクトストレージを使用" useObjectStorage: "オブジェクトストレージを使用"
objectStorageBaseUrl: "Base URL" objectStorageBaseUrl: "Base URL"
@ -504,7 +508,7 @@ objectStorageSetPublicRead: "アップロード時に'public-read'を設定す
serverLogs: "サーバーログ" serverLogs: "サーバーログ"
deleteAll: "全て削除" deleteAll: "全て削除"
showFixedPostForm: "タイムライン上部に投稿フォームを表示する" showFixedPostForm: "タイムライン上部に投稿フォームを表示する"
newNoteRecived: "新しいノートがあります" newNoteRecived: "新しい投稿があります"
sounds: "サウンド" sounds: "サウンド"
listen: "聴く" listen: "聴く"
none: "なし" none: "なし"
@ -519,7 +523,7 @@ recentUsed: "最近使用"
install: "インストール" install: "インストール"
uninstall: "アンインストール" uninstall: "アンインストール"
installedApps: "インストールされたアプリ" installedApps: "インストールされたアプリ"
nothing: "ありません" nothing: "まだ何もありません"
installedDate: "インストール日時" installedDate: "インストール日時"
lastUsedDate: "最終使用日時" lastUsedDate: "最終使用日時"
state: "状態" state: "状態"
@ -527,10 +531,10 @@ sort: "ソート"
ascendingOrder: "昇順" ascendingOrder: "昇順"
descendingOrder: "降順" descendingOrder: "降順"
scratchpad: "スクラッチパッド" scratchpad: "スクラッチパッド"
scratchpadDescription: "スクラッチパッドは、AiScriptの実験環境を提供します。Misskeyと対話するコードの記述、実行、結果の確認ができます。" scratchpadDescription: "スクラッチパッドは、AiScriptの実験環境を提供します。Calckeyと対話するコードの記述、実行、結果の確認ができます。"
output: "出力" output: "出力"
script: "スクリプト" script: "スクリプト"
disablePagesScript: "Pagesのスクリプトを無効にする" disablePagesScript: "ページのスクリプトを無効にする"
updateRemoteUser: "リモートユーザー情報の更新" updateRemoteUser: "リモートユーザー情報の更新"
deleteAllFiles: "すべてのファイルを削除" deleteAllFiles: "すべてのファイルを削除"
deleteAllFilesConfirm: "すべてのファイルを削除しますか?" deleteAllFilesConfirm: "すべてのファイルを削除しますか?"
@ -626,7 +630,7 @@ sample: "サンプル"
abuseReports: "通報" abuseReports: "通報"
reportAbuse: "通報" reportAbuse: "通報"
reportAbuseOf: "{name}を通報する" reportAbuseOf: "{name}を通報する"
fillAbuseReportDescription: "通報理由の詳細を記入してください。対象のノートがある場合はそのURLも記入してください。" fillAbuseReportDescription: "通報理由の詳細を記入してください。対象の投稿がある場合はそのURLも記入してください。"
abuseReported: "内容が送信されました。ご報告ありがとうございました。" abuseReported: "内容が送信されました。ご報告ありがとうございました。"
reporter: "通報者" reporter: "通報者"
reporteeOrigin: "通報先" reporteeOrigin: "通報先"
@ -639,7 +643,7 @@ openInNewTab: "新しいタブで開く"
openInSideView: "サイドビューで開く" openInSideView: "サイドビューで開く"
defaultNavigationBehaviour: "デフォルトのナビゲーション" defaultNavigationBehaviour: "デフォルトのナビゲーション"
editTheseSettingsMayBreakAccount: "これらの設定を編集するとアカウントが破損する可能性があります。" editTheseSettingsMayBreakAccount: "これらの設定を編集するとアカウントが破損する可能性があります。"
instanceTicker: "ノートのインスタンス情報" instanceTicker: "投稿のインスタンス情報"
waitingFor: "{x}を待っています" waitingFor: "{x}を待っています"
random: "ランダム" random: "ランダム"
system: "システム" system: "システム"
@ -650,16 +654,16 @@ createNew: "新規作成"
optional: "任意" optional: "任意"
createNewClip: "新しいクリップを作成" createNewClip: "新しいクリップを作成"
unclip: "クリップ解除" unclip: "クリップ解除"
confirmToUnclipAlreadyClippedNote: "このノートはすでにクリップ「{name}」に含まれています。ノートをこのクリップから除外しますか?" confirmToUnclipAlreadyClippedNote: "この投稿はすでにクリップ「{name}」に含まれています。投稿をこのクリップから除外しますか?"
public: "パブリック" public: "パブリック"
i18nInfo: "Calckeyは有志によって様々な言語に翻訳されています。{link}で翻訳に協力できます。" i18nInfo: "Calckeyは有志によって様々な言語に翻訳されています。{link}で翻訳に協力できます。"
manageAccessTokens: "アクセストークンの管理" manageAccessTokens: "アクセストークンの管理"
accountInfo: "アカウント情報" accountInfo: "アカウント情報"
notesCount: "ノートの数" notesCount: "投稿の数"
repliesCount: "返信した数" repliesCount: "返信した数"
renotesCount: "Renoteした数" renotesCount: "ブーストした数"
repliedCount: "返信された数" repliedCount: "返信された数"
renotedCount: "Renoteされた数" renotedCount: "ブーストされた数"
followingCount: "フォロー数" followingCount: "フォロー数"
followersCount: "フォロワー数" followersCount: "フォロワー数"
sentReactionsCount: "リアクションした数" sentReactionsCount: "リアクションした数"
@ -671,17 +675,17 @@ no: "いいえ"
driveFilesCount: "ドライブのファイル数" driveFilesCount: "ドライブのファイル数"
driveUsage: "ドライブ使用量" driveUsage: "ドライブ使用量"
noCrawle: "クローラーによるインデックスを拒否" noCrawle: "クローラーによるインデックスを拒否"
noCrawleDescription: "検索エンジンにあなたのユーザーページ、ート、Pagesなどのコンテンツを登録(インデックス)しないよう要請します。" noCrawleDescription: "検索エンジンにあなたのプロフィールや投稿、ページなどのコンテンツを登録(インデックス)しないよう要請します。"
lockedAccountInfo: "フォローを承認制にしても、ノートの公開範囲を「フォロワー」にしない限り、誰でもあなたのノートを見ることができます。" lockedAccountInfo: "フォローを承認制にしても、投稿の公開範囲を「フォロワー」にしない限り、誰でもあなたの投稿を見ることができます。"
alwaysMarkSensitive: "デフォルトでメディアを閲覧注意にする" alwaysMarkSensitive: "デフォルトでメディアを閲覧注意にする"
loadRawImages: "添付画像のサムネイルをオリジナル画質にする" loadRawImages: "添付画像のサムネイルをオリジナル画質にする"
disableShowingAnimatedImages: "アニメーション画像を再生しない" disableShowingAnimatedImages: "アニメーション画像を再生しない"
verificationEmailSent: "確認のメールを送信しました。メールに記載されたリンクにアクセスして、設定を完了してください。" verificationEmailSent: "確認のメールを送信しました。メールに記載されたリンクにアクセスして、設定を完了してください。"
notSet: "未設定" notSet: "未設定"
emailVerified: "メールアドレスが確認されました" emailVerified: "メールアドレスが確認されました"
noteFavoritesCount: "お気に入りノートの数" noteFavoritesCount: "お気に入りの投稿の数"
pageLikesCount: "Pageにいいねした数" pageLikesCount: "ページにいいねした数"
pageLikedCount: "Pageにいいねされた数" pageLikedCount: "ページにいいねされた数"
contact: "連絡先" contact: "連絡先"
useSystemFont: "システムのデフォルトのフォントを使う" useSystemFont: "システムのデフォルトのフォントを使う"
clips: "クリップ" clips: "クリップ"
@ -689,7 +693,7 @@ experimentalFeatures: "実験的機能"
developer: "開発者" developer: "開発者"
makeExplorable: "アカウントを見つけやすくする" makeExplorable: "アカウントを見つけやすくする"
makeExplorableDescription: "オフにすると、「みつける」にアカウントが載らなくなります。" makeExplorableDescription: "オフにすると、「みつける」にアカウントが載らなくなります。"
showGapBetweenNotesInTimeline: "タイムラインのノートを離して表示" showGapBetweenNotesInTimeline: "タイムラインの投稿を離して表示"
duplicate: "複製" duplicate: "複製"
left: "左" left: "左"
center: "中央" center: "中央"
@ -701,9 +705,9 @@ showTitlebar: "タイトルバーを表示する"
clearCache: "キャッシュをクリア" clearCache: "キャッシュをクリア"
onlineUsersCount: "{n}人がオンライン" onlineUsersCount: "{n}人がオンライン"
nUsers: "{n}ユーザー" nUsers: "{n}ユーザー"
nNotes: "{n}ノート" nNotes: "{n}投稿"
sendErrorReports: "エラーリポートを送信" sendErrorReports: "エラーリポートを送信"
sendErrorReportsDescription: "オンにすると、問題が発生したときにエラーの詳細情報がMisskeyに共有され、ソフトウェアの品質向上に役立てることができます。エラー情報には、OSのバージョン、ブラウザの種類、行動履歴などが含まれます。" sendErrorReportsDescription: "オンにすると、問題が発生したときにエラーの詳細情報がCalckeyに共有され、ソフトウェアの品質向上に役立てることができます。エラー情報には、OSのバージョン、ブラウザの種類、行動履歴などが含まれます。"
myTheme: "マイテーマ" myTheme: "マイテーマ"
backgroundColor: "背景" backgroundColor: "背景"
accentColor: "アクセント" accentColor: "アクセント"
@ -742,7 +746,7 @@ unlikeConfirm: "いいね解除しますか?"
fullView: "フルビュー" fullView: "フルビュー"
quitFullView: "フルビュー解除" quitFullView: "フルビュー解除"
addDescription: "説明を追加" addDescription: "説明を追加"
userPagePinTip: "個々のノートのメニューから「ピン留め」を選択することで、ここにノートを表示しておくことができます。" userPagePinTip: "個々の投稿のメニューから「ピン留め」を選択することで、ここに投稿を表示しておくことができます。"
notSpecifiedMentionWarning: "宛先に含まれていないメンションがあります" notSpecifiedMentionWarning: "宛先に含まれていないメンションがあります"
info: "情報" info: "情報"
userInfo: "ユーザー情報" userInfo: "ユーザー情報"
@ -772,7 +776,7 @@ postToGallery: "ギャラリーへ投稿"
gallery: "ギャラリー" gallery: "ギャラリー"
recentPosts: "最近の投稿" recentPosts: "最近の投稿"
popularPosts: "人気の投稿" popularPosts: "人気の投稿"
shareWithNote: "ノートで共有" shareWithNote: "投稿で共有"
ads: "広告" ads: "広告"
expiration: "期限" expiration: "期限"
memo: "メモ" memo: "メモ"
@ -786,7 +790,7 @@ secureMode: "セキュアモード (Authorized Fetch)"
instanceSecurity: "インスタンスのセキュリティー" instanceSecurity: "インスタンスのセキュリティー"
secureModeInfo: "他のインスタンスからリクエストするときに、証明を付けなければ返送しません。他のインスタンスの設定ファイルでsignToActivityPubGetはtrueにしてください。" secureModeInfo: "他のインスタンスからリクエストするときに、証明を付けなければ返送しません。他のインスタンスの設定ファイルでsignToActivityPubGetはtrueにしてください。"
privateMode: "非公開モード" privateMode: "非公開モード"
privateModeInfo: "有効にして、許可されているインスタンスのみがリクエストできます。すべてのノートが公開に非表示にします。" privateModeInfo: "有効にして、許可されているインスタンスのみがリクエストできます。すべての投稿が公開に非表示にします。"
allowedInstances: "許可されたインスタンス" allowedInstances: "許可されたインスタンス"
allowedInstancesDescription: "許可したいインスタンスのホストを改行で区切って設定します。非公開モードだけで有効です。" allowedInstancesDescription: "許可したいインスタンスのホストを改行で区切って設定します。非公開モードだけで有効です。"
previewNoteText: "本文をプレビュー" previewNoteText: "本文をプレビュー"
@ -794,7 +798,7 @@ customCss: "カスタムCSS"
customCssWarn: "この設定は必ず知識のある方が行ってください。不適切な設定を行うとクライアントが正常に使用できなくなる恐れがあります。" customCssWarn: "この設定は必ず知識のある方が行ってください。不適切な設定を行うとクライアントが正常に使用できなくなる恐れがあります。"
global: "グローバル" global: "グローバル"
squareAvatars: "アイコンを四角形で表示" squareAvatars: "アイコンを四角形で表示"
seperateRenoteQuote: "リノートと引用ボタンを分ける" seperateRenoteQuote: "ブーストと引用ボタンを分ける"
sent: "送信" sent: "送信"
received: "受信" received: "受信"
searchResult: "検索結果" searchResult: "検索結果"
@ -802,13 +806,13 @@ hashtags: "ハッシュタグ"
troubleshooting: "トラブルシューティング" troubleshooting: "トラブルシューティング"
useBlurEffect: "UIにぼかし効果を使用" useBlurEffect: "UIにぼかし効果を使用"
learnMore: "詳しく" learnMore: "詳しく"
misskeyUpdated: "Misskeyが更新されました" misskeyUpdated: "Calckeyが更新されました"
whatIsNew: "更新情報を見る" whatIsNew: "更新情報を見る"
translate: "翻訳" translate: "翻訳"
translatedFrom: "{x}から翻訳" translatedFrom: "{x}から翻訳"
accountDeletionInProgress: "アカウントの削除が進行中です" accountDeletionInProgress: "アカウントの削除が進行中です"
usernameInfo: "サーバー上であなたのアカウントを一意に識別するための名前。アルファベット(a~z, A~Z)、数字(0~9)、およびアンダーバー(_)が使用できます。ユーザー名は後から変更することは出来ません。" usernameInfo: "サーバー上であなたのアカウントを一意に識別するための名前。アルファベット(a~z, A~Z)、数字(0~9)、およびアンダーバー(_)が使用できます。ユーザー名は後から変更することは出来ません。"
aiChanMode: "藍モード" aiChanMode: "藍モードクラシックUI"
enterSendsMessage: "メッセージングでReturnキーを押すと、メッセージが送信されますデフォルトはCtrl + Returnです" enterSendsMessage: "メッセージングでReturnキーを押すと、メッセージが送信されますデフォルトはCtrl + Returnです"
keepCw: "CWを維持する" keepCw: "CWを維持する"
pubSub: "Pub/Subのアカウント" pubSub: "Pub/Subのアカウント"
@ -912,15 +916,25 @@ customMOTDDescription: "ユーザがページをロード/リロードするた
customSplashIcons: "カスタムスプラッシュスクリーンアイコン" customSplashIcons: "カスタムスプラッシュスクリーンアイコン"
customSplashIconsDescription: "ユーザがページをロード/リロードするたびにランダムに表示される、改行で区切られたカスタムスプラッシュスクリーンアイコンの URL。画像は静的なURLで、できればすべて192x192にリサイズしてください。" customSplashIconsDescription: "ユーザがページをロード/リロードするたびにランダムに表示される、改行で区切られたカスタムスプラッシュスクリーンアイコンの URL。画像は静的なURLで、できればすべて192x192にリサイズしてください。"
showUpdates: "Calckeyの更新時にポップアップを表示する" showUpdates: "Calckeyの更新時にポップアップを表示する"
recommendedInstances: "推奨インスタンス" recommendedInstances: "おすすめインスタンス"
recommendedInstancesDescription: "推奨タイムラインに表示するために改行で区切られた推奨インスタンス。`https://`を追加しないでください。ドメインのみを追加してください。" recommendedInstancesDescription: "おすすめタイムラインに表示される、改行で区切られたインスタンス。`https://`を追加しないでください。ドメインのみを追加してください。"
caption: "自動キャプション" caption: "自動キャプション"
splash: "スプラッシュスクリーン" splash: "スプラッシュスクリーン"
updateAvailable: "アップデートがありますよ" updateAvailable: "アップデートがありますよ"
swipeOnDesktop: "デスクトップでモバイルスタイルのスワイプを可能にする" swipeOnDesktop: "デスクトップでモバイルスタイルのスワイプを可能にする"
logoImageUrl: "ロゴのURL" logoImageUrl: "ロゴのURL"
showAdminUpdates: "新しいCalckeyのバージョンが利用可能であることを示す(管理者のみ)" showAdminUpdates: "新しいCalckeyのバージョンが利用可能であることを示す(管理者のみ)"
replayTutorial: "リプレイチュートリアル" replayTutorial: "もう一度チュートリアルを見る"
migration: "アカウントの引っ越し"
moveTo: "このアカウントを新しいアカウントに引っ越す"
moveToLabel: "引っ越し先のアカウント:"
moveAccount: "引っ越し実行!"
moveAccountDescription: "この操作は取り消せません。まずは引っ越し先のアカウントでこのアカウントに対しエイリアスを作成したことを確認してください。エイリアス作成後、引っ越し先のアカウントをこのように入力してください:@person@instance.com"
moveFrom: "別のアカウントからこのアカウントに引っ越す"
moveFromLabel: "引っ越し元のアカウント:"
moveFromDescription: "別のアカウントからこのアカウントにフォロワーを引き継いで引っ越したい場合、ここでエイリアスを作成しておく必要があります。必ず引っ越しを実行する前に作成してください!引っ越し元のアカウントをこのように入力してください:@person@instance.com"
migrationConfirm: "本当にこのアカウントを {account} に引っ越しますか?一度引っ越しを行うと取り消せず、二度とこのアカウントを元の状態で使用することはできません。\nまた、引っ越し先のアカウントでエイリアスを作成したことを確認してください。"
defaultReaction: "リモートとローカルの投稿に対するデフォルトの絵文字リアクション"
_sensitiveMediaDetection: _sensitiveMediaDetection:
description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てることができます。サーバーの負荷が少し増えます。" description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てることができます。サーバーの負荷が少し増えます。"
@ -930,24 +944,20 @@ _sensitiveMediaDetection:
setSensitiveFlagAutomaticallyDescription: "この設定をオフにしても内部的に判定結果は保持されます。" setSensitiveFlagAutomaticallyDescription: "この設定をオフにしても内部的に判定結果は保持されます。"
analyzeVideos: "動画の解析を有効化" analyzeVideos: "動画の解析を有効化"
analyzeVideosDescription: "静止画に加えて動画も解析するようにします。サーバーの負荷が少し増えます。" analyzeVideosDescription: "静止画に加えて動画も解析するようにします。サーバーの負荷が少し増えます。"
_emailUnavailable: _emailUnavailable:
used: "既に使用されています" used: "既に使用されています"
format: "形式が正しくありません" format: "形式が正しくありません"
disposable: "恒久的に使用可能なアドレスではありません" disposable: "恒久的に使用可能なアドレスではありません"
mx: "正しいメールサーバーではありません" mx: "正しいメールサーバーではありません"
smtp: "メールサーバーが応答しません" smtp: "メールサーバーが応答しません"
_ffVisibility: _ffVisibility:
public: "公開" public: "公開"
followers: "フォロワーだけに公開" followers: "フォロワーだけに公開"
private: "非公開" private: "非公開"
_signup: _signup:
almostThere: "ほとんど完了です" almostThere: "ほとんど完了です"
emailAddressInfo: "あなたが使っているメールアドレスを入力してください。メールアドレスが公開されることはありません。" emailAddressInfo: "あなたが使っているメールアドレスを入力してください。メールアドレスが公開されることはありません。"
emailSent: "入力されたメールアドレス({email})宛に確認のメールが送信されました。メールに記載されたリンクにアクセスすると、アカウントの作成が完了します。" emailSent: "入力されたメールアドレス({email})宛に確認のメールが送信されました。メールに記載されたリンクにアクセスすると、アカウントの作成が完了します。"
_accountDelete: _accountDelete:
accountDelete: "アカウントの削除" accountDelete: "アカウントの削除"
mayTakeTime: "アカウントの削除は負荷のかかる処理であるため、作成したコンテンツの数やアップロードしたファイルの数が多いと完了までに時間がかかることがあります。" mayTakeTime: "アカウントの削除は負荷のかかる処理であるため、作成したコンテンツの数やアップロードしたファイルの数が多いと完了までに時間がかかることがあります。"
@ -955,33 +965,27 @@ _accountDelete:
requestAccountDelete: "アカウント削除をリクエスト" requestAccountDelete: "アカウント削除をリクエスト"
started: "削除処理が開始されました。" started: "削除処理が開始されました。"
inProgress: "削除が進行中" inProgress: "削除が進行中"
_ad: _ad:
back: "戻る" back: "戻る"
reduceFrequencyOfThisAd: "この広告の表示頻度を下げる" reduceFrequencyOfThisAd: "この広告の表示頻度を下げる"
_forgotPassword: _forgotPassword:
enterEmail: "アカウントに登録したメールアドレスを入力してください。そのアドレス宛てに、パスワードリセット用のリンクが送信されます。" enterEmail: "アカウントに登録したメールアドレスを入力してください。そのアドレス宛てに、パスワードリセット用のリンクが送信されます。"
ifNoEmail: "メールアドレスを登録していない場合は、管理者までお問い合わせください。" ifNoEmail: "メールアドレスを登録していない場合は、管理者までお問い合わせください。"
contactAdmin: "このインスタンスではメールがサポートされていないため、パスワードリセットを行う場合は管理者までお問い合わせください。" contactAdmin: "このインスタンスではメールがサポートされていないため、パスワードリセットを行う場合は管理者までお問い合わせください。"
_gallery: _gallery:
my: "自分の投稿" my: "自分の投稿"
liked: "いいねした投稿" liked: "いいねした投稿"
like: "いいね!" like: "いいね!"
unlike: "いいね解除" unlike: "いいね解除"
_email: _email:
_follow: _follow:
title: "フォローされました" title: "フォローされました"
_receiveFollowRequest: _receiveFollowRequest:
title: "フォローリクエストを受け取りました" title: "フォローリクエストを受け取りました"
_plugin: _plugin:
install: "プラグインのインストール" install: "プラグインのインストール"
installWarn: "信頼できないプラグインはインストールしないでください。" installWarn: "信頼できないプラグインはインストールしないでください。"
manage: "プラグインの管理" manage: "プラグインの管理"
_preferencesBackups: _preferencesBackups:
list: "作成したバックアップ" list: "作成したバックアップ"
saveNew: "新規保存" saveNew: "新規保存"
@ -1000,33 +1004,29 @@ _preferencesBackups:
updatedAt: "更新日時: {date} {time}" updatedAt: "更新日時: {date} {time}"
cannotLoad: "読み込みできません" cannotLoad: "読み込みできません"
invalidFile: "ファイル形式が違います。" invalidFile: "ファイル形式が違います。"
_registry: _registry:
scope: "スコープ" scope: "スコープ"
key: "キー" key: "キー"
keys: "キー" keys: "キー"
domain: "ドメイン" domain: "ドメイン"
createKey: "キーを作成" createKey: "キーを作成"
_aboutMisskey: _aboutMisskey:
about: "Calckeyは、2022年から開発されているThatOneCalculator社製のMisskeyのforkです。" about: "Calckeyは、2022年に生まれたThatOneCalculatorによるMisskeyのforkです。"
contributors: "主なコントリビューター" contributors: "主なコントリビューター"
allContributors: "全てのコントリビューター" allContributors: "全てのコントリビューター"
source: "ソースコード" source: "ソースコード"
translation: "Misskeyを翻訳" translation: "Calckeyを翻訳"
donate: "Misskeyに寄付" donate: "Calckeyに寄付"
morePatrons: "他にも多くの方が支援してくれています。ありがとうございます🥰" morePatrons: "他にも多くの方が支援してくれています。ありがとうございます 🥰"
patrons: "支援者" patrons: "支援者"
_nsfw: _nsfw:
respect: "閲覧注意のメディアは隠す" respect: "閲覧注意のメディアは隠す"
ignore: "閲覧注意のメディアを隠さない" ignore: "閲覧注意のメディアを隠さない"
force: "常にメディアを隠す" force: "常にメディアを隠す"
_mfm: _mfm:
cheatSheet: "MFMチートシート" cheatSheet: "MFMチートシート"
intro: "MFMは、Misskey内の様々な場所で使用できる専用のマークアップ言語です。ここでは、MFMで使用可能な構文一覧が確認できます。" intro: "MFMは、MisskeyやCalckey、Akkomaなどの様々な場所で使用できるマークアップ言語です。ここでは、MFMで使用可能な構文一覧が確認できます。"
dummy: "MisskeyでFediverseの世界が広がります" dummy: "CalckeyでFediverseの世界が広がります"
mention: "メンション" mention: "メンション"
mentionDescription: "アットマーク + ユーザー名で、特定のユーザーを示すことができます。" mentionDescription: "アットマーク + ユーザー名で、特定のユーザーを示すことができます。"
hashtag: "ハッシュタグ" hashtag: "ハッシュタグ"
@ -1089,18 +1089,15 @@ _mfm:
rotateDescription: "指定した角度で回転させます。" rotateDescription: "指定した角度で回転させます。"
plain: "プレーン" plain: "プレーン"
plainDescription: "内側の構文を全て無効にします。" plainDescription: "内側の構文を全て無効にします。"
_instanceTicker: _instanceTicker:
none: "表示しない" none: "表示しない"
remote: "リモートユーザーに表示" remote: "リモートユーザーに表示"
always: "常に表示" always: "常に表示"
_serverDisconnectedBehavior: _serverDisconnectedBehavior:
reload: "自動でリロード" reload: "自動でリロード"
dialog: "ダイアログで警告" dialog: "ダイアログで警告"
quiet: "控えめに警告" quiet: "控えめに警告"
nothing: "何も起こらない" nothing: "何も起こらない"
_channel: _channel:
create: "チャンネルを作成" create: "チャンネルを作成"
edit: "チャンネルを編集" edit: "チャンネルを編集"
@ -1111,33 +1108,28 @@ _channel:
following: "フォロー中" following: "フォロー中"
usersCount: "{n}人が参加中" usersCount: "{n}人が参加中"
notesCount: "{n}投稿があります" notesCount: "{n}投稿があります"
_messaging: _messaging:
dms: "ディーエム" dms: "ディーエム"
groups: "グループ" groups: "グループ"
_menuDisplay: _menuDisplay:
sideFull: "横" sideFull: "横"
sideIcon: "横(アイコン)" sideIcon: "横(アイコン)"
top: "上部" top: "上部"
hide: "隠す" hide: "隠す"
_wordMute: _wordMute:
muteWords: "ミュートするワード" muteWords: "ミュートするワード"
muteWordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。" muteWordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。"
muteWordsDescription2: "キーワードをスラッシュで囲むと正規表現になります。" muteWordsDescription2: "キーワードをスラッシュで囲むと正規表現になります。"
softDescription: "指定した条件のノートをタイムラインから隠します。" softDescription: "指定した条件の投稿をタイムラインから隠します。"
hardDescription: "指定した条件のノートをタイムラインに追加しないようにします。追加されなかったノートは、条件を変更しても除外されたままになります。" hardDescription: "指定した条件の投稿をタイムラインに追加しないようにします。追加されなかった投稿は、条件を変更しても除外されたままになります。"
soft: "ソフト" soft: "ソフト"
hard: "ハード" hard: "ハード"
mutedNotes: "ミュートされたノート" mutedNotes: "ミュートされた投稿"
_instanceMute: _instanceMute:
instanceMuteDescription: "ミュートしたインスタンスのユーザーへの返信を含めて、設定したインスタンスの全てのートとRenoteをミュートします。" instanceMuteDescription: "ミュートしたインスタンスのユーザーへの返信を含めて、設定したインスタンスの全ての投稿とブーストをミュートします。"
instanceMuteDescription2: "改行で区切って設定します" instanceMuteDescription2: "改行で区切って設定します"
title: "設定したインスタンスのノートを隠します。" title: "設定したインスタンスの投稿を隠します。"
heading: "ミュートするインスタンス" heading: "ミュートするインスタンス"
_theme: _theme:
explore: "テーマを探す" explore: "テーマを探す"
install: "テーマのインストール" install: "テーマのインストール"
@ -1168,7 +1160,6 @@ _theme:
inputConstantName: "定数名を入力してください" inputConstantName: "定数名を入力してください"
importInfo: "ここにテーマコードを貼り付けて、エディターにインポートできます" importInfo: "ここにテーマコードを貼り付けて、エディターにインポートできます"
deleteConstantConfirm: "定数 {const} を削除しても良いですか?" deleteConstantConfirm: "定数 {const} を削除しても良いですか?"
keys: keys:
accent: "アクセント" accent: "アクセント"
bg: "背景" bg: "背景"
@ -1187,7 +1178,7 @@ _theme:
hashtag: "ハッシュタグ" hashtag: "ハッシュタグ"
mention: "メンション" mention: "メンション"
mentionMe: "あなた宛てメンション" mentionMe: "あなた宛てメンション"
renote: "Renote" renote: "ブースト"
modalBg: "モーダルの背景" modalBg: "モーダルの背景"
divider: "分割線" divider: "分割線"
scrollbarHandle: "スクロールバーの取っ手" scrollbarHandle: "スクロールバーの取っ手"
@ -1213,16 +1204,14 @@ _theme:
accentDarken: "アクセント (暗め)" accentDarken: "アクセント (暗め)"
accentLighten: "アクセント (明るめ)" accentLighten: "アクセント (明るめ)"
fgHighlighted: "強調された文字" fgHighlighted: "強調された文字"
_sfx: _sfx:
note: "ノート" note: "投稿"
noteMy: "ノート(自分)" noteMy: "投稿(自分)"
notification: "通知" notification: "通知"
chat: "チャット" chat: "チャット"
chatBg: "チャット(バックグラウンド)" chatBg: "チャット(バックグラウンド)"
antenna: "アンテナ受信" antenna: "アンテナ受信"
channel: "チャンネル通知" channel: "チャンネル通知"
_ago: _ago:
future: "未来" future: "未来"
justNow: "たった今" justNow: "たった今"
@ -1233,35 +1222,32 @@ _ago:
weeksAgo: "{n}週間前" weeksAgo: "{n}週間前"
monthsAgo: "{n}ヶ月前" monthsAgo: "{n}ヶ月前"
yearsAgo: "{n}年前" yearsAgo: "{n}年前"
_time: _time:
second: "秒" second: "秒"
minute: "分" minute: "分"
hour: "時間" hour: "時間"
day: "日" day: "日"
_tutorial: _tutorial:
title: "Calckeyの使い方" title: "Calckeyの使い方"
step1_1: "ようこそ!" step1_1: "ようこそ"
step1_2: "設定をしてみましょう" step1_2: "使い始める前に、いくつか設定を済ませましょう。すぐできますよ!"
step2_1: "メモを書いたり、誰かをフォローする前に、プロフィールの設定を済ませましょう。" step2_1: "最初に、あなたのプロフィールを作りましょう。"
step2_2: "あなたが誰なのか、いくつかの情報を提供することで、他の人があなたのメモを見たり、フォローしたりしたいのかがわかりやすくなります。" step2_2: "プロフィールを設定することで、他の人があなたの投稿を見たり、フォローしたりするときの助けになります。"
step3_1: "さあ、何人かの人をフォローする時間です" step3_1: "それでは、何人かフォローしてみましょう"
step3_2: "あなたのホームとソーシャルタイムラインは、あなたが誰をフォローしているかで決まります。 まずは、いくつかのアカウントをフォローしてみましょう。" step3_2: "あなたのホームとソーシャルタイムラインは、あなたが誰をフォローしているかで決まります。まずは、いくつかのアカウントをフォローしてみましょう。\nプロフィールの右上にある丸いボタンをクリックするとフォローできます。"
step4_1: "さあ、外に出てみましょう。" step4_1: "投稿してみましょう!"
step4_2: "最初の投稿は、{introduction}の投稿や、シンプルに「こんにちは、世界よ!」的な投稿をするのが好きな人もいます。" step4_2: "最初は{introduction}に投稿したり、シンプルに「こんにちは、アカウント作ってみました!」などの投稿をする人もいます。"
step5_1: "タイムライン、タイムラインだらけ!" step5_1: "タイムライン、タイムラインだらけ!"
step5_2: "あなたのインスタンスは{timelines}異なるタイムラインを有効にしています。" step5_2: "あなたのインスタンスでは{timelines}種類のタイムラインが有効になっています。"
step5_3: "ホーム{icon}のタイムラインは、あなたのフォロワーからの投稿を見ることができます。" step5_3: "ホーム{icon}タイムラインでは、あなたがフォローしているアカウントの投稿を見ることができます。"
step5_4: "ローカル{icon}タイムラインは、このインスタンスのみんなの投稿を見ることができる場所です。" step5_4: "ローカル{icon}タイムラインでは、このインスタンスのみんなの投稿を見ることができます。"
step5_5: "おすすめ{icon}のタイムラインは、管理人がおすすめするインスタンスの投稿を見ることができます。" step5_5: "おすすめ{icon}タイムラインでは、管理人がおすすめするインスタンスの投稿を見ることができます。"
step5_6: "ソーシャル{icon}のタイムラインは、あなたのフォロワーの友達の投稿を見ることができる場所です。" step5_6: "ソーシャル{icon}タイムラインでは、ホームタイムラインとローカルタイムラインの投稿を同時に見ることができます。"
step5_7: "グローバル{icon}タイムラインは、接続している他のすべてのインスタンスからの投稿を見ることができます。" step5_7: "グローバル{icon}タイムラインでは、接続している他のすべてのインスタンスからの投稿を見ることができます。"
step6_1: "それで、ここは何なの?" step6_1: "じゃあ、ここはどんな場所なの?"
step6_2: "まあ、あなたはCalckeyに参加しただけではありません。何千ものサーバーが相互接続されたネットワークで インスタンスと呼ばれる。" step6_2: "実は、あなたはただCalckeyに参加しただけではありません。ここは、何千もの相互接続されたサーバーが構成する Fediverse への入口です。各サーバーは「インスタンス」と呼ばれます。"
step6_3: "各サーバーは異なる方法で動作し、すべてのサーバーがCalckeyを実行するわけではありません。でも、このサーバーは動くんです" step6_3: "それぞれのサーバーでは必ずしもCalckeyが使われているわけではなく、異なる動作をするサーバーもあります。しかし、あなたは他のサーバーのアカウントもフォローしたり、返信・ブーストができます。一見難しそうですが大丈夫すぐ慣れます。"
step6_4: "さあ、探検して、楽しんでください!" step6_4: "これで完了です。お楽しみください!"
_2fa: _2fa:
alreadyRegistered: "既に設定は完了しています。" alreadyRegistered: "既に設定は完了しています。"
registerDevice: "デバイスを登録" registerDevice: "デバイスを登録"
@ -1272,7 +1258,6 @@ _2fa:
step3: "アプリに表示されているトークンを入力して完了です。" step3: "アプリに表示されているトークンを入力して完了です。"
step4: "これからログインするときも、同じようにトークンを入力します。" step4: "これからログインするときも、同じようにトークンを入力します。"
securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキーもしくは端末の指紋認証やPINを使用してログインするように設定できます。" securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキーもしくは端末の指紋認証やPINを使用してログインするように設定できます。"
_permissions: _permissions:
"read:account": "アカウントの情報を見る" "read:account": "アカウントの情報を見る"
"write:account": "アカウントの情報を変更する" "write:account": "アカウントの情報を変更する"
@ -1288,7 +1273,7 @@ _permissions:
"write:messaging": "チャットを操作する" "write:messaging": "チャットを操作する"
"read:mutes": "ミュートを見る" "read:mutes": "ミュートを見る"
"write:mutes": "ミュートを操作する" "write:mutes": "ミュートを操作する"
"write:notes": "ノートを作成・削除する" "write:notes": "投稿を作成・削除する"
"read:notifications": "通知を見る" "read:notifications": "通知を見る"
"write:notifications": "通知を操作する" "write:notifications": "通知を操作する"
"read:reactions": "リアクションを見る" "read:reactions": "リアクションを見る"
@ -1306,22 +1291,21 @@ _permissions:
"write:gallery": "ギャラリーを操作する" "write:gallery": "ギャラリーを操作する"
"read:gallery-likes": "ギャラリーのいいねを見る" "read:gallery-likes": "ギャラリーのいいねを見る"
"write:gallery-likes": "ギャラリーのいいねを操作する" "write:gallery-likes": "ギャラリーのいいねを操作する"
_auth: _auth:
shareAccess: "「{name}」がアカウントにアクセスすることを許可しますか?" shareAccess: "「{name}」がアカウントにアクセスすることを許可しますか?"
shareAccessAsk: "アカウントへのアクセスを許可しますか?" shareAccessAsk: "アカウントへのアクセスを許可しますか?"
permissionAsk: "このアプリは次の権限を要求しています" permissionAsk: "このアプリケーションは次の権限を要求しています"
pleaseGoBack: "アプリケーションに戻ってやっていってください" pleaseGoBack: "アプリケーションに戻り続行してください"
callback: "アプリケーションに戻っています" callback: "アプリケーションに戻っています"
denied: "アクセスを拒否しました" denied: "アクセスを拒否しました"
copyAsk: "以下の認証コードをアプリケーションにコピーしてください"
_antennaSources: _antennaSources:
all: "全てのノート" all: "全ての投稿"
homeTimeline: "フォローしているユーザーのノート" homeTimeline: "フォローしているユーザーの投稿"
users: "指定した一人または複数のユーザーのノート" users: "指定した一人または複数のユーザーの投稿"
userList: "指定したリストのユーザーのノート" userList: "指定したリストのユーザーの投稿"
userGroup: "指定したグループのユーザーのノート" userGroup: "指定したグループのユーザーの投稿"
instances: "指定したインスタンスの全ユーザーの投稿"
_weekday: _weekday:
sunday: "日曜日" sunday: "日曜日"
monday: "月曜日" monday: "月曜日"
@ -1330,7 +1314,6 @@ _weekday:
thursday: "木曜日" thursday: "木曜日"
friday: "金曜日" friday: "金曜日"
saturday: "土曜日" saturday: "土曜日"
_widgets: _widgets:
memo: "付箋" memo: "付箋"
notifications: "通知" notifications: "通知"
@ -1353,14 +1336,14 @@ _widgets:
jobQueue: "ジョブキュー" jobQueue: "ジョブキュー"
serverMetric: "サーバーメトリクス" serverMetric: "サーバーメトリクス"
aiscript: "AiScriptコンソール" aiscript: "AiScriptコンソール"
aichan: "藍" userList: "ユーザーリスト"
_userList:
chooseList: "リストを選択"
_cw: _cw:
hide: "隠す" hide: "隠す"
show: "もっと見る" show: "もっと見る"
chars: "{count}文字" chars: "{count}文字"
files: "{count}ファイル" files: "{count}ファイル"
_poll: _poll:
noOnlyOneChoice: "選択肢は最低2つ必要です" noOnlyOneChoice: "選択肢は最低2つ必要です"
choiceN: "選択肢{n}" choiceN: "選択肢{n}"
@ -1383,7 +1366,6 @@ _poll:
remainingHours: "終了まであと{h}時間{m}分" remainingHours: "終了まであと{h}時間{m}分"
remainingMinutes: "終了まであと{m}分{s}秒" remainingMinutes: "終了まであと{m}分{s}秒"
remainingSeconds: "終了まであと{s}秒" remainingSeconds: "終了まであと{s}秒"
_visibility: _visibility:
public: "パブリック" public: "パブリック"
publicDescription: "全てのユーザーに公開" publicDescription: "全てのユーザーに公開"
@ -1395,10 +1377,9 @@ _visibility:
specifiedDescription: "指定したユーザーのみに公開" specifiedDescription: "指定したユーザーのみに公開"
localOnly: "ローカルのみ" localOnly: "ローカルのみ"
localOnlyDescription: "リモートユーザーには非公開" localOnlyDescription: "リモートユーザーには非公開"
_postForm: _postForm:
replyPlaceholder: "このノートに返信..." replyPlaceholder: "この投稿に返信..."
quotePlaceholder: "このノートを引用..." quotePlaceholder: "この投稿を引用..."
channelPlaceholder: "チャンネルに投稿..." channelPlaceholder: "チャンネルに投稿..."
_placeholders: _placeholders:
a: "いまどうしてる?" a: "いまどうしてる?"
@ -1407,7 +1388,6 @@ _postForm:
d: "言いたいことは?" d: "言いたいことは?"
e: "ここに書いてください" e: "ここに書いてください"
f: "あなたが書くのを待っています..." f: "あなたが書くのを待っています..."
_profile: _profile:
name: "名前" name: "名前"
username: "ユーザー名" username: "ユーザー名"
@ -1420,51 +1400,47 @@ _profile:
metadataContent: "内容" metadataContent: "内容"
changeAvatar: "アバター画像を変更" changeAvatar: "アバター画像を変更"
changeBanner: "バナー画像を変更" changeBanner: "バナー画像を変更"
locationDescription: "正しく入力すると、あなたの現地時間が他のユーザーに表示されます。"
_exportOrImport: _exportOrImport:
allNotes: "全てのノート" allNotes: "全ての投稿"
followingList: "フォロー" followingList: "フォロー"
muteList: "ミュート" muteList: "ミュート"
blockingList: "ブロック" blockingList: "ブロック"
userLists: "リスト" userLists: "リスト"
excludeMutingUsers: "ミュートしているユーザーを除外" excludeMutingUsers: "ミュートしているユーザーを除外"
excludeInactiveUsers: "使われていないアカウントを除外" excludeInactiveUsers: "使われていないアカウントを除外"
_charts: _charts:
federation: "連合" federation: "連合"
apRequest: "リクエスト" apRequest: "リクエスト"
usersIncDec: "ユーザーの増減" usersIncDec: "ユーザーの増減"
usersTotal: "ユーザーの合計" usersTotal: "ユーザーの合計"
activeUsers: "アクティブユーザー数" activeUsers: "アクティブユーザー数"
notesIncDec: "ノートの増減" notesIncDec: "投稿の増減"
localNotesIncDec: "ローカルのノートの増減" localNotesIncDec: "ローカルの投稿の増減"
remoteNotesIncDec: "リモートのノートの増減" remoteNotesIncDec: "リモートの投稿の増減"
notesTotal: "ノートの合計" notesTotal: "投稿の合計"
filesIncDec: "ファイルの増減" filesIncDec: "ファイルの増減"
filesTotal: "ファイルの合計" filesTotal: "ファイルの合計"
storageUsageIncDec: "ストレージ使用量の増減" storageUsageIncDec: "ストレージ使用量の増減"
storageUsageTotal: "ストレージ使用量の合計" storageUsageTotal: "ストレージ使用量の合計"
_instanceCharts: _instanceCharts:
requests: "リクエスト" requests: "リクエスト"
users: "ユーザーの増減" users: "ユーザーの増減"
usersTotal: "ユーザーの累積" usersTotal: "ユーザーの累積"
notes: "ノートの増減" notes: "投稿の増減"
notesTotal: "ノートの累積" notesTotal: "投稿の累積"
ff: "フォロー/フォロワーの増減" ff: "フォロー/フォロワーの増減"
ffTotal: "フォロー/フォロワーの累積" ffTotal: "フォロー/フォロワーの累積"
cacheSize: "キャッシュサイズの増減" cacheSize: "キャッシュサイズの増減"
cacheSizeTotal: "キャッシュサイズの累積" cacheSizeTotal: "キャッシュサイズの累積"
files: "ファイル数の増減" files: "ファイル数の増減"
filesTotal: "ファイル数の累積" filesTotal: "ファイル数の累積"
_timelines: _timelines:
home: "ホーム" home: "ホーム"
local: "ローカル" local: "ローカル"
recommended: "一押し" recommended: "おすすめ"
social: "ソーシャル" social: "ソーシャル"
global: "グローバル" global: "グローバル"
_pages: _pages:
newPage: "ページの作成" newPage: "ページの作成"
editPage: "ページの編集" editPage: "ページの編集"
@ -1511,59 +1487,49 @@ _pages:
section: "セクション" section: "セクション"
image: "画像" image: "画像"
button: "ボタン" button: "ボタン"
if: "もし" if: "もし"
_if: _if:
variable: "変数" variable: "変数"
post: "投稿フォーム" post: "投稿フォーム"
_post: _post:
text: "内容" text: "内容"
attachCanvasImage: "キャンバスの画像を添付する" attachCanvasImage: "キャンバスの画像を添付する"
canvasId: "キャンバスID" canvasId: "キャンバスID"
textInput: "テキスト入力" textInput: "テキスト入力"
_textInput: _textInput:
name: "変数名" name: "変数名"
text: "タイトル" text: "タイトル"
default: "デフォルト値" default: "デフォルト値"
textareaInput: "複数行テキスト入力" textareaInput: "複数行テキスト入力"
_textareaInput: _textareaInput:
name: "変数名" name: "変数名"
text: "タイトル" text: "タイトル"
default: "デフォルト値" default: "デフォルト値"
numberInput: "数値入力" numberInput: "数値入力"
_numberInput: _numberInput:
name: "変数名" name: "変数名"
text: "タイトル" text: "タイトル"
default: "デフォルト値" default: "デフォルト値"
canvas: "キャンバス" canvas: "キャンバス"
_canvas: _canvas:
id: "キャンバスID" id: "キャンバスID"
width: "幅" width: "幅"
height: "高さ" height: "高さ"
note: "投稿の埋め込み"
note: "ノート埋め込み"
_note: _note:
id: "ノートID" id: "投稿のID"
idDescription: "ノートURLをペーストして設定することもできます。" idDescription: "投稿のURLをペーストして設定することもできます。"
detailed: "詳細な表示" detailed: "詳細な表示"
switch: "スイッチ" switch: "スイッチ"
_switch: _switch:
name: "変数名" name: "変数名"
text: "タイトル" text: "タイトル"
default: "デフォルト値" default: "デフォルト値"
counter: "カウンター" counter: "カウンター"
_counter: _counter:
name: "変数名" name: "変数名"
text: "タイトル" text: "タイトル"
inc: "増加値" inc: "増加値"
_button: _button:
text: "タイトル" text: "タイトル"
colored: "色付き" colored: "色付き"
@ -1582,14 +1548,12 @@ _pages:
callAiScript: "AiScript呼び出し" callAiScript: "AiScript呼び出し"
_callAiScript: _callAiScript:
functionName: "関数名" functionName: "関数名"
radioButton: "選択肢" radioButton: "選択肢"
_radioButton: _radioButton:
name: "変数名" name: "変数名"
title: "タイトル" title: "タイトル"
values: "改行で区切った選択肢" values: "改行で区切った選択肢"
default: "デフォルト値" default: "デフォルト値"
script: script:
categories: categories:
flow: "制御" flow: "制御"
@ -1766,18 +1730,16 @@ _pages:
enviromentVariables: "環境変数" enviromentVariables: "環境変数"
pageVariables: "ページ要素" pageVariables: "ページ要素"
argVariables: "入力スロット" argVariables: "入力スロット"
_relayStatus: _relayStatus:
requesting: "承認待ち" requesting: "承認待ち"
accepted: "承認済み" accepted: "承認済み"
rejected: "拒否済み" rejected: "拒否済み"
_notification: _notification:
fileUploaded: "ファイルがアップロードされました" fileUploaded: "ファイルがアップロードされました"
youGotMention: "{name}からのメンション" youGotMention: "{name}からのメンション"
youGotReply: "{name}からのリプライ" youGotReply: "{name}からのリプライ"
youGotQuote: "{name}による引用" youGotQuote: "{name}による引用"
youRenoted: "{name}がRenoteしました" youRenoted: "{name}がブーストしました"
youGotPoll: "{name}が投票しました" youGotPoll: "{name}が投票しました"
youGotMessagingMessageFromUser: "{name}からのチャットがあります" youGotMessagingMessageFromUser: "{name}からのチャットがあります"
youGotMessagingMessageFromGroup: "{name}のチャットがあります" youGotMessagingMessageFromGroup: "{name}のチャットがあります"
@ -1787,13 +1749,12 @@ _notification:
youWereInvitedToGroup: "{userName}があなたをグループに招待しました" youWereInvitedToGroup: "{userName}があなたをグループに招待しました"
pollEnded: "アンケートの結果が出ました" pollEnded: "アンケートの結果が出ました"
emptyPushNotificationMessage: "プッシュ通知の更新をしました" emptyPushNotificationMessage: "プッシュ通知の更新をしました"
_types: _types:
all: "すべて" all: "すべて"
follow: "フォロー" follow: "フォロー"
mention: "メンション" mention: "メンション"
reply: "リプライ" reply: "リプライ"
renote: "Renote" renote: "ブースト"
quote: "引用" quote: "引用"
reaction: "リアクション" reaction: "リアクション"
pollVote: "アンケートに投票された" pollVote: "アンケートに投票された"
@ -1802,12 +1763,10 @@ _notification:
followRequestAccepted: "フォローが受理された" followRequestAccepted: "フォローが受理された"
groupInvited: "グループに招待された" groupInvited: "グループに招待された"
app: "連携アプリからの通知" app: "連携アプリからの通知"
_actions: _actions:
followBack: "フォローバック" followBack: "フォローバック"
reply: "返信" reply: "返信"
renote: "Renote" renote: "ブースト"
_deck: _deck:
alwaysShowMainColumn: "常にメインカラムを表示" alwaysShowMainColumn: "常にメインカラムを表示"
columnAlign: "カラムの寄せ" columnAlign: "カラムの寄せ"
@ -1825,7 +1784,6 @@ _deck:
introduction: "カラムを組み合わせて自分だけのインターフェイスを作りましょう!" introduction: "カラムを組み合わせて自分だけのインターフェイスを作りましょう!"
introduction2: "画面の右にある + を押して、いつでもカラムを追加できます。" introduction2: "画面の右にある + を押して、いつでもカラムを追加できます。"
widgetsIntroduction: "カラムのメニューから、「ウィジェットの編集」を選択してウィジェットを追加してください" widgetsIntroduction: "カラムのメニューから、「ウィジェットの編集」を選択してウィジェットを追加してください"
_columns: _columns:
main: "メイン" main: "メイン"
widgets: "ウィジェット" widgets: "ウィジェット"
@ -1835,3 +1793,20 @@ _deck:
list: "リスト" list: "リスト"
mentions: "あなた宛て" mentions: "あなた宛て"
direct: "ダイレクト" direct: "ダイレクト"
_apps:
apps: "アプリ"
crossPlatform: "クロスプラットフォーム"
mobile: "モバイル"
firstParty: "ファーストパーティ"
firstClass: "対応度◎"
secondClass: "対応度○"
thirdClass: "対応度△"
free: "無料"
paid: "有料"
pwa: "PWAをインストール"
kaiteki: "Kaiteki"
milktea: "Milktea"
missLi: "MissLi"
mona: "Mona"
theDesk: "TheDesk"
lesskey: "Lesskey"

View file

@ -1,12 +1,12 @@
{ {
"name": "calckey", "name": "calckey",
"version": "13.1.3-rc2", "version": "13.2.0-rc",
"codename": "aqua", "codename": "aqua",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://codeberg.org/calckey/calckey.git" "url": "https://codeberg.org/calckey/calckey.git"
}, },
"packageManager": "pnpm@7.27.0", "packageManager": "pnpm@7.27.1",
"private": true, "private": true,
"scripts": { "scripts": {
"rebuild": "pnpm run clean && pnpm -r run build && pnpm run gulp", "rebuild": "pnpm run clean && pnpm -r run build && pnpm run gulp",
@ -20,6 +20,7 @@
"gulp": "gulp build", "gulp": "gulp build",
"watch": "pnpm run dev", "watch": "pnpm run dev",
"dev": "pnpm node ./scripts/dev.js", "dev": "pnpm node ./scripts/dev.js",
"dev:staging": "NODE_OPTIONS=--max_old_space_size=3072 NODE_ENV=development pnpm run build && pnpm run start",
"lint": "pnpm -r run lint", "lint": "pnpm -r run lint",
"cy:open": "cypress open --browser --e2e --config-file=cypress.config.ts", "cy:open": "cypress open --browser --e2e --config-file=cypress.config.ts",
"cy:run": "cypress run", "cy:run": "cypress run",
@ -38,9 +39,8 @@
"@bull-board/api": "^4.10.2", "@bull-board/api": "^4.10.2",
"@bull-board/ui": "^4.10.2", "@bull-board/ui": "^4.10.2",
"@tensorflow/tfjs": "^3.21.0", "@tensorflow/tfjs": "^3.21.0",
"calckey-js": "^0.0.20", "calckey-js": "^0.0.22",
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
"phosphor-icons": "^1.4.2",
"seedrandom": "^3.0.5" "seedrandom": "^3.0.5"
}, },
"devDependencies": { "devDependencies": {

BIN
packages/backend/assets/favicon.svg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
packages/backend/assets/inverse wordmark.svg (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -0,0 +1,10 @@
import {loadConfig} from './built/config.js';
import {createRedisConnection} from "./built/redis.js";
const config = loadConfig();
const redis = createRedisConnection(config);
redis.on('connect', () => redis.disconnect());
redis.on('error', (e) => {
throw e;
});

View file

@ -0,0 +1,17 @@
export class AntennaInstances1676093997212 {
name = 'AntennaInstances1676093997212'
async up(queryRunner) {
await queryRunner.query(`ALTER TYPE "antenna_src_enum" ADD VALUE 'instances'`);
await queryRunner.query(`ALTER TABLE "antenna" ADD "instances" jsonb NOT NULL DEFAULT '[]'`);
}
async down(queryRunner) {
await queryRunner.query(`DELETE FROM "antenna" WHERE "src" = 'instances'`);
await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "instances"`);
await queryRunner.query(`CREATE TYPE "public"."antenna_src_enum_old" AS ENUM('home', 'all', 'users', 'list', 'group')`);
await queryRunner.query(`ALTER TABLE "antenna" ALTER COLUMN "src" TYPE "public"."antenna_src_enum_old" USING "src"::"text"::"public"."antenna_src_enum_old"`);
await queryRunner.query(`DROP TYPE "public"."antenna_src_enum"`);
await queryRunner.query(`ALTER TYPE "public"."antenna_src_enum_old" RENAME TO "antenna_src_enum"`);
}
}

View file

@ -0,0 +1,11 @@
export class DriveComment1677935903517 {
name = 'DriveComment1677935903517'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "drive_file" ALTER "comment" TYPE character varying(8192)`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "drive_file" ALTER "comment" TYPE character varying(512)`);
}
}

View file

@ -8,6 +8,7 @@
"start:test": "NODE_ENV=test pnpm node ./built/index.js", "start:test": "NODE_ENV=test pnpm node ./built/index.js",
"migrate": "typeorm migration:run -d ormconfig.js", "migrate": "typeorm migration:run -d ormconfig.js",
"revertmigration": "typeorm migration:revert -d ormconfig.js", "revertmigration": "typeorm migration:revert -d ormconfig.js",
"check:connect": "node ./check_connect.js",
"build": "pnpm swc src -d built -D", "build": "pnpm swc src -d built -D",
"watch": "pnpm swc src -d built -D -w", "watch": "pnpm swc src -d built -D -w",
"lint": "pnpm rome check \"src/**/*.ts\"", "lint": "pnpm rome check \"src/**/*.ts\"",
@ -37,13 +38,17 @@
"@tensorflow/tfjs": "^4.2.0", "@tensorflow/tfjs": "^4.2.0",
"ajv": "8.11.2", "ajv": "8.11.2",
"archiver": "5.3.1", "archiver": "5.3.1",
"koa-body": "^6.0.1",
"autobind-decorator": "2.4.0",
"autolinker": "4.0.0",
"axios": "^1.3.2",
"autwh": "0.1.0", "autwh": "0.1.0",
"aws-sdk": "2.1277.0", "aws-sdk": "2.1277.0",
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"blurhash": "1.1.5", "blurhash": "1.1.5",
"bull": "4.10.2", "bull": "4.10.2",
"cacheable-lookup": "7.0.0", "cacheable-lookup": "7.0.0",
"calckey-js": "^0.0.20", "calckey-js": "^0.0.22",
"cbor": "8.1.0", "cbor": "8.1.0",
"chalk": "5.2.0", "chalk": "5.2.0",
"chalk-template": "0.4.0", "chalk-template": "0.4.0",
@ -67,6 +72,7 @@
"jsonld": "6.0.0", "jsonld": "6.0.0",
"jsrsasign": "10.6.1", "jsrsasign": "10.6.1",
"koa": "2.13.4", "koa": "2.13.4",
"koa-remove-trailing-slashes": "2.0.3",
"koa-bodyparser": "4.3.0", "koa-bodyparser": "4.3.0",
"koa-favicon": "2.1.0", "koa-favicon": "2.1.0",
"koa-json-body": "5.3.0", "koa-json-body": "5.3.0",
@ -75,6 +81,7 @@
"koa-send": "5.0.1", "koa-send": "5.0.1",
"koa-slow": "2.1.0", "koa-slow": "2.1.0",
"koa-views": "7.0.2", "koa-views": "7.0.2",
"@calckey/megalodon": "5.1.2",
"mfm-js": "0.23.2", "mfm-js": "0.23.2",
"mime-types": "2.1.35", "mime-types": "2.1.35",
"multer": "1.4.4-lts.1", "multer": "1.4.4-lts.1",
@ -92,6 +99,7 @@
"punycode": "2.1.1", "punycode": "2.1.1",
"pureimage": "0.3.15", "pureimage": "0.3.15",
"qrcode": "1.5.1", "qrcode": "1.5.1",
"qs": "6.9.7",
"random-seed": "0.3.0", "random-seed": "0.3.0",
"ratelimiter": "3.4.1", "ratelimiter": "3.4.1",
"re2": "1.18.0", "re2": "1.18.0",
@ -152,6 +160,7 @@
"@types/pug": "2.0.6", "@types/pug": "2.0.6",
"@types/punycode": "2.1.0", "@types/punycode": "2.1.0",
"@types/qrcode": "1.5.0", "@types/qrcode": "1.5.0",
"@types/qs": "6.9.7",
"@types/random-seed": "0.3.3", "@types/random-seed": "0.3.3",
"@types/ratelimiter": "3.4.4", "@types/ratelimiter": "3.4.4",
"@types/redis": "4.0.11", "@types/redis": "4.0.11",

View file

@ -0,0 +1 @@
declare module 'koa-remove-trailing-slashes';

View file

@ -74,6 +74,7 @@ export type Source = {
maxUserSignups?: number; maxUserSignups?: number;
isManagedHosting?: boolean; isManagedHosting?: boolean;
maxNoteLength?: number; maxNoteLength?: number;
maxCaptionLength?: number;
deepl: { deepl: {
managed?: boolean; managed?: boolean;
authKey?: string; authKey?: string;

View file

@ -1,7 +1,12 @@
import config from "@/config/index.js"; import config from "@/config/index.js";
import { DB_MAX_IMAGE_COMMENT_LENGTH } from "@/misc/hard-limits.js";
export const MAX_NOTE_TEXT_LENGTH = export const MAX_NOTE_TEXT_LENGTH =
config.maxNoteLength != null ? config.maxNoteLength : 3000; // <- should we increase this? config.maxNoteLength != null ? config.maxNoteLength : 3000; // <- should we increase this?
export const MAX_CAPTION_TEXT_LENGTH = Math.min(
config.maxCaptionLength ?? 1500,
DB_MAX_IMAGE_COMMENT_LENGTH,
);
export const SECOND = 1000; export const SECOND = 1000;
export const SEC = 1000; // why do we need this duplicate here? export const SEC = 1000; // why do we need this duplicate here?

View file

@ -80,6 +80,13 @@ export async function checkHitAntenna(
) )
) )
return false; return false;
} else if (antenna.src === "instances") {
const instances = antenna.instances
.filter((x) => x !== "")
.map((host) => {
return host.toLowerCase();
});
if (!instances.includes(noteUser.host?.toLowerCase() ?? "")) return false;
} }
const keywords = antenna.keywords const keywords = antenna.keywords

View file

@ -2,3 +2,4 @@ import twemoji from "twemoji-parser/dist/lib/regex.js";
const twemojiRegex = twemoji.default; const twemojiRegex = twemoji.default;
export const emojiRegex = new RegExp(`(${twemojiRegex.source})`); export const emojiRegex = new RegExp(`(${twemojiRegex.source})`);
export const emojiRegexAtStartToEnd = new RegExp(`^(${twemojiRegex.source})$`);

View file

@ -11,26 +11,41 @@ const size = 128; // px
const n = 5; // resolution const n = 5; // resolution
const margin = size / 4; const margin = size / 4;
const colors = [ const colors = [
["#FF512F", "#DD2476"], ["#eb6f92", "#b4637a"],
["#FF61D2", "#FE9090"], ["#f6c177", "#ea9d34"],
["#72FFB6", "#10D164"], ["#ebbcba", "#d7827e"],
["#FD8451", "#FFBD6F"], ["#9ccfd8", "#56949f"],
["#305170", "#6DFC6B"], ["#c4a7e7", "#907aa9"],
["#00C0FF", "#4218B8"], ["#eb6f92", "#f6c177"],
["#009245", "#FCEE21"], ["#eb6f92", "#ebbcba"],
["#0100EC", "#FB36F4"], ["#eb6f92", "#31748f"],
["#FDABDD", "#374A5A"], ["#eb6f92", "#9ccfd8"],
["#38A2D7", "#561139"], ["#eb6f92", "#c4a7e7"],
["#121C84", "#8278DA"], ["#f6c177", "#eb6f92"],
["#5761B2", "#1FC5A8"], ["#f6c177", "#ebbcba"],
["#FFDB01", "#0E197D"], ["#f6c177", "#31748f"],
["#FF3E9D", "#0E1F40"], ["#f6c177", "#9ccfd8"],
["#766eff", "#00d4ff"], ["#f6c177", "#c4a7e7"],
["#9bff6e", "#00d4ff"], ["#ebbcba", "#eb6f92"],
["#ff6e94", "#00d4ff"], ["#ebbcba", "#f6c177"],
["#ffa96e", "#00d4ff"], ["#ebbcba", "#31748f"],
["#ffa96e", "#ff009d"], ["#ebbcba", "#9ccfd8"],
["#ffdd6e", "#ff009d"], ["#ebbcba", "#c4a7e7"],
["#31748f", "#eb6f92"],
["#31748f", "#f6c177"],
["#31748f", "#ebbcba"],
["#31748f", "#9ccfd8"],
["#31748f", "#c4a7e7"],
["#9ccfd8", "#eb6f92"],
["#9ccfd8", "#f6c177"],
["#9ccfd8", "#ebbcba"],
["#9ccfd8", "#31748f"],
["#9ccfd8", "#c4a7e7"],
["#c4a7e7", "#eb6f92"],
["#c4a7e7", "#f6c177"],
["#c4a7e7", "#ebbcba"],
["#c4a7e7", "#31748f"],
["#c4a7e7", "#9ccfd8"],
]; ];
const actualSize = size - margin * 2; const actualSize = size - margin * 2;

View file

@ -10,4 +10,4 @@ export const DB_MAX_NOTE_TEXT_LENGTH = 8192;
* Maximum image description length that can be stored in DB. * Maximum image description length that can be stored in DB.
* Surrogate pairs count as one * Surrogate pairs count as one
*/ */
export const DB_MAX_IMAGE_COMMENT_LENGTH = 512; export const DB_MAX_IMAGE_COMMENT_LENGTH = 8192;

View file

@ -5,17 +5,17 @@ import { toPunyNullable } from "./convert-host.js";
import { IsNull } from "typeorm"; import { IsNull } from "typeorm";
const legacies = new Map([ const legacies = new Map([
['like', '👍'], ["like", "👍"],
['love', '❤️'], ["love", "❤️"],
['laugh', '😆'], ["laugh", "😆"],
['hmm', '🤔'], ["hmm", "🤔"],
['surprise', '😮'], ["surprise", "😮"],
['congrats', '🎉'], ["congrats", "🎉"],
['angry', '💢'], ["angry", "💢"],
['confused', '😥'], ["confused", "😥"],
['rip', '😇'], ["rip", "😇"],
['pudding', '🍮'], ["pudding", "🍮"],
['star', '⭐'], ["star", "⭐"],
]); ]);
export async function getFallbackReaction() { export async function getFallbackReaction() {
@ -42,7 +42,10 @@ export function convertLegacyReactions(reactions: Record<string, number>) {
if (emoji) { if (emoji) {
_reactions.set(emoji, (_reactions.get(emoji) || 0) + reactions[reaction]); _reactions.set(emoji, (_reactions.get(emoji) || 0) + reactions[reaction]);
} else { } else {
_reactions.set(reaction, (_reactions.get(reaction) || 0) + reactions[reaction]); _reactions.set(
reaction,
(_reactions.get(reaction) || 0) + reactions[reaction],
);
} }
} }

View file

@ -40,8 +40,8 @@ export class Antenna {
}) })
public name: string; public name: string;
@Column('enum', { enum: ['home', 'all', 'users', 'list', 'group'] }) @Column('enum', { enum: ['home', 'all', 'users', 'list', 'group', 'instances'] })
public src: "home" | "all" | "users" | "list" | "group"; public src: "home" | "all" | "users" | "list" | "group" | "instances";
@Column({ @Column({
...id(), ...id(),
@ -73,6 +73,11 @@ export class Antenna {
}) })
public users: string[]; public users: string[];
@Column('jsonb', {
default: [],
})
public instances: string[];
@Column('jsonb', { @Column('jsonb', {
default: [], default: [],
}) })

View file

@ -9,6 +9,7 @@ import {
import { id } from "../id.js"; import { id } from "../id.js";
import { User } from "./user.js"; import { User } from "./user.js";
import { DriveFolder } from "./drive-folder.js"; import { DriveFolder } from "./drive-folder.js";
import { DB_MAX_IMAGE_COMMENT_LENGTH } from "@/misc/hard-limits.js";
@Entity() @Entity()
@Index(['userId', 'folderId', 'id']) @Index(['userId', 'folderId', 'id'])
@ -69,7 +70,8 @@ export class DriveFile {
public size: number; public size: number;
@Column('varchar', { @Column('varchar', {
length: 512, nullable: true, length: DB_MAX_IMAGE_COMMENT_LENGTH,
nullable: true,
comment: 'The comment of the DriveFile.', comment: 'The comment of the DriveFile.',
}) })
public comment: string | null; public comment: string | null;

View file

@ -25,6 +25,7 @@ export const AntennaRepository = db.getRepository(Antenna).extend({
userListId: antenna.userListId, userListId: antenna.userListId,
userGroupId: userGroupJoining ? userGroupJoining.userGroupId : null, userGroupId: userGroupJoining ? userGroupJoining.userGroupId : null,
users: antenna.users, users: antenna.users,
instances: antenna.instances,
caseSensitive: antenna.caseSensitive, caseSensitive: antenna.caseSensitive,
notify: antenna.notify, notify: antenna.notify,
withReplies: antenna.withReplies, withReplies: antenna.withReplies,

View file

@ -197,6 +197,11 @@ export const NoteRepository = db.getRepository(Note).extend({
.map((x) => decodeReaction(x).reaction) .map((x) => decodeReaction(x).reaction)
.map((x) => x.replace(/:/g, "")); .map((x) => x.replace(/:/g, ""));
const noteEmoji = await populateEmojis(
note.emojis.concat(reactionEmojiNames),
host,
);
const reactionEmoji = await populateEmojis(reactionEmojiNames, host);
const packed: Packed<"Note"> = await awaitAll({ const packed: Packed<"Note"> = await awaitAll({
id: note.id, id: note.id,
createdAt: note.createdAt.toISOString(), createdAt: note.createdAt.toISOString(),
@ -213,8 +218,9 @@ export const NoteRepository = db.getRepository(Note).extend({
renoteCount: note.renoteCount, renoteCount: note.renoteCount,
repliesCount: note.repliesCount, repliesCount: note.repliesCount,
reactions: convertLegacyReactions(note.reactions), reactions: convertLegacyReactions(note.reactions),
reactionEmojis: reactionEmoji,
emojis: noteEmoji,
tags: note.tags.length > 0 ? note.tags : undefined, tags: note.tags.length > 0 ? note.tags : undefined,
emojis: populateEmojis(note.emojis.concat(reactionEmojiNames), host),
fileIds: note.fileIds, fileIds: note.fileIds,
files: DriveFiles.packMany(note.fileIds), files: DriveFiles.packMany(note.fileIds),
replyId: note.replyId, replyId: note.replyId,

View file

@ -52,7 +52,7 @@ export const packedAntennaSchema = {
type: "string", type: "string",
optional: false, optional: false,
nullable: false, nullable: false,
enum: ["home", "all", "users", "list", "group"], enum: ["home", "all", "users", "list", "group", "instances"],
}, },
userListId: { userListId: {
type: "string", type: "string",
@ -76,6 +76,16 @@ export const packedAntennaSchema = {
nullable: false, nullable: false,
}, },
}, },
instances: {
type: "array",
optional: false,
nullable: false,
items: {
type: "string",
optional: false,
nullable: false,
},
},
caseSensitive: { caseSensitive: {
type: "boolean", type: "boolean",
optional: false, optional: false,

View file

@ -161,27 +161,10 @@ export const packedNoteSchema = {
nullable: false, nullable: false,
}, },
emojis: { emojis: {
type: "array",
optional: false,
nullable: false,
items: {
type: "object", type: "object",
optional: false, optional: true,
nullable: false,
properties: {
name: {
type: "string",
optional: false,
nullable: false,
},
url: {
type: "string",
optional: false,
nullable: true, nullable: true,
}, },
},
},
},
reactions: { reactions: {
type: "object", type: "object",
optional: false, optional: false,

View file

@ -20,6 +20,7 @@ export default async (job: Bull.Job<WebhookDeliverJobData>) => {
"X-Calckey-Host": config.host, "X-Calckey-Host": config.host,
"X-Calckey-Hook-Id": job.data.webhookId, "X-Calckey-Hook-Id": job.data.webhookId,
"X-Calckey-Hook-Secret": job.data.secret, "X-Calckey-Hook-Secret": job.data.secret,
'Content-Type': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify({
hookId: job.data.webhookId, hookId: job.data.webhookId,

View file

@ -111,10 +111,37 @@ export async function createNote(
const note: IPost = object; const note: IPost = object;
logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); if (note.id && !note.id.startsWith("https://")) {
throw new Error(`unexpected shcema of note.id: ${note.id}`);
}
const url = getOneApHrefNullable(note.url);
if (url && !url.startsWith("https://")) {
throw new Error(`unexpected shcema of note url: ${url}`);
}
logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
logger.info(`Creating the Note: ${note.id}`); logger.info(`Creating the Note: ${note.id}`);
// Skip if note is made before 2007 (1yr before Fedi was created)
// OR skip if note is made 3 days in advance
if (note.published) {
const DateChecker = new Date(note.published);
const FutureCheck = new Date();
FutureCheck.setDate(FutureCheck.getDate() + 3); // Allow some wiggle room for misconfigured hosts
if (DateChecker.getFullYear() < 2007) {
logger.warn(
"Note somehow made before Activitypub was created; discarding",
);
return null;
}
if (DateChecker > FutureCheck) {
logger.warn("Note somehow made after today; discarding");
return null;
}
}
// Fetch author // Fetch author
const actor = (await resolvePerson( const actor = (await resolvePerson(
getOneApId(note.attributedTo), getOneApId(note.attributedTo),
@ -123,7 +150,10 @@ export async function createNote(
// Skip if author is suspended. // Skip if author is suspended.
if (actor.isSuspended) { if (actor.isSuspended) {
throw new Error("actor has been suspended"); logger.debug(
`User ${actor.usernameLower}@${actor.host} suspended; discarding.`,
);
return null;
} }
const noteAudience = await parseAudience(actor, note.to, note.cc); const noteAudience = await parseAudience(actor, note.to, note.cc);
@ -344,7 +374,7 @@ export async function createNote(
apEmojis, apEmojis,
poll, poll,
uri: note.id, uri: note.id,
url: getOneApHrefNullable(note.url), url: url,
}, },
silent, silent,
); );

View file

@ -195,6 +195,12 @@ export async function createPerson(
const bday = person["vcard:bday"]?.match(/^\d{4}-\d{2}-\d{2}/); const bday = person["vcard:bday"]?.match(/^\d{4}-\d{2}-\d{2}/);
const url = getOneApHrefNullable(person.url);
if (url && !url.startsWith("https://")) {
throw new Error(`unexpected shcema of person url: ${url}`);
}
// Create user // Create user
let user: IRemoteUser; let user: IRemoteUser;
try { try {
@ -237,7 +243,7 @@ export async function createPerson(
description: person.summary description: person.summary
? htmlToMfm(truncate(person.summary, summaryLength), person.tag) ? htmlToMfm(truncate(person.summary, summaryLength), person.tag)
: null, : null,
url: getOneApHrefNullable(person.url), url: url,
fields, fields,
birthday: bday ? bday[0] : null, birthday: bday ? bday[0] : null,
location: person["vcard:Address"] || null, location: person["vcard:Address"] || null,
@ -387,6 +393,12 @@ export async function updatePerson(
const bday = person["vcard:bday"]?.match(/^\d{4}-\d{2}-\d{2}/); const bday = person["vcard:bday"]?.match(/^\d{4}-\d{2}-\d{2}/);
const url = getOneApHrefNullable(person.url);
if (url && !url.startsWith("https://")) {
throw new Error(`unexpected shcema of person url: ${url}`);
}
const updates = { const updates = {
lastFetchedAt: new Date(), lastFetchedAt: new Date(),
inbox: person.inbox, inbox: person.inbox,
@ -401,8 +413,8 @@ export async function updatePerson(
isBot: getApType(object) === "Service", isBot: getApType(object) === "Service",
isCat: (person as any).isCat === true, isCat: (person as any).isCat === true,
isLocked: !!person.manuallyApprovesFollowers, isLocked: !!person.manuallyApprovesFollowers,
movedToUri: person.movedTo, movedToUri: person.movedTo || null,
alsoKnownAs: person.alsoKnownAs, alsoKnownAs: person.alsoKnownAs || null,
isExplorable: !!person.discoverable, isExplorable: !!person.discoverable,
} as Partial<User>; } as Partial<User>;
@ -430,7 +442,7 @@ export async function updatePerson(
await UserProfiles.update( await UserProfiles.update(
{ userId: exist.id }, { userId: exist.id },
{ {
url: getOneApHrefNullable(person.url), url: url,
fields, fields,
description: person.summary description: person.summary
? htmlToMfm(truncate(person.summary, summaryLength), person.tag) ? htmlToMfm(truncate(person.summary, summaryLength), person.tag)

View file

@ -107,6 +107,7 @@ export async function signup(opts: {
isAdmin: isAdmin:
(await Users.countBy({ (await Users.countBy({
host: IsNull(), host: IsNull(),
isAdmin: true,
})) === 0, })) === 0,
}), }),
); );

View file

@ -198,6 +198,7 @@ import * as ep___i_readAnnouncement from "./endpoints/i/read-announcement.js";
import * as ep___i_regenerateToken from "./endpoints/i/regenerate-token.js"; import * as ep___i_regenerateToken from "./endpoints/i/regenerate-token.js";
import * as ep___i_registry_getAll from "./endpoints/i/registry/get-all.js"; import * as ep___i_registry_getAll from "./endpoints/i/registry/get-all.js";
import * as ep___i_registry_getDetail from "./endpoints/i/registry/get-detail.js"; import * as ep___i_registry_getDetail from "./endpoints/i/registry/get-detail.js";
import * as ep___i_registry_getUnsecure from "./endpoints/i/registry/get-unsecure.js";
import * as ep___i_registry_get from "./endpoints/i/registry/get.js"; import * as ep___i_registry_get from "./endpoints/i/registry/get.js";
import * as ep___i_registry_keysWithType from "./endpoints/i/registry/keys-with-type.js"; import * as ep___i_registry_keysWithType from "./endpoints/i/registry/keys-with-type.js";
import * as ep___i_registry_keys from "./endpoints/i/registry/keys.js"; import * as ep___i_registry_keys from "./endpoints/i/registry/keys.js";
@ -221,6 +222,7 @@ import * as ep___messaging_messages_create from "./endpoints/messaging/messages/
import * as ep___messaging_messages_delete from "./endpoints/messaging/messages/delete.js"; import * as ep___messaging_messages_delete from "./endpoints/messaging/messages/delete.js";
import * as ep___messaging_messages_read from "./endpoints/messaging/messages/read.js"; import * as ep___messaging_messages_read from "./endpoints/messaging/messages/read.js";
import * as ep___meta from "./endpoints/meta.js"; import * as ep___meta from "./endpoints/meta.js";
import * as ep___sounds from "./endpoints/get-sounds.js";
import * as ep___miauth_genToken from "./endpoints/miauth/gen-token.js"; import * as ep___miauth_genToken from "./endpoints/miauth/gen-token.js";
import * as ep___mute_create from "./endpoints/mute/create.js"; import * as ep___mute_create from "./endpoints/mute/create.js";
import * as ep___mute_delete from "./endpoints/mute/delete.js"; import * as ep___mute_delete from "./endpoints/mute/delete.js";
@ -538,6 +540,7 @@ const eps = [
["i/regenerate-token", ep___i_regenerateToken], ["i/regenerate-token", ep___i_regenerateToken],
["i/registry/get-all", ep___i_registry_getAll], ["i/registry/get-all", ep___i_registry_getAll],
["i/registry/get-detail", ep___i_registry_getDetail], ["i/registry/get-detail", ep___i_registry_getDetail],
["i/registry/get-unsecure", ep___i_registry_getUnsecure],
["i/registry/get", ep___i_registry_get], ["i/registry/get", ep___i_registry_get],
["i/registry/keys-with-type", ep___i_registry_keysWithType], ["i/registry/keys-with-type", ep___i_registry_keysWithType],
["i/registry/keys", ep___i_registry_keys], ["i/registry/keys", ep___i_registry_keys],
@ -666,6 +669,7 @@ const eps = [
["users/stats", ep___users_stats], ["users/stats", ep___users_stats],
["admin/drive-capacity-override", ep___admin_driveCapOverride], ["admin/drive-capacity-override", ep___admin_driveCapOverride],
["fetch-rss", ep___fetchRss], ["fetch-rss", ep___fetchRss],
["get-sounds", ep___sounds],
]; ];
export interface IEndpointMeta { export interface IEndpointMeta {
@ -766,16 +770,16 @@ export interface IEndpointMeta {
export interface IEndpoint { export interface IEndpoint {
name: string; name: string;
exec: any; exec: any; // TODO: may be obosolete @ThatOneCalculator
meta: IEndpointMeta; meta: IEndpointMeta;
params: Schema; params: Schema;
} }
const endpoints: IEndpoint[] = eps.map(([name, ep]) => { const endpoints: IEndpoint[] = (eps as [string, any]).map(([name, ep]) => {
return { return {
name: name, name: name,
exec: ep.default, exec: ep.default,
meta: ep.meta || {}, meta: ep.meta ?? {},
params: ep.paramDef, params: ep.paramDef,
}; };
}); });

View file

@ -35,6 +35,7 @@ export default define(meta, paramDef, async (ps, _me) => {
const noUsers = const noUsers =
(await Users.countBy({ (await Users.countBy({
host: IsNull(), host: IsNull(),
isAdmin: true,
})) === 0; })) === 0;
if (!(noUsers || me?.isAdmin)) throw new Error("access denied"); if (!(noUsers || me?.isAdmin)) throw new Error("access denied");

View file

@ -1,7 +1,7 @@
import define from "../../define.js"; import define from "../../define.js";
import { Users } from "@/models/index.js"; import { Users } from "@/models/index.js";
import { User } from "@/models/entities/user.js";
import { insertModerationLog } from "@/services/insert-moderation-log.js"; import { insertModerationLog } from "@/services/insert-moderation-log.js";
import { publishInternalEvent } from "@/services/stream.js";
export const meta = { export const meta = {
tags: ["admin"], tags: ["admin"],
@ -29,17 +29,14 @@ export default define(meta, paramDef, async (ps, me) => {
throw new Error("user is not local user"); throw new Error("user is not local user");
} }
/*if (user.isAdmin) {
throw new Error('cannot suspend admin');
}
if (user.isModerator) {
throw new Error('cannot suspend moderator');
}*/
await Users.update(user.id, { await Users.update(user.id, {
driveCapacityOverrideMb: ps.overrideMb, driveCapacityOverrideMb: ps.overrideMb,
}); });
publishInternalEvent("localUserUpdated", {
id: user.id,
});
insertModerationLog(me, "change-drive-capacity-override", { insertModerationLog(me, "change-drive-capacity-override", {
targetId: user.id, targetId: user.id,
}); });

View file

@ -1,6 +1,6 @@
import config from "@/config/index.js"; import config from "@/config/index.js";
import { fetchMeta } from "@/misc/fetch-meta.js"; import { fetchMeta } from "@/misc/fetch-meta.js";
import { MAX_NOTE_TEXT_LENGTH } from "@/const.js"; import { MAX_NOTE_TEXT_LENGTH, MAX_CAPTION_TEXT_LENGTH } from "@/const.js";
import define from "../../define.js"; import define from "../../define.js";
export const meta = { export const meta = {
@ -86,6 +86,11 @@ export const meta = {
optional: false, optional: false,
nullable: false, nullable: false,
}, },
maxCaptionTextLength: {
type: "number",
optional: false,
nullable: false,
},
emojis: { emojis: {
type: "array", type: "array",
optional: false, optional: false,
@ -499,6 +504,7 @@ export default define(meta, paramDef, async (ps, me) => {
backgroundImageUrl: instance.backgroundImageUrl, backgroundImageUrl: instance.backgroundImageUrl,
logoImageUrl: instance.logoImageUrl, logoImageUrl: instance.logoImageUrl,
maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, // 後方互換性のため maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, // 後方互換性のため
maxCaptionTextLength: MAX_CAPTION_TEXT_LENGTH,
defaultLightTheme: instance.defaultLightTheme, defaultLightTheme: instance.defaultLightTheme,
defaultDarkTheme: instance.defaultDarkTheme, defaultDarkTheme: instance.defaultDarkTheme,
enableEmail: instance.enableEmail, enableEmail: instance.enableEmail,

View file

@ -37,7 +37,10 @@ export const paramDef = {
type: "object", type: "object",
properties: { properties: {
name: { type: "string", minLength: 1, maxLength: 100 }, name: { type: "string", minLength: 1, maxLength: 100 },
src: { type: "string", enum: ["home", "all", "users", "list", "group"] }, src: {
type: "string",
enum: ["home", "all", "users", "list", "group", "instances"],
},
userListId: { type: "string", format: "misskey:id", nullable: true }, userListId: { type: "string", format: "misskey:id", nullable: true },
userGroupId: { type: "string", format: "misskey:id", nullable: true }, userGroupId: { type: "string", format: "misskey:id", nullable: true },
keywords: { keywords: {
@ -64,6 +67,12 @@ export const paramDef = {
type: "string", type: "string",
}, },
}, },
instances: {
type: "array",
items: {
type: "string",
},
},
caseSensitive: { type: "boolean" }, caseSensitive: { type: "boolean" },
withReplies: { type: "boolean" }, withReplies: { type: "boolean" },
withFile: { type: "boolean" }, withFile: { type: "boolean" },
@ -75,6 +84,7 @@ export const paramDef = {
"keywords", "keywords",
"excludeKeywords", "excludeKeywords",
"users", "users",
"instances",
"caseSensitive", "caseSensitive",
"withReplies", "withReplies",
"withFile", "withFile",
@ -118,6 +128,7 @@ export default define(meta, paramDef, async (ps, user) => {
keywords: ps.keywords, keywords: ps.keywords,
excludeKeywords: ps.excludeKeywords, excludeKeywords: ps.excludeKeywords,
users: ps.users, users: ps.users,
instances: ps.instances,
caseSensitive: ps.caseSensitive, caseSensitive: ps.caseSensitive,
withReplies: ps.withReplies, withReplies: ps.withReplies,
withFile: ps.withFile, withFile: ps.withFile,

View file

@ -43,7 +43,10 @@ export const paramDef = {
properties: { properties: {
antennaId: { type: "string", format: "misskey:id" }, antennaId: { type: "string", format: "misskey:id" },
name: { type: "string", minLength: 1, maxLength: 100 }, name: { type: "string", minLength: 1, maxLength: 100 },
src: { type: "string", enum: ["home", "all", "users", "list", "group"] }, src: {
type: "string",
enum: ["home", "all", "users", "list", "group", "instances"],
},
userListId: { type: "string", format: "misskey:id", nullable: true }, userListId: { type: "string", format: "misskey:id", nullable: true },
userGroupId: { type: "string", format: "misskey:id", nullable: true }, userGroupId: { type: "string", format: "misskey:id", nullable: true },
keywords: { keywords: {
@ -70,6 +73,12 @@ export const paramDef = {
type: "string", type: "string",
}, },
}, },
instances: {
type: "array",
items: {
type: "string",
},
},
caseSensitive: { type: "boolean" }, caseSensitive: { type: "boolean" },
withReplies: { type: "boolean" }, withReplies: { type: "boolean" },
withFile: { type: "boolean" }, withFile: { type: "boolean" },
@ -82,6 +91,7 @@ export const paramDef = {
"keywords", "keywords",
"excludeKeywords", "excludeKeywords",
"users", "users",
"instances",
"caseSensitive", "caseSensitive",
"withReplies", "withReplies",
"withFile", "withFile",
@ -131,6 +141,7 @@ export default define(meta, paramDef, async (ps, user) => {
keywords: ps.keywords, keywords: ps.keywords,
excludeKeywords: ps.excludeKeywords, excludeKeywords: ps.excludeKeywords,
users: ps.users, users: ps.users,
instances: ps.instances,
caseSensitive: ps.caseSensitive, caseSensitive: ps.caseSensitive,
withReplies: ps.withReplies, withReplies: ps.withReplies,
withFile: ps.withFile, withFile: ps.withFile,

View file

@ -1,6 +1,5 @@
import define from "../../define.js"; import define from "../../define.js";
import { Channels, ChannelFollowings } from "@/models/index.js"; import { Channels, ChannelFollowings } from "@/models/index.js";
import { makePaginationQuery } from "../../common/make-pagination-query.js";
export const meta = { export const meta = {
tags: ["channels", "account"], tags: ["channels", "account"],
@ -33,11 +32,24 @@ export const paramDef = {
} as const; } as const;
export default define(meta, paramDef, async (ps, me) => { export default define(meta, paramDef, async (ps, me) => {
const query = makePaginationQuery( const query = ChannelFollowings.createQueryBuilder("following").andWhere({
ChannelFollowings.createQueryBuilder(), followerId: me.id,
ps.sinceId, });
ps.untilId, if (ps.sinceId) {
).andWhere({ followerId: me.id }); query.andWhere('following."followeeId" > :sinceId', {
sinceId: ps.sinceId,
});
}
if (ps.untilId) {
query.andWhere('following."followeeId" < :untilId', {
untilId: ps.untilId,
});
}
if (ps.sinceId && !ps.untilId) {
query.orderBy('following."followeeId"', "ASC");
} else {
query.orderBy('following."followeeId"', "DESC");
}
const followings = await query.take(ps.limit).getMany(); const followings = await query.take(ps.limit).getMany();

View file

@ -102,10 +102,13 @@ export default define(meta, paramDef, async (ps, me) => {
if (typeof ps.blocked === "boolean") { if (typeof ps.blocked === "boolean") {
const meta = await fetchMeta(true); const meta = await fetchMeta(true);
if (ps.blocked) { if (ps.blocked) {
if (meta.blockedHosts.length === 0) {
return [];
}
query.andWhere("instance.host IN (:...blocks)", { query.andWhere("instance.host IN (:...blocks)", {
blocks: meta.blockedHosts, blocks: meta.blockedHosts,
}); });
} else { } else if (meta.blockedHosts.length > 0) {
query.andWhere("instance.host NOT IN (:...blocks)", { query.andWhere("instance.host NOT IN (:...blocks)", {
blocks: meta.blockedHosts, blocks: meta.blockedHosts,
}); });

View file

@ -0,0 +1,30 @@
import { readdir } from "fs/promises";
import define from "../define.js";
export const meta = {
tags: ["meta"],
requireCredential: false,
requireCredentialPrivateMode: false,
} as const;
export const paramDef = {
type: "object",
properties: {},
required: [],
} as const;
export default define(meta, paramDef, async () => {
const music_files: (string | null)[] = [null];
const directory = (
await readdir("./assets/sounds", { withFileTypes: true })
).filter((potentialFolder) => potentialFolder.isDirectory());
for await (const folder of directory) {
const files = (await readdir(`./assets/sounds/${folder.name}`)).filter(
(potentialSong) => potentialSong.endsWith(".mp3"),
);
for await (const file of files) {
music_files.push(`${folder.name}/${file.replace(".mp3", "")}`);
}
}
return music_files;
});

View file

@ -0,0 +1,50 @@
import { ApiError } from "../../../error.js";
import define from "../../../define.js";
import { RegistryItems } from "@/models/index.js";
export const meta = {
requireCredential: true,
secure: false,
errors: {
noSuchKey: {
message: "No such key.",
code: "NO_SUCH_KEY",
id: "ac3ed68a-62f0-422b-a7bc-d5e09e8f6a6a",
},
},
} as const;
export const paramDef = {
type: "object",
properties: {
key: { type: "string" },
scope: {
type: "array",
default: [],
items: {
type: "string",
pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1),
},
},
},
required: ["key"],
} as const;
export default define(meta, paramDef, async (ps, user) => {
if (ps.key !== "reactions") return;
const query = RegistryItems.createQueryBuilder("item")
.where("item.domain IS NULL")
.andWhere("item.userId = :userId", { userId: user.id })
.andWhere("item.key = :key", { key: ps.key })
.andWhere("item.scope = :scope", { scope: ps.scope });
const item = await query.getOne();
if (item == null) {
throw new ApiError(meta.errors.noSuchKey);
}
return item.value;
});

View file

@ -2,8 +2,7 @@ import { IsNull, MoreThan } from "typeorm";
import config from "@/config/index.js"; import config from "@/config/index.js";
import { fetchMeta } from "@/misc/fetch-meta.js"; import { fetchMeta } from "@/misc/fetch-meta.js";
import { Ads, Emojis, Users } from "@/models/index.js"; import { Ads, Emojis, Users } from "@/models/index.js";
import { DB_MAX_NOTE_TEXT_LENGTH } from "@/misc/hard-limits.js"; import { MAX_NOTE_TEXT_LENGTH, MAX_CAPTION_TEXT_LENGTH } from "@/const.js";
import { MAX_NOTE_TEXT_LENGTH } from "@/const.js";
import define from "../define.js"; import define from "../define.js";
export const meta = { export const meta = {
@ -178,6 +177,11 @@ export const meta = {
optional: false, optional: false,
nullable: false, nullable: false,
}, },
maxCaptionTextLength: {
type: "number",
optional: false,
nullable: false,
},
emojis: { emojis: {
type: "array", type: "array",
optional: false, optional: false,
@ -456,6 +460,7 @@ export default define(meta, paramDef, async (ps, me) => {
backgroundImageUrl: instance.backgroundImageUrl, backgroundImageUrl: instance.backgroundImageUrl,
logoImageUrl: instance.logoImageUrl, logoImageUrl: instance.logoImageUrl,
maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, // 後方互換性のため maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, // 後方互換性のため
maxCaptionTextLength: MAX_CAPTION_TEXT_LENGTH,
emojis: instance.privateMode && !me ? [] : await Emojis.packMany(emojis), emojis: instance.privateMode && !me ? [] : await Emojis.packMany(emojis),
defaultLightTheme: instance.defaultLightTheme, defaultLightTheme: instance.defaultLightTheme,
defaultDarkTheme: instance.defaultDarkTheme, defaultDarkTheme: instance.defaultDarkTheme,
@ -489,6 +494,7 @@ export default define(meta, paramDef, async (ps, me) => {
requireSetup: requireSetup:
(await Users.countBy({ (await Users.countBy({
host: IsNull(), host: IsNull(),
isAdmin: true,
})) === 0, })) === 0,
} }
: {}), : {}),

View file

@ -27,6 +27,11 @@ export const paramDef = {
properties: { properties: {
limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, limit: { type: "integer", minimum: 1, maximum: 100, default: 10 },
offset: { type: "integer", default: 0 }, offset: { type: "integer", default: 0 },
origin: {
type: "string",
enum: ["combined", "local", "remote"],
default: "local",
},
}, },
required: [], required: [],
} as const; } as const;
@ -37,7 +42,7 @@ export default define(meta, paramDef, async (ps, user) => {
const query = Notes.createQueryBuilder("note") const query = Notes.createQueryBuilder("note")
.addSelect("note.score") .addSelect("note.score")
.where("note.userHost IS NULL") // .where("note.userHost IS NULL")
.andWhere("note.score > 0") .andWhere("note.score > 0")
.andWhere("note.createdAt > :date", { date: new Date(Date.now() - day) }) .andWhere("note.createdAt > :date", { date: new Date(Date.now() - day) })
.andWhere("note.visibility = 'public'") .andWhere("note.visibility = 'public'")
@ -53,6 +58,15 @@ export default define(meta, paramDef, async (ps, user) => {
.leftJoinAndSelect("renoteUser.avatar", "renoteUserAvatar") .leftJoinAndSelect("renoteUser.avatar", "renoteUserAvatar")
.leftJoinAndSelect("renoteUser.banner", "renoteUserBanner"); .leftJoinAndSelect("renoteUser.banner", "renoteUserBanner");
switch (ps.origin) {
case "local":
query.andWhere("note.userHost IS NULL");
break;
case "remote":
query.andWhere("note.userHost IS NOT NULL");
break;
}
if (user) generateMutedUserQuery(query, user); if (user) generateMutedUserQuery(query, user);
if (user) generateBlockedUserQuery(query, user); if (user) generateBlockedUserQuery(query, user);

View file

@ -7,8 +7,10 @@ import Router from "@koa/router";
import multer from "@koa/multer"; import multer from "@koa/multer";
import bodyParser from "koa-bodyparser"; import bodyParser from "koa-bodyparser";
import cors from "@koa/cors"; import cors from "@koa/cors";
import { apiMastodonCompatible, getClient } from "./mastodon/ApiMastodonCompatibleService.js";
import { Instances, AccessTokens, Users } from "@/models/index.js"; import { Instances, AccessTokens, Users } from "@/models/index.js";
import config from "@/config/index.js"; import config from "@/config/index.js";
import fs from "fs";
import endpoints from "./endpoints.js"; import endpoints from "./endpoints.js";
import compatibility from "./compatibility.js"; import compatibility from "./compatibility.js";
import handler from "./api-handler.js"; import handler from "./api-handler.js";
@ -18,6 +20,36 @@ import signupPending from "./private/signup-pending.js";
import discord from "./service/discord.js"; import discord from "./service/discord.js";
import github from "./service/github.js"; import github from "./service/github.js";
import twitter from "./service/twitter.js"; import twitter from "./service/twitter.js";
import { koaBody } from "koa-body";
export enum IdType {
CalckeyId,
MastodonId
};
export function convertId(idIn: string, idConvertTo: IdType ) {
let idArray = []
switch (idConvertTo) {
case IdType.MastodonId:
idArray = [...idIn].map(item => item.charCodeAt(0));
idArray = idArray.map(item => {
if (item.toString().length < 3) {
return `0${item.toString()}`
}
else return item.toString()
});
return idArray.join('');
case IdType.CalckeyId:
for (let i = 0; i < idIn.length; i += 3) {
if ((idIn.length % 3) !== 0) {
idIn = `0${idIn}`
}
idArray.push(idIn.slice(i, i+3));
}
idArray = idArray.map(item => String.fromCharCode(item));
return idArray.join('');
}
};
// Init app // Init app
const app = new Koa(); const app = new Koa();
@ -34,16 +66,11 @@ app.use(async (ctx, next) => {
await next(); await next();
}); });
app.use( // Init router
bodyParser({ const router = new Router();
// リクエストが multipart/form-data でない限りはJSONだと見なす const mastoRouter = new Router();
detectJSON: (ctx) => const mastoFileRouter = new Router();
!( const errorRouter = new Router();
ctx.is("multipart/form-data") ||
ctx.is("application/x-www-form-urlencoded")
),
}),
);
// Init multer instance // Init multer instance
const upload = multer({ const upload = multer({
@ -54,8 +81,76 @@ const upload = multer({
}, },
}); });
// Init router router.use(
const router = new Router(); bodyParser({
// リクエストが multipart/form-data でない限りはJSONだと見なす
detectJSON: (ctx) =>
!(
ctx.is("multipart/form-data") ||
ctx.is("application/x-www-form-urlencoded")
),
}),
);
mastoRouter.use(
koaBody({
multipart: true,
urlencoded: true,
}),
);
mastoFileRouter.post("/v1/media", upload.single("file"), async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
let multipartData = await ctx.file;
if (!multipartData) {
ctx.body = { error: "No image" };
ctx.status = 401;
return;
}
const data = await client.uploadMedia(multipartData);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
mastoFileRouter.post("/v2/media", upload.single("file"), async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
let multipartData = await ctx.file;
if (!multipartData) {
ctx.body = { error: "No image" };
ctx.status = 401;
return;
}
const data = await client.uploadMedia(multipartData);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
mastoRouter.use(async (ctx, next) => {
if (ctx.request.query) {
if (!ctx.request.body || Object.keys(ctx.request.body).length === 0) {
ctx.request.body = ctx.request.query;
} else {
ctx.request.body = { ...ctx.request.body, ...ctx.request.query };
}
}
await next();
});
apiMastodonCompatible(mastoRouter);
/** /**
* Register endpoint handlers * Register endpoint handlers
@ -141,11 +236,15 @@ router.post("/miauth/:session/check", async (ctx) => {
}); });
// Return 404 for unknown API // Return 404 for unknown API
router.all("(.*)", async (ctx) => { errorRouter.all("(.*)", async (ctx) => {
ctx.status = 404; ctx.status = 404;
}); });
// Register router // Register router
app.use(mastoFileRouter.routes());
app.use(mastoRouter.routes());
app.use(mastoRouter.allowedMethods());
app.use(router.routes()); app.use(router.routes());
app.use(errorRouter.routes());
export default app; export default app;

View file

@ -0,0 +1,64 @@
import Router from "@koa/router";
import megalodon, { MegalodonInterface } from "@calckey/megalodon";
import { apiAuthMastodon } from "./endpoints/auth.js";
import { apiAccountMastodon } from "./endpoints/account.js";
import { apiStatusMastodon } from "./endpoints/status.js";
import { apiFilterMastodon } from "./endpoints/filter.js";
import { apiTimelineMastodon } from "./endpoints/timeline.js";
import { apiNotificationsMastodon } from "./endpoints/notifications.js";
import { apiSearchMastodon } from "./endpoints/search.js";
import { getInstance } from "./endpoints/meta.js";
export function getClient(
BASE_URL: string,
authorization: string | undefined,
): MegalodonInterface {
const accessTokenArr = authorization?.split(" ") ?? [null];
const accessToken = accessTokenArr[accessTokenArr.length - 1];
const generator = (megalodon as any).default;
const client = generator(
"misskey",
BASE_URL,
accessToken,
) as MegalodonInterface;
return client;
}
export function apiMastodonCompatible(router: Router): void {
apiAuthMastodon(router);
apiAccountMastodon(router);
apiStatusMastodon(router);
apiFilterMastodon(router);
apiTimelineMastodon(router);
apiNotificationsMastodon(router);
apiSearchMastodon(router);
router.get("/v1/custom_emojis", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getInstanceCustomEmojis();
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/instance", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
// displayed without being logged in
try {
const data = await client.getInstance();
ctx.body = await getInstance(data.data);
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
}

View file

@ -0,0 +1,499 @@
import { Users } from "@/models/index.js";
import { resolveUser } from "@/remote/resolve-user.js";
import Router from "@koa/router";
import { FindOptionsWhere, IsNull } from "typeorm";
import { getClient } from "../ApiMastodonCompatibleService.js";
import { argsToBools, limitToInt } from "./timeline.js";
import { convertId, IdType } from "../../index.js";
const relationshipModel = {
id: "",
following: false,
followed_by: false,
delivery_following: false,
blocking: false,
blocked_by: false,
muting: false,
muting_notifications: false,
requested: false,
domain_blocking: false,
showing_reblogs: false,
endorsed: false,
notifying: false,
note: "",
};
export function apiAccountMastodon(router: Router): void {
router.get("/v1/accounts/verify_credentials", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.verifyAccountCredentials();
let acct = data.data;
acct.id = convertId(acct.id, IdType.MastodonId);
acct.url = `${BASE_URL}/@${acct.url}`;
acct.note = "";
acct.avatar_static = acct.avatar;
acct.header = acct.header || "";
acct.header_static = acct.header || "";
acct.source = {
note: acct.note,
fields: acct.fields,
privacy: "public",
sensitive: false,
language: "",
};
console.log(acct);
ctx.body = acct;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.patch("/v1/accounts/update_credentials", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.updateCredentials(
(ctx.request as any).body as any,
);
let resp = data.data;
resp.id = convertId(resp.id, IdType.MastodonId);
ctx.body = resp;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/accounts/lookup", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.search((ctx.request.query as any).acct, 'accounts');
let resp = data.data.accounts[0];
resp.id = convertId(resp.id, IdType.MastodonId);
ctx.body = resp;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get<{ Params: { id: string } }>(
"/v1/accounts/:id(^.*\\d.*$)",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const calcId = convertId(ctx.params.id, IdType.CalckeyId);
const data = await client.getAccount(calcId);
let resp = data.data;
resp.id = convertId(resp.id, IdType.MastodonId);
ctx.body = resp;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get<{ Params: { id: string } }>(
"/v1/accounts/:id/statuses",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getAccountStatuses(
convertId(ctx.params.id, IdType.CalckeyId),
argsToBools(limitToInt(ctx.query as any)),
);
let resp = data.data;
for (let statIdx = 0; statIdx < resp.length; statIdx++) {
resp[statIdx].id = convertId(resp[statIdx].id, IdType.MastodonId);
resp[statIdx].in_reply_to_account_id = resp[statIdx].in_reply_to_account_id ? convertId(resp[statIdx].in_reply_to_account_id, IdType.MastodonId) : null;
resp[statIdx].in_reply_to_id = resp[statIdx].in_reply_to_id ? convertId(resp[statIdx].in_reply_to_id, IdType.MastodonId) : null;
let mentions = resp[statIdx].mentions
for (let mtnIdx = 0; mtnIdx < mentions.length; mtnIdx++) {
resp[statIdx].mentions[mtnIdx].id = convertId(mentions[mtnIdx].id, IdType.MastodonId);
}
}
ctx.body = resp;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get<{ Params: { id: string } }>(
"/v1/accounts/:id/followers",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getAccountFollowers(
convertId(ctx.params.id, IdType.CalckeyId),
limitToInt(ctx.query as any),
);
let resp = data.data;
for (let acctIdx = 0; acctIdx < resp.length; acctIdx++) {
resp[acctIdx].id = convertId(resp[acctIdx].id, IdType.MastodonId);
}
ctx.body = resp;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get<{ Params: { id: string } }>(
"/v1/accounts/:id/following",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getAccountFollowing(
convertId(ctx.params.id, IdType.CalckeyId),
limitToInt(ctx.query as any),
);
let resp = data.data;
for (let acctIdx = 0; acctIdx < resp.length; acctIdx++) {
resp[acctIdx].id = convertId(resp[acctIdx].id, IdType.MastodonId);
}
ctx.body = resp;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get<{ Params: { id: string } }>(
"/v1/accounts/:id/lists",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getAccountLists(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/follow",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.followAccount(convertId(ctx.params.id, IdType.CalckeyId));
let acct = data.data;
acct.following = true;
acct.id = convertId(acct.id, IdType.MastodonId);
ctx.body = acct;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/unfollow",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unfollowAccount(convertId(ctx.params.id, IdType.CalckeyId));
let acct = data.data;
acct.id = convertId(acct.id, IdType.MastodonId);
acct.following = false;
ctx.body = acct;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/block",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.blockAccount(convertId(ctx.params.id, IdType.CalckeyId));
let resp = data.data;
resp.id = convertId(resp.id, IdType.MastodonId);
ctx.body = resp;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/unblock",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unblockAccount(convertId(ctx.params.id, IdType.MastodonId));
let resp = data.data;
resp.id = convertId(resp.id, IdType.MastodonId);
ctx.body = resp;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/mute",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.muteAccount(
convertId(ctx.params.id, IdType.CalckeyId),
(ctx.request as any).body as any,
);
let resp = data.data;
resp.id = convertId(resp.id, IdType.MastodonId);
ctx.body = resp;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/unmute",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unmuteAccount(convertId(ctx.params.id, IdType.CalckeyId));
let resp = data.data;
resp.id = convertId(resp.id, IdType.MastodonId);
ctx.body = resp;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get("/v1/accounts/relationships", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
let users;
try {
// TODO: this should be body
let ids = ctx.request.query ? ctx.request.query["id[]"] : null;
if (typeof ids === "string") {
ids = [ids];
}
users = ids;
relationshipModel.id = ids?.toString() || "1";
if (!ids) {
ctx.body = [relationshipModel];
return;
}
const data = await client.getRelationships(ids);
let resp = data.data;
for (let acctIdx = 0; acctIdx < resp.length; acctIdx++) {
resp[acctIdx].id = convertId(resp[acctIdx].id, IdType.MastodonId);
}
ctx.body = resp;
} catch (e: any) {
console.error(e);
let data = e.response.data;
data.users = users;
console.error(data);
ctx.status = 401;
ctx.body = data;
}
});
router.get("/v1/bookmarks", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = (await client.getBookmarks(ctx.query as any)) as any;
let resp = data.data;
for (let statIdx = 0; statIdx < resp.length; statIdx++) {
resp[statIdx].id = convertId(resp[statIdx].id, IdType.MastodonId);
resp[statIdx].in_reply_to_account_id = resp[statIdx].in_reply_to_account_id ? convertId(resp[statIdx].in_reply_to_account_id, IdType.MastodonId) : null;
resp[statIdx].in_reply_to_id = resp[statIdx].in_reply_to_id ? convertId(resp[statIdx].in_reply_to_id, IdType.MastodonId) : null;
let mentions = resp[statIdx].mentions
for (let mtnIdx = 0; mtnIdx < mentions.length; mtnIdx++) {
resp[statIdx].mentions[mtnIdx].id = convertId(mentions[mtnIdx].id, IdType.MastodonId);
}
}
ctx.body = resp;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/favourites", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getFavourites(ctx.query as any);
let resp = data.data;
for (let statIdx = 0; statIdx < resp.length; statIdx++) {
resp[statIdx].id = convertId(resp[statIdx].id, IdType.MastodonId);
resp[statIdx].in_reply_to_account_id = resp[statIdx].in_reply_to_account_id ? convertId(resp[statIdx].in_reply_to_account_id, IdType.MastodonId) : null;
resp[statIdx].in_reply_to_id = resp[statIdx].in_reply_to_id ? convertId(resp[statIdx].in_reply_to_id, IdType.MastodonId) : null;
let mentions = resp[statIdx].mentions
for (let mtnIdx = 0; mtnIdx < mentions.length; mtnIdx++) {
resp[statIdx].mentions[mtnIdx].id = convertId(mentions[mtnIdx].id, IdType.MastodonId);
}
}
ctx.body = resp;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/mutes", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getMutes(ctx.query as any);
let resp = data.data;
for (let acctIdx = 0; acctIdx < resp.length; acctIdx++) {
resp[acctIdx].id = convertId(resp[acctIdx].id, IdType.MastodonId);
}
ctx.body = resp;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/blocks", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getBlocks(ctx.query as any);
let resp = data.data;
for (let acctIdx = 0; acctIdx < resp.length; acctIdx++) {
resp[acctIdx].id = convertId(resp[acctIdx].id, IdType.MastodonId);
}
ctx.body = resp;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/follow_requests", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getFollowRequests(
((ctx.query as any) || { limit: 20 }).limit,
);
let resp = data.data;
for (let acctIdx = 0; acctIdx < resp.length; acctIdx++) {
resp[acctIdx].id = convertId(resp[acctIdx].id, IdType.MastodonId);
}
ctx.body = resp;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.post<{ Params: { id: string } }>(
"/v1/follow_requests/:id/authorize",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.acceptFollowRequest(convertId(ctx.params.id, IdType.CalckeyId));
let resp = data.data;
resp.id = convertId(resp.id, IdType.MastodonId);
ctx.body = resp;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/follow_requests/:id/reject",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.rejectFollowRequest(convertId(ctx.params.id, IdType.CalckeyId));
let resp = data.data;
resp.id = convertId(resp.id, IdType.MastodonId);
ctx.body = resp;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
}

View file

@ -0,0 +1,81 @@
import megalodon, { MegalodonInterface } from "@calckey/megalodon";
import Router from "@koa/router";
import { koaBody } from "koa-body";
import { getClient } from "../ApiMastodonCompatibleService.js";
import bodyParser from "koa-bodyparser";
const readScope = [
"read:account",
"read:drive",
"read:blocks",
"read:favorites",
"read:following",
"read:messaging",
"read:mutes",
"read:notifications",
"read:reactions",
"read:pages",
"read:page-likes",
"read:user-groups",
"read:channels",
"read:gallery",
"read:gallery-likes",
];
const writeScope = [
"write:account",
"write:drive",
"write:blocks",
"write:favorites",
"write:following",
"write:messaging",
"write:mutes",
"write:notes",
"write:notifications",
"write:reactions",
"write:votes",
"write:pages",
"write:page-likes",
"write:user-groups",
"write:channels",
"write:gallery",
"write:gallery-likes",
];
export function apiAuthMastodon(router: Router): void {
router.post("/v1/apps", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const client = getClient(BASE_URL, '');
const body: any = ctx.request.body || ctx.request.query;
try {
let scope = body.scopes;
if (typeof scope === "string") scope = scope.split(" ");
const pushScope = new Set<string>();
for (const s of scope) {
if (s.match(/^read/)) for (const r of readScope) pushScope.add(r);
if (s.match(/^write/)) for (const r of writeScope) pushScope.add(r);
}
const scopeArr = Array.from(pushScope);
const red = body.redirect_uris;
const appData = await client.registerApp(body.client_name, {
scopes: scopeArr,
redirect_uris: red,
website: body.website,
});
const returns = {
id: Math.floor(Math.random() * 100).toString(),
name: appData.name,
website: body.website,
redirect_uri: red,
client_id: Buffer.from(appData.url || "").toString("base64"),
client_secret: appData.clientSecret
};
console.log(returns)
ctx.body = returns;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
}

View file

@ -0,0 +1,84 @@
import megalodon, { MegalodonInterface } from "@calckey/megalodon";
import Router from "@koa/router";
import { getClient } from "../ApiMastodonCompatibleService.js";
export function apiFilterMastodon(router: Router): void {
router.get("/v1/filters", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.getFilters();
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/filters/:id", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.getFilter(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.post("/v1/filters", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.createFilter(body.phrase, body.context, body);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.post("/v1/filters/:id", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.updateFilter(
ctx.params.id,
body.phrase,
body.context,
);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.delete("/v1/filters/:id", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.deleteFilter(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
}

View file

@ -0,0 +1,108 @@
import { Entity } from "@calckey/megalodon";
import { fetchMeta } from "@/misc/fetch-meta.js";
import { Users, Notes } from "@/models/index.js";
import { IsNull, MoreThan } from "typeorm";
// TODO: add calckey features
export async function getInstance(response: Entity.Instance) {
const meta = await fetchMeta(true);
const totalUsers = Users.count({ where: { host: IsNull() } });
const totalStatuses = Notes.count({ where: { userHost: IsNull() } });
return {
uri: response.uri,
title: response.title || "Calckey",
short_description: response.description.substring(0, 50) || "See real server website",
description: response.description || "This is a vanilla Calckey Instance. It doesnt seem to have a description. BTW you are using the Mastodon api to access this server :)",
email: response.email || "",
version: "3.0.0 compatible (3.5+ Calckey)", //I hope this version string is correct, we will need to test it.
urls: response.urls,
stats: {
user_count: (await totalUsers),
status_count: (await totalStatuses),
domain_count: response.stats.domain_count
},
thumbnail: response.thumbnail || 'https://http.cat/404',
languages: meta.langs,
registrations: !meta.disableRegistration || response.registrations,
approval_required: !response.registrations,
invites_enabled: response.registrations,
configuration: {
accounts: {
max_featured_tags: 20,
},
statuses: {
max_characters: 3000,
max_media_attachments: 4,
characters_reserved_per_url: response.uri.length,
},
media_attachments: {
supported_mime_types: [
"image/jpeg",
"image/png",
"image/gif",
"image/heic",
"image/heif",
"image/webp",
"image/avif",
"video/webm",
"video/mp4",
"video/quicktime",
"video/ogg",
"audio/wave",
"audio/wav",
"audio/x-wav",
"audio/x-pn-wave",
"audio/vnd.wave",
"audio/ogg",
"audio/vorbis",
"audio/mpeg",
"audio/mp3",
"audio/webm",
"audio/flac",
"audio/aac",
"audio/m4a",
"audio/x-m4a",
"audio/mp4",
"audio/3gpp",
"video/x-ms-asf",
],
image_size_limit: 10485760,
image_matrix_limit: 16777216,
video_size_limit: 41943040,
video_frame_rate_limit: 60,
video_matrix_limit: 2304000,
},
polls: {
max_options: 8,
max_characters_per_option: 50,
min_expiration: 300,
max_expiration: 2629746,
},
},
contact_account: {
id: "1",
username: "admin",
acct: "admin",
display_name: "admin",
locked: true,
bot: true,
discoverable: false,
group: false,
created_at: new Date().toISOString(),
note: "<p>Please refer to the original instance for the actual admin contact.</p>",
url: `${response.uri}/`,
avatar: `${response.uri}/static-assets/badges/info.png`,
avatar_static: `${response.uri}/static-assets/badges/info.png`,
header: "https://http.cat/404",
header_static: "https://http.cat/404",
followers_count: -1,
following_count: 0,
statuses_count: 0,
last_status_at: new Date().toISOString(),
noindex: true,
emojis: [],
fields: [],
},
rules: [],
};
}

View file

@ -0,0 +1,90 @@
import megalodon, { MegalodonInterface } from "@calckey/megalodon";
import Router from "@koa/router";
import { koaBody } from "koa-body";
import { getClient } from "../ApiMastodonCompatibleService.js";
import { toTextWithReaction } from "./timeline.js";
function toLimitToInt(q: any) {
if (q.limit) if (typeof q.limit === "string") q.limit = parseInt(q.limit, 10);
return q;
}
export function apiNotificationsMastodon(router: Router): void {
router.get("/v1/notifications", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.getNotifications(toLimitToInt(ctx.query));
const notfs = data.data;
const ret = notfs.map((n) => {
if (n.type !== "follow" && n.type !== "follow_request") {
if (n.type === "reaction") n.type = "favourite";
n.status = toTextWithReaction(
n.status ? [n.status] : [],
ctx.hostname,
)[0];
return n;
} else {
return n;
}
});
ctx.body = ret;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/notification/:id", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const dataRaw = await client.getNotification(ctx.params.id);
const data = dataRaw.data;
if (data.type !== "follow" && data.type !== "follow_request") {
if (data.type === "reaction") data.type = "favourite";
ctx.body = toTextWithReaction([data as any], ctx.request.hostname)[0];
} else {
ctx.body = data;
}
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.post("/v1/notifications/clear", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.dismissNotifications();
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.post("/v1/notification/:id/dismiss", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.dismissNotification(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
}

View file

@ -0,0 +1,131 @@
import megalodon, { MegalodonInterface } from "@calckey/megalodon";
import Router from "@koa/router";
import { getClient } from "../ApiMastodonCompatibleService.js";
import axios from "axios";
import { Converter } from "@calckey/megalodon";
import { limitToInt } from "./timeline.js";
export function apiSearchMastodon(router: Router): void {
router.get("/v1/search", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const query: any = limitToInt(ctx.query);
const type = query.type || "";
const data = await client.search(query.q, type, query);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v2/search", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const query: any = limitToInt(ctx.query);
const type = query.type;
if (type) {
const data = await client.search(query.q, type, query);
ctx.body = data.data;
} else {
const acct = await client.search(query.q, "accounts", query);
const stat = await client.search(query.q, "statuses", query);
const tags = await client.search(query.q, "hashtags", query);
ctx.body = {
accounts: acct.data.accounts,
statuses: stat.data.statuses,
hashtags: tags.data.hashtags,
};
}
} catch (e: any) {
console.error(e);
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.get("/v1/trends/statuses", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.headers.authorization;
try {
const data = await getHighlight(BASE_URL, ctx.request.hostname, accessTokens);
ctx.body = data;
} catch (e: any) {
console.error(e);
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.get("/v2/suggestions", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.headers.authorization;
try {
const query: any = ctx.query;
const data = await getFeaturedUser(
BASE_URL,
ctx.request.hostname,
accessTokens,
query.limit || 20,
);
console.log(data);
ctx.body = data;
} catch (e: any) {
console.error(e);
ctx.status = (401);
ctx.body = e.response.data;
}
});
}
async function getHighlight(
BASE_URL: string,
domain: string,
accessTokens: string | undefined,
) {
const accessTokenArr = accessTokens?.split(" ") ?? [null];
const accessToken = accessTokenArr[accessTokenArr.length - 1];
try {
const api = await axios.post(`${BASE_URL}/api/notes/featured`, {
i: accessToken,
});
const data: MisskeyEntity.Note[] = api.data;
return data.map((note) => Converter.note(note, domain));
} catch (e: any) {
console.log(e);
console.log(e.response.data);
return [];
}
}
async function getFeaturedUser(
BASE_URL: string,
host: string,
accessTokens: string | undefined,
limit: number,
) {
const accessTokenArr = accessTokens?.split(" ") ?? [null];
const accessToken = accessTokenArr[accessTokenArr.length - 1];
try {
const api = await axios.post(`${BASE_URL}/api/users`, {
i: accessToken,
limit,
origin: "local",
sort: "+follower",
state: "alive",
});
const data: MisskeyEntity.UserDetail[] = api.data;
console.log(data);
return data.map((u) => {
return {
source: "past_interactions",
account: Converter.userDetail(u, host),
};
});
} catch (e: any) {
console.log(e);
console.log(e.response.data);
return [];
}
}

View file

@ -0,0 +1,446 @@
import Router from "@koa/router";
import { getClient } from "../ApiMastodonCompatibleService.js";
import { emojiRegexAtStartToEnd } from "@/misc/emoji-regex.js";
import axios from "axios";
import querystring from 'node:querystring'
import qs from 'qs'
function normalizeQuery(data: any) {
const str = querystring.stringify(data);
return qs.parse(str);
}
export function apiStatusMastodon(router: Router): void {
router.post("/v1/statuses", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
let body: any = ctx.request.body;
if ((!body.poll && body['poll[options][]']) || (!body.media_ids && body['media_ids[]'])) {
body = normalizeQuery(body)
}
const text = body.status;
const removed = text.replace(/@\S+/g, "").replace(/\s|/g, '')
const isDefaultEmoji = emojiRegexAtStartToEnd.test(removed);
const isCustomEmoji = /^:[a-zA-Z0-9@_]+:$/.test(removed);
if ((body.in_reply_to_id && isDefaultEmoji) || isCustomEmoji) {
const a = await client.createEmojiReaction(
body.in_reply_to_id,
removed,
);
ctx.body = a.data;
}
if (body.in_reply_to_id && removed === "/unreact") {
try {
const id = body.in_reply_to_id;
const post = await client.getStatus(id);
const react = post.data.emoji_reactions.filter((e) => e.me)[0].name;
const data = await client.deleteEmojiReaction(id, react);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
}
if (!body.media_ids) body.media_ids = undefined;
if (body.media_ids && !body.media_ids.length) body.media_ids = undefined;
const { sensitive } = body
body.sensitive = typeof sensitive === 'string' ? sensitive === 'true' : sensitive
const data = await client.postStatus(text, body);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get<{ Params: { id: string } }>(
"/v1/statuses/:id",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getStatus(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.delete<{ Params: { id: string } }>(
"/v1/statuses/:id",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.deleteStatus(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e.response.data, request.params.id);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
interface IReaction {
id: string;
createdAt: string;
user: MisskeyEntity.User;
type: string;
}
router.get<{ Params: { id: string } }>(
"/v1/statuses/:id/context",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const id = ctx.params.id;
const data = await client.getStatusContext(id, ctx.query as any);
const status = await client.getStatus(id);
const reactionsAxios = await axios.get(
`${BASE_URL}/api/notes/reactions?noteId=${id}`,
);
const reactions: IReaction[] = reactionsAxios.data;
const text = reactions
.map((r) => `${r.type.replace("@.", "")} ${r.user.username}`)
.join("<br />");
data.data.descendants.unshift(
statusModel(
status.data.id,
status.data.account.id,
status.data.emojis,
text,
),
);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get<{ Params: { id: string } }>(
"/v1/statuses/:id/reblogged_by",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getStatusRebloggedBy(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get<{ Params: { id: string } }>(
"/v1/statuses/:id/favourited_by",
async (ctx) => {
ctx.body = [];
},
);
router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/favourite",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const react = await getFirstReaction(BASE_URL, accessTokens);
try {
const a = (await client.createEmojiReaction(
ctx.params.id,
react,
)) as any;
//const data = await client.favouriteStatus(ctx.params.id) as any;
ctx.body = a.data;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/unfavourite",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const react = await getFirstReaction(BASE_URL, accessTokens);
try {
const data = await client.deleteEmojiReaction(ctx.params.id, react);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/reblog",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.reblogStatus(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/unreblog",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unreblogStatus(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/bookmark",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.bookmarkStatus(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/unbookmark",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = (await client.unbookmarkStatus(ctx.params.id)) as any;
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/pin",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.pinStatus(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/unpin",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unpinStatus(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get<{ Params: { id: string } }>(
"/v1/media/:id",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getMedia(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.put<{ Params: { id: string } }>(
"/v1/media/:id",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.updateMedia(
ctx.params.id,
ctx.request.body as any,
);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get<{ Params: { id: string } }>(
"/v1/polls/:id",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getPoll(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/polls/:id/votes",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.votePoll(
ctx.params.id,
(ctx.request.body as any).choices,
);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
}
async function getFirstReaction(
BASE_URL: string,
accessTokens: string | undefined,
) {
const accessTokenArr = accessTokens?.split(" ") ?? [null];
const accessToken = accessTokenArr[accessTokenArr.length - 1];
let react = "⭐";
try {
const api = await axios.post(`${BASE_URL}/api/i/registry/get-unsecure`, {
scope: ["client", "base"],
key: "reactions",
i: accessToken,
});
const reactRaw = api.data;
react = Array.isArray(reactRaw) ? api.data[0] : "⭐";
console.log(api.data);
return react;
} catch (e) {
return react;
}
}
export function statusModel(
id: string | null,
acctId: string | null,
emojis: MastodonEntity.Emoji[],
content: string,
) {
const now = Math.floor(new Date().getTime() / 1000);
return {
id: "9atm5frjhb",
uri: "https://http.cat/404", // ""
url: "https://http.cat/404", // "",
account: {
id: "9arzuvv0sw",
username: "Reactions",
acct: "Reactions",
display_name: "Reactions to this post",
locked: false,
created_at: now,
followers_count: 0,
following_count: 0,
statuses_count: 0,
note: "",
url: "https://http.cat/404",
avatar: "/static-assets/badges/info.png",
avatar_static: "/static-assets/badges/info.png",
header: "https://http.cat/404", // ""
header_static: "https://http.cat/404", // ""
emojis: [],
fields: [],
moved: null,
bot: false,
},
in_reply_to_id: id,
in_reply_to_account_id: acctId,
reblog: null,
content: `<p>${content}</p>`,
plain_content: null,
created_at: now,
emojis: emojis,
replies_count: 0,
reblogs_count: 0,
favourites_count: 0,
favourited: false,
reblogged: false,
muted: false,
sensitive: false,
spoiler_text: "",
visibility: "public" as const,
media_attachments: [],
mentions: [],
tags: [],
card: null,
poll: null,
application: null,
language: null,
pinned: false,
emoji_reactions: [],
bookmarked: false,
quote: null,
};
}

View file

@ -0,0 +1,334 @@
import Router from "@koa/router";
import megalodon, { Entity, MegalodonInterface } from "@calckey/megalodon";
import { getClient } from "../ApiMastodonCompatibleService.js";
import { statusModel } from "./status.js";
import Autolinker from "autolinker";
import { ParsedUrlQuery } from "querystring";
export function limitToInt(q: ParsedUrlQuery) {
let object: any = q;
if (q.limit)
if (typeof q.limit === "string") object.limit = parseInt(q.limit, 10);
if (q.offset)
if (typeof q.offset === "string") object.offset = parseInt(q.offset, 10);
return object;
}
export function argsToBools(q: ParsedUrlQuery) {
let object: any = q;
if (q.only_media)
if (typeof q.only_media === "string")
object.only_media = q.only_media.toLowerCase() === "true";
if (q.exclude_replies)
if (typeof q.exclude_replies === "string")
object.exclude_replies = q.exclude_replies.toLowerCase() === "true";
return q;
}
export function toTextWithReaction(status: Entity.Status[], host: string) {
return status.map((t) => {
if (!t) return statusModel(null, null, [], "no content");
t.quote = null as any;
if (!t.emoji_reactions) return t;
if (t.reblog) t.reblog = toTextWithReaction([t.reblog], host)[0];
const reactions = t.emoji_reactions.map((r) => {
const emojiNotation = r.url ? `:${r.name.replace('@.', '')}:` : r.name
return `${emojiNotation} (${r.count}${r.me ? `* ` : ''})`
});
const reaction = t.emoji_reactions as Entity.Reaction[];
const emoji = t.emojis || []
for (const r of reaction) {
if (!r.url) continue
emoji.push({
'shortcode': r.name,
'url': r.url,
'static_url': r.url,
'visible_in_picker': true,
},)
}
const isMe = reaction.findIndex((r) => r.me) > -1;
const total = reaction.reduce((sum, reaction) => sum + reaction.count, 0);
t.favourited = isMe;
t.favourites_count = total;
t.emojis = emoji
t.content = `<p>${autoLinker(t.content, host)}</p><p>${reactions.join(
", ",
)}</p>`;
return t;
});
}
export function autoLinker(input: string, host: string) {
return Autolinker.link(input, {
hashtag: "twitter",
mention: "twitter",
email: false,
stripPrefix: false,
replaceFn: function (match) {
switch (match.type) {
case "url":
return true;
case "mention":
console.log("Mention: ", match.getMention());
console.log("Mention Service Name: ", match.getServiceName());
return `<a href="https://${host}/@${encodeURIComponent(
match.getMention(),
)}" target="_blank">@${match.getMention()}</a>`;
case "hashtag":
console.log("Hashtag: ", match.getHashtag());
return `<a href="https://${host}/tags/${encodeURIComponent(
match.getHashtag(),
)}" target="_blank">#${match.getHashtag()}</a>`;
}
return false;
},
});
}
export function apiTimelineMastodon(router: Router): void {
router.get("/v1/timelines/public", async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const query: any = ctx.query;
const data = query.local
? await client.getLocalTimeline(limitToInt(query))
: await client.getPublicTimeline(limitToInt(query));
ctx.body = toTextWithReaction(data.data, ctx.hostname);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get<{ Params: { hashtag: string } }>(
"/v1/timelines/tag/:hashtag",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getTagTimeline(
ctx.params.hashtag,
limitToInt(ctx.query),
);
ctx.body = toTextWithReaction(data.data, ctx.hostname);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get(
"/v1/timelines/home",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getHomeTimeline(limitToInt(ctx.query));
ctx.body = toTextWithReaction(data.data, ctx.hostname);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get<{ Params: { listId: string } }>(
"/v1/timelines/list/:listId",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getListTimeline(
ctx.params.listId,
limitToInt(ctx.query),
);
ctx.body = toTextWithReaction(data.data, ctx.hostname);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get("/v1/conversations", async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getConversationTimeline(limitToInt(ctx.query));
ctx.body = data.data;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/lists", async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getLists();
ctx.body = data.data;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get<{ Params: { id: string } }>(
"/v1/lists/:id",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getList(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post("/v1/lists", async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.createList((ctx.query as any).title);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.put<{ Params: { id: string } }>(
"/v1/lists/:id",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.updateList(ctx.params.id, ctx.query as any);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.delete<{ Params: { id: string } }>(
"/v1/lists/:id",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.deleteList(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get<{ Params: { id: string } }>(
"/v1/lists/:id/accounts",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getAccountsInList(
ctx.params.id,
ctx.query as any,
);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/lists/:id/accounts",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.addAccountsToList(
ctx.params.id,
(ctx.query as any).account_ids,
);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.delete<{ Params: { id: string } }>(
"/v1/lists/:id/accounts",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.deleteAccountsFromList(
ctx.params.id,
(ctx.query as any).account_ids,
);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
}
function escapeHTML(str: string) {
if (!str) {
return "";
}
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function nl2br(str: string) {
if (!str) {
return "";
}
str = str.replace(/\r\n/g, "<br />");
str = str.replace(/(\n|\r)/g, "<br />");
return str;
}

View file

@ -24,6 +24,9 @@ import { readNotification } from "../common/read-notification.js";
import channels from "./channels/index.js"; import channels from "./channels/index.js";
import type Channel from "./channel.js"; import type Channel from "./channel.js";
import type { StreamEventEmitter, StreamMessages } from "./types.js"; import type { StreamEventEmitter, StreamMessages } from "./types.js";
import { Converter } from "@calckey/megalodon";
import { getClient } from "../mastodon/ApiMastodonCompatibleService.js";
import { toTextWithReaction } from "../mastodon/endpoints/timeline.js";
/** /**
* Main stream connection * Main stream connection
@ -41,17 +44,27 @@ export default class Connection {
private channels: Channel[] = []; private channels: Channel[] = [];
private subscribingNotes: any = {}; private subscribingNotes: any = {};
private cachedNotes: Packed<"Note">[] = []; private cachedNotes: Packed<"Note">[] = [];
private isMastodonCompatible: boolean = false;
private host: string;
private accessToken: string;
private currentSubscribe: string[][] = [];
constructor( constructor(
wsConnection: websocket.connection, wsConnection: websocket.connection,
subscriber: EventEmitter, subscriber: EventEmitter,
user: User | null | undefined, user: User | null | undefined,
token: AccessToken | null | undefined, token: AccessToken | null | undefined,
host: string,
accessToken: string,
prepareStream: string | undefined,
) { ) {
console.log("constructor", prepareStream);
this.wsConnection = wsConnection; this.wsConnection = wsConnection;
this.subscriber = subscriber; this.subscriber = subscriber;
if (user) this.user = user; if (user) this.user = user;
if (token) this.token = token; if (token) this.token = token;
if (host) this.host = host;
if (accessToken) this.accessToken = accessToken;
this.onWsConnectionMessage = this.onWsConnectionMessage.bind(this); this.onWsConnectionMessage = this.onWsConnectionMessage.bind(this);
this.onUserEvent = this.onUserEvent.bind(this); this.onUserEvent = this.onUserEvent.bind(this);
@ -73,6 +86,13 @@ export default class Connection {
this.subscriber.on(`user:${this.user.id}`, this.onUserEvent); this.subscriber.on(`user:${this.user.id}`, this.onUserEvent);
} }
console.log("prepare", prepareStream);
if (prepareStream) {
this.onWsConnectionMessage({
type: "utf8",
utf8Data: JSON.stringify({ stream: prepareStream, type: "subscribe" }),
});
}
} }
private onUserEvent(data: StreamMessages["user"]["payload"]) { private onUserEvent(data: StreamMessages["user"]["payload"]) {
@ -125,16 +145,106 @@ export default class Connection {
if (data.type !== "utf8") return; if (data.type !== "utf8") return;
if (data.utf8Data == null) return; if (data.utf8Data == null) return;
let obj: Record<string, any>; let objs: Record<string, any>[];
try { try {
obj = JSON.parse(data.utf8Data); objs = [JSON.parse(data.utf8Data)];
} catch (e) { } catch (e) {
return; return;
} }
const { type, body } = obj; const simpleObj = objs[0];
if (simpleObj.stream) {
// is Mastodon Compatible
this.isMastodonCompatible = true;
if (simpleObj.type === "subscribe") {
let forSubscribe = [];
if (simpleObj.stream === "user") {
this.currentSubscribe.push(["user"]);
objs = [
{
type: "connect",
body: {
channel: "main",
id: simpleObj.stream,
},
},
{
type: "connect",
body: {
channel: "homeTimeline",
id: simpleObj.stream,
},
},
];
const client = getClient(this.host, this.accessToken);
try {
const tl = await client.getHomeTimeline();
for (const t of tl.data) forSubscribe.push(t.id);
} catch (e: any) {
console.log(e);
console.error(e.response.data);
}
} else if (simpleObj.stream === "public:local") {
this.currentSubscribe.push(["public:local"]);
objs = [
{
type: "connect",
body: {
channel: "localTimeline",
id: simpleObj.stream,
},
},
];
const client = getClient(this.host, this.accessToken);
const tl = await client.getLocalTimeline();
for (const t of tl.data) forSubscribe.push(t.id);
} else if (simpleObj.stream === "public") {
this.currentSubscribe.push(["public"]);
objs = [
{
type: "connect",
body: {
channel: "globalTimeline",
id: simpleObj.stream,
},
},
];
const client = getClient(this.host, this.accessToken);
const tl = await client.getPublicTimeline();
for (const t of tl.data) forSubscribe.push(t.id);
} else if (simpleObj.stream === "list") {
this.currentSubscribe.push(["list", simpleObj.list]);
objs = [
{
type: "connect",
body: {
channel: "list",
id: simpleObj.stream,
params: {
listId: simpleObj.list,
},
},
},
];
const client = getClient(this.host, this.accessToken);
const tl = await client.getListTimeline(simpleObj.list);
for (const t of tl.data) forSubscribe.push(t.id);
}
for (const s of forSubscribe) {
objs.push({
type: "s",
body: {
id: s,
},
});
}
}
}
for (const obj of objs) {
const { type, body } = obj;
console.log(type, body);
switch (type) { switch (type) {
case "readNotification": case "readNotification":
this.onReadNotification(body); this.onReadNotification(body);
@ -179,6 +289,7 @@ export default class Connection {
break; break;
} }
} }
}
private onBroadcastMessage(data: StreamMessages["broadcast"]["payload"]) { private onBroadcastMessage(data: StreamMessages["broadcast"]["payload"]) {
this.sendMessageToWs(data.type, data.body); this.sendMessageToWs(data.type, data.body);
@ -280,6 +391,69 @@ export default class Connection {
* *
*/ */
public sendMessageToWs(type: string, payload: any) { public sendMessageToWs(type: string, payload: any) {
console.log(payload, this.isMastodonCompatible);
if (this.isMastodonCompatible) {
if (payload.type === "note") {
this.wsConnection.send(
JSON.stringify({
stream: [payload.id],
event: "update",
payload: JSON.stringify(
toTextWithReaction(
[Converter.note(payload.body, this.host)],
this.host,
)[0],
),
}),
);
this.onSubscribeNote({
id: payload.body.id,
});
} else if (payload.type === "reacted" || payload.type === "unreacted") {
// reaction
const client = getClient(this.host, this.accessToken);
client.getStatus(payload.id).then((data) => {
const newPost = toTextWithReaction([data.data], this.host);
const targetPost = newPost[0]
for (const stream of this.currentSubscribe) {
this.wsConnection.send(
JSON.stringify({
stream,
event: "status.update",
payload: JSON.stringify(targetPost),
}),
);
}
});
} else if (payload.type === "deleted") {
// delete
for (const stream of this.currentSubscribe) {
this.wsConnection.send(
JSON.stringify({
stream,
event: "delete",
payload: payload.id,
}),
);
}
} else if (payload.type === "unreadNotification") {
if (payload.id === "user") {
const body = Converter.notification(payload.body, this.host);
if (body.type === "reaction") body.type = "favourite";
body.status = toTextWithReaction(
body.status ? [body.status] : [],
"",
)[0];
this.wsConnection.send(
JSON.stringify({
stream: ["user"],
event: "notification",
payload: JSON.stringify(body),
}),
);
}
}
} else {
this.wsConnection.send( this.wsConnection.send(
JSON.stringify({ JSON.stringify({
type: type, type: type,
@ -287,6 +461,7 @@ export default class Connection {
}), }),
); );
} }
}
/** /**
* *

View file

@ -7,7 +7,6 @@ import type { Note } from "@/models/entities/note.js";
import type { Antenna } from "@/models/entities/antenna.js"; import type { Antenna } from "@/models/entities/antenna.js";
import type { DriveFile } from "@/models/entities/drive-file.js"; import type { DriveFile } from "@/models/entities/drive-file.js";
import type { DriveFolder } from "@/models/entities/drive-folder.js"; import type { DriveFolder } from "@/models/entities/drive-folder.js";
import { Emoji } from "@/models/entities/emoji.js";
import type { UserList } from "@/models/entities/user-list.js"; import type { UserList } from "@/models/entities/user-list.js";
import type { MessagingMessage } from "@/models/entities/messaging-message.js"; import type { MessagingMessage } from "@/models/entities/messaging-message.js";
import type { UserGroup } from "@/models/entities/user-group.js"; import type { UserGroup } from "@/models/entities/user-group.js";
@ -23,7 +22,10 @@ export interface InternalStreamTypes {
id: User["id"]; id: User["id"];
isSuspended: User["isSuspended"]; isSuspended: User["isSuspended"];
}; };
userChangeSilencedState: { id: User["id"]; isSilenced: User["isSilenced"] }; userChangeSilencedState: {
id: User["id"];
isSilenced: User["isSilenced"];
};
userChangeModeratorState: { userChangeModeratorState: {
id: User["id"]; id: User["id"];
isModerator: User["isModerator"]; isModerator: User["isModerator"];
@ -33,7 +35,12 @@ export interface InternalStreamTypes {
oldToken: User["token"]; oldToken: User["token"];
newToken: User["token"]; newToken: User["token"];
}; };
remoteUserUpdated: { id: User["id"] }; localUserUpdated: {
id: User["id"];
};
remoteUserUpdated: {
id: User["id"];
};
webhookCreated: Webhook; webhookCreated: Webhook;
webhookDeleted: Webhook; webhookDeleted: Webhook;
webhookUpdated: Webhook; webhookUpdated: Webhook;
@ -135,6 +142,9 @@ export interface NoteStreamTypes {
reaction: string; reaction: string;
userId: User["id"]; userId: User["id"];
}; };
replied: {
id: Note["id"];
};
} }
type NoteStreamEventTypes = { type NoteStreamEventTypes = {
[key in keyof NoteStreamTypes]: { [key in keyof NoteStreamTypes]: {

View file

@ -16,10 +16,13 @@ export const initializeStreamingServer = (server: http.Server) => {
ws.on("request", async (request) => { ws.on("request", async (request) => {
const q = request.resourceURL.query as ParsedUrlQuery; const q = request.resourceURL.query as ParsedUrlQuery;
const headers = request.httpRequest.headers["sec-websocket-protocol"] || "";
const cred = q.i || q.access_token || headers;
const accessToken = cred.toString();
const [user, app] = await authenticate( const [user, app] = await authenticate(
request.httpRequest.headers.authorization, request.httpRequest.headers.authorization,
q.i, accessToken,
).catch((err) => { ).catch((err) => {
request.reject(403, err.message); request.reject(403, err.message);
return []; return [];
@ -43,8 +46,19 @@ export const initializeStreamingServer = (server: http.Server) => {
} }
redisClient.on("message", onRedisMessage); redisClient.on("message", onRedisMessage);
const host = `https://${request.host}`;
const prepareStream = q.stream?.toString();
console.log("start", q);
const main = new MainStreamConnection(connection, ev, user, app); const main = new MainStreamConnection(
connection,
ev,
user,
app,
host,
accessToken,
prepareStream,
);
const intervalId = user const intervalId = user
? setInterval(() => { ? setInterval(() => {

View file

@ -20,6 +20,7 @@ import { createTemp } from "@/misc/create-temp.js";
import { publishMainStream } from "@/services/stream.js"; import { publishMainStream } from "@/services/stream.js";
import * as Acct from "@/misc/acct.js"; import * as Acct from "@/misc/acct.js";
import { envOption } from "@/env.js"; import { envOption } from "@/env.js";
import megalodon, { MegalodonInterface } from "@calckey/megalodon";
import activityPub from "./activitypub.js"; import activityPub from "./activitypub.js";
import nodeinfo from "./nodeinfo.js"; import nodeinfo from "./nodeinfo.js";
import wellKnown from "./well-known.js"; import wellKnown from "./well-known.js";
@ -28,6 +29,9 @@ import fileServer from "./file/index.js";
import proxyServer from "./proxy/index.js"; import proxyServer from "./proxy/index.js";
import webServer from "./web/index.js"; import webServer from "./web/index.js";
import { initializeStreamingServer } from "./api/streaming.js"; import { initializeStreamingServer } from "./api/streaming.js";
import { koaBody } from "koa-body";
import removeTrailingSlash from "koa-remove-trailing-slashes";
import {v4 as uuid} from "uuid";
export const serverLogger = new Logger("server", "gray", false); export const serverLogger = new Logger("server", "gray", false);
@ -35,6 +39,8 @@ export const serverLogger = new Logger("server", "gray", false);
const app = new Koa(); const app = new Koa();
app.proxy = true; app.proxy = true;
app.use(removeTrailingSlash());
if (!["production", "test"].includes(process.env.NODE_ENV || "")) { if (!["production", "test"].includes(process.env.NODE_ENV || "")) {
// Logger // Logger
app.use( app.use(
@ -68,6 +74,25 @@ app.use(mount("/proxy", proxyServer));
// Init router // Init router
const router = new Router(); const router = new Router();
const mastoRouter = new Router();
mastoRouter.use(
koaBody({
urlencoded: true,
multipart: true,
}),
);
mastoRouter.use(async (ctx, next) => {
if (ctx.request.query) {
if (!ctx.request.body || Object.keys(ctx.request.body).length === 0) {
ctx.request.body = ctx.request.query;
} else {
ctx.request.body = { ...ctx.request.body, ...ctx.request.query };
}
}
await next();
});
// Routing // Routing
router.use(activityPub.routes()); router.use(activityPub.routes());
@ -133,7 +158,76 @@ router.get("/verify-email/:code", async (ctx) => {
} }
}); });
mastoRouter.get("/oauth/authorize", async (ctx) => {
const { client_id, state, redirect_uri } = ctx.request.query;
console.log(ctx.request.req);
let param = "mastodon=true";
if (state)
param += `&state=${state}`;
if (redirect_uri)
param += `&redirect_uri=${redirect_uri}`;
const client = client_id? client_id : "";
ctx.redirect(`${Buffer.from(client.toString(), 'base64').toString()}?${param}`);
});
mastoRouter.post("/oauth/token", async (ctx) => {
const body: any = ctx.request.body || ctx.request.query;
console.log('token-request', body);
console.log('token-query', ctx.request.query);
if (body.redirect_uri.startsWith('com.tapbots') && body.grant_type === 'client_credentials') {
const ret = {
access_token: uuid(),
token_type: "Bearer",
scope: "read",
created_at: Math.floor(new Date().getTime() / 1000),
};
ctx.body = ret;
return;
}
let client_id: any = body.client_id;
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const generator = (megalodon as any).default;
const client = generator("misskey", BASE_URL, null) as MegalodonInterface;
let m = null;
let token = null;
if (body.code) {
//m = body.code.match(/^([a-zA-Z0-9]{8})([a-zA-Z0-9]{4})([a-zA-Z0-9]{4})([a-zA-Z0-9]{4})([a-zA-Z0-9]{12})/);
//if (!m.length) {
// ctx.body = { error: "Invalid code" };
// return;
//}
//token = `${m[1]}-${m[2]}-${m[3]}-${m[4]}-${m[5]}`
console.log(body.code, token)
token = body.code
}
if (client_id instanceof Array) {
client_id = client_id.toString();
} else if (!client_id) {
client_id = null;
}
try {
const atData = await client.fetchAccessToken(
client_id,
body.client_secret,
token ? token : "",
);
const ret = {
access_token: atData.accessToken,
token_type: "Bearer",
scope: body.scope || 'read write follow push',
created_at: Math.floor(new Date().getTime() / 1000),
};
console.log('token-response', ret)
ctx.body = ret;
} catch (err: any) {
console.error(err);
ctx.status = 401;
ctx.body = err.response.data;
}
});
// Register router // Register router
app.use(mastoRouter.routes());
app.use(router.routes()); app.use(router.routes());
app.use(mount(webServer)); app.use(mount(webServer));

View file

@ -3,7 +3,7 @@ import config from "@/config/index.js";
import { fetchMeta } from "@/misc/fetch-meta.js"; import { fetchMeta } from "@/misc/fetch-meta.js";
import { Users, Notes } from "@/models/index.js"; import { Users, Notes } from "@/models/index.js";
import { IsNull, MoreThan } from "typeorm"; import { IsNull, MoreThan } from "typeorm";
import { MAX_NOTE_TEXT_LENGTH } from "@/const.js"; import { MAX_NOTE_TEXT_LENGTH, MAX_CAPTION_TEXT_LENGTH } from "@/const.js";
import { Cache } from "@/misc/cache.js"; import { Cache } from "@/misc/cache.js";
const router = new Router(); const router = new Router();
@ -85,6 +85,7 @@ const nodeinfo2 = async () => {
enableHcaptcha: meta.enableHcaptcha, enableHcaptcha: meta.enableHcaptcha,
enableRecaptcha: meta.enableRecaptcha, enableRecaptcha: meta.enableRecaptcha,
maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, maxNoteTextLength: MAX_NOTE_TEXT_LENGTH,
maxCaptionTextLength: MAX_CAPTION_TEXT_LENGTH,
enableTwitterIntegration: meta.enableTwitterIntegration, enableTwitterIntegration: meta.enableTwitterIntegration,
enableGithubIntegration: meta.enableGithubIntegration, enableGithubIntegration: meta.enableGithubIntegration,
enableDiscordIntegration: meta.enableDiscordIntegration, enableDiscordIntegration: meta.enableDiscordIntegration,

View file

@ -1,7 +1,7 @@
'use strict'; "use strict";
window.onload = async () => { window.onload = async () => {
const account = JSON.parse(localStorage.getItem('account')); const account = JSON.parse(localStorage.getItem("account"));
const i = account.token; const i = account.token;
const api = (endpoint, data = {}) => { const api = (endpoint, data = {}) => {
@ -10,12 +10,13 @@ window.onload = async () => {
if (i) data.i = i; if (i) data.i = i;
// Send request // Send request
fetch(endpoint.indexOf('://') > -1 ? endpoint : `/api/${endpoint}`, { fetch(endpoint.indexOf("://") > -1 ? endpoint : `/api/${endpoint}`, {
method: 'POST', method: "POST",
body: JSON.stringify(data), body: JSON.stringify(data),
credentials: 'omit', credentials: "omit",
cache: 'no-cache' cache: "no-cache",
}).then(async (res) => { })
.then(async (res) => {
const body = res.status === 204 ? null : await res.json(); const body = res.status === 204 ? null : await res.json();
if (res.status === 200) { if (res.status === 200) {
@ -25,27 +26,28 @@ window.onload = async () => {
} else { } else {
reject(body.error); reject(body.error);
} }
}).catch(reject); })
.catch(reject);
}); });
return promise; return promise;
}; };
const content = document.getElementById('content'); const content = document.getElementById("content");
document.getElementById('ls').addEventListener('click', () => { document.getElementById("ls").addEventListener("click", () => {
content.innerHTML = ''; content.innerHTML = "";
const lsEditor = document.createElement('div'); const lsEditor = document.createElement("div");
lsEditor.id = 'lsEditor'; lsEditor.id = "lsEditor";
const adder = document.createElement('div'); const adder = document.createElement("div");
adder.classList.add('adder'); adder.classList.add("adder");
const addKeyInput = document.createElement('input'); const addKeyInput = document.createElement("input");
const addValueTextarea = document.createElement('textarea'); const addValueTextarea = document.createElement("textarea");
const addButton = document.createElement('button'); const addButton = document.createElement("button");
addButton.textContent = 'Add'; addButton.textContent = "Add";
addButton.addEventListener('click', () => { addButton.addEventListener("click", () => {
localStorage.setItem(addKeyInput.value, addValueTextarea.value); localStorage.setItem(addKeyInput.value, addValueTextarea.value);
location.reload(); location.reload();
}); });
@ -57,21 +59,21 @@ window.onload = async () => {
for (let i = 0; i < localStorage.length; i++) { for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i); const k = localStorage.key(i);
const record = document.createElement('div'); const record = document.createElement("div");
record.classList.add('record'); record.classList.add("record");
const header = document.createElement('header'); const header = document.createElement("header");
header.textContent = k; header.textContent = k;
const textarea = document.createElement('textarea'); const textarea = document.createElement("textarea");
textarea.textContent = localStorage.getItem(k); textarea.textContent = localStorage.getItem(k);
const saveButton = document.createElement('button'); const saveButton = document.createElement("button");
saveButton.textContent = 'Save'; saveButton.textContent = "Save";
saveButton.addEventListener('click', () => { saveButton.addEventListener("click", () => {
localStorage.setItem(k, textarea.value); localStorage.setItem(k, textarea.value);
location.reload(); location.reload();
}); });
const removeButton = document.createElement('button'); const removeButton = document.createElement("button");
removeButton.textContent = 'Remove'; removeButton.textContent = "Remove";
removeButton.addEventListener('click', () => { removeButton.addEventListener("click", () => {
localStorage.removeItem(k); localStorage.removeItem(k);
location.reload(); location.reload();
}); });

View file

@ -9,120 +9,122 @@
* : webpackは介さないためこのファイルではrequireやimportは使えません * : webpackは介さないためこのファイルではrequireやimportは使えません
*/ */
'use strict'; "use strict";
// ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので // ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので
(async () => { (async () => {
window.onerror = (e) => { window.onerror = (e) => {
console.error(e); console.error(e);
renderError('SOMETHING_HAPPENED', e); renderError("SOMETHING_HAPPENED", e);
}; };
window.onunhandledrejection = (e) => { window.onunhandledrejection = (e) => {
console.error(e); console.error(e);
renderError('SOMETHING_HAPPENED_IN_PROMISE', e); renderError("SOMETHING_HAPPENED_IN_PROMISE", e);
}; };
//#region Detect language & fetch translations //#region Detect language & fetch translations
const v = localStorage.getItem('v') || VERSION; const v = localStorage.getItem("v") || VERSION;
const supportedLangs = LANGS; const supportedLangs = LANGS;
let lang = localStorage.getItem('lang'); let lang = localStorage.getItem("lang");
if (lang == null || !supportedLangs.includes(lang)) { if (lang == null || !supportedLangs.includes(lang)) {
if (supportedLangs.includes(navigator.language)) { if (supportedLangs.includes(navigator.language)) {
lang = navigator.language; lang = navigator.language;
} else { } else {
lang = supportedLangs.find(x => x.split('-')[0] === navigator.language); lang = supportedLangs.find((x) => x.split("-")[0] === navigator.language);
// Fallback // Fallback
if (lang == null) lang = 'en-US'; if (lang == null) lang = "en-US";
} }
} }
const res = await fetch(`/assets/locales/${lang}.${v}.json`); const res = await fetch(`/assets/locales/${lang}.${v}.json`);
if (res.status === 200) { if (res.status === 200) {
localStorage.setItem('lang', lang); localStorage.setItem("lang", lang);
localStorage.setItem('locale', await res.text()); localStorage.setItem("locale", await res.text());
localStorage.setItem('localeVersion', v); localStorage.setItem("localeVersion", v);
} else { } else {
await checkUpdate(); await checkUpdate();
renderError('LOCALE_FETCH'); renderError("LOCALE_FETCH");
return; return;
} }
//#endregion //#endregion
//#region Script //#region Script
function importAppScript() { function importAppScript() {
import(`/assets/${CLIENT_ENTRY}`) import(`/assets/${CLIENT_ENTRY}`).catch(async (e) => {
.catch(async e => {
await checkUpdate(); await checkUpdate();
console.error(e); console.error(e);
renderError('APP_IMPORT', e); renderError("APP_IMPORT", e);
}); });
} }
// タイミングによっては、この時点でDOMの構築が済んでいる場合とそうでない場合とがある // タイミングによっては、この時点でDOMの構築が済んでいる場合とそうでない場合とがある
if (document.readyState !== 'loading') { if (document.readyState !== "loading") {
importAppScript(); importAppScript();
} else { } else {
window.addEventListener('DOMContentLoaded', () => { window.addEventListener("DOMContentLoaded", () => {
importAppScript(); importAppScript();
}); });
} }
//#endregion //#endregion
//#region Theme //#region Theme
const theme = localStorage.getItem('theme'); const theme = localStorage.getItem("theme");
if (theme) { if (theme) {
for (const [k, v] of Object.entries(JSON.parse(theme))) { for (const [k, v] of Object.entries(JSON.parse(theme))) {
document.documentElement.style.setProperty(`--${k}`, v.toString()); document.documentElement.style.setProperty(`--${k}`, v.toString());
// HTMLの theme-color 適用 // HTMLの theme-color 適用
if (k === 'htmlThemeColor') { if (k === "htmlThemeColor") {
for (const tag of document.head.children) { for (const tag of document.head.children) {
if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') { if (
tag.setAttribute('content', v); tag.tagName === "META" &&
tag.getAttribute("name") === "theme-color"
) {
tag.setAttribute("content", v);
break; break;
} }
} }
} }
} }
} }
const colorSchema = localStorage.getItem('colorSchema'); const colorSchema = localStorage.getItem("colorSchema");
if (colorSchema) { if (colorSchema) {
document.documentElement.style.setProperty('color-schema', colorSchema); document.documentElement.style.setProperty("color-schema", colorSchema);
} }
//#endregion //#endregion
const fontSize = localStorage.getItem('fontSize'); const fontSize = localStorage.getItem("fontSize");
if (fontSize) { if (fontSize) {
document.documentElement.classList.add('f-' + fontSize); document.documentElement.classList.add("f-" + fontSize);
} }
const useSystemFont = localStorage.getItem('useSystemFont'); const useSystemFont = localStorage.getItem("useSystemFont");
if (useSystemFont) { if (useSystemFont) {
document.documentElement.classList.add('useSystemFont'); document.documentElement.classList.add("useSystemFont");
} }
const wallpaper = localStorage.getItem('wallpaper'); const wallpaper = localStorage.getItem("wallpaper");
if (wallpaper) { if (wallpaper) {
document.documentElement.style.backgroundImage = `url(${wallpaper})`; document.documentElement.style.backgroundImage = `url(${wallpaper})`;
} }
const customCss = localStorage.getItem('customCss'); const customCss = localStorage.getItem("customCss");
if (customCss && customCss.length > 0) { if (customCss && customCss.length > 0) {
const style = document.createElement('style'); const style = document.createElement("style");
style.innerHTML = customCss; style.innerHTML = customCss;
document.head.appendChild(style); document.head.appendChild(style);
} }
async function addStyle(styleText) { async function addStyle(styleText) {
let css = document.createElement('style'); let css = document.createElement("style");
css.appendChild(document.createTextNode(styleText)); css.appendChild(document.createTextNode(styleText));
document.head.appendChild(css); document.head.appendChild(css);
} }
function renderError(code, details) { function renderError(code, details) {
let errorsElement = document.getElementById('errors'); let errorsElement = document.getElementById("errors");
if (!errorsElement) { if (!errorsElement) {
document.body.innerHTML = ` document.body.innerHTML = `
@ -158,9 +160,9 @@
<br> <br>
<div id="errors"></div> <div id="errors"></div>
`; `;
errorsElement = document.getElementById('errors'); errorsElement = document.getElementById("errors");
} }
const detailsElement = document.createElement('details'); const detailsElement = document.createElement("details");
detailsElement.innerHTML = ` detailsElement.innerHTML = `
<br> <br>
<summary> <summary>
@ -278,25 +280,25 @@
details { details {
width: 50%; width: 50%;
} }
`) `);
} }
async function checkUpdate() { async function checkUpdate() {
try { try {
const res = await fetch('/api/meta', { const res = await fetch("/api/meta", {
method: 'POST', method: "POST",
cache: 'no-cache' cache: "no-cache",
}); });
const meta = await res.json(); const meta = await res.json();
if (meta.version != v) { if (meta.version != v) {
localStorage.setItem('v', meta.version); localStorage.setItem("v", meta.version);
refresh(); refresh();
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
renderError('UPDATE_CHECK', e); renderError("UPDATE_CHECK", e);
throw e; throw e;
} }
} }
@ -304,9 +306,9 @@
function refresh() { function refresh() {
// Clear cache (service worker) // Clear cache (service worker)
try { try {
navigator.serviceWorker.controller.postMessage('clear'); navigator.serviceWorker.controller.postMessage("clear");
navigator.serviceWorker.getRegistrations().then(registrations => { navigator.serviceWorker.getRegistrations().then((registrations) => {
registrations.forEach(registration => registration.unregister()); registrations.forEach((registration) => registration.unregister());
}); });
} catch (e) { } catch (e) {
console.error(e); console.error(e);

View file

@ -1,7 +1,7 @@
'use strict'; "use strict";
window.onload = async () => { window.onload = async () => {
const account = JSON.parse(localStorage.getItem('account')); const account = JSON.parse(localStorage.getItem("account"));
const i = account.token; const i = account.token;
const api = (endpoint, data = {}) => { const api = (endpoint, data = {}) => {
@ -10,12 +10,13 @@ window.onload = async () => {
if (i) data.i = i; if (i) data.i = i;
// Send request // Send request
fetch(endpoint.indexOf('://') > -1 ? endpoint : `/api/${endpoint}`, { fetch(endpoint.indexOf("://") > -1 ? endpoint : `/api/${endpoint}`, {
method: 'POST', method: "POST",
body: JSON.stringify(data), body: JSON.stringify(data),
credentials: 'omit', credentials: "omit",
cache: 'no-cache' cache: "no-cache",
}).then(async (res) => { })
.then(async (res) => {
const body = res.status === 204 ? null : await res.json(); const body = res.status === 204 ? null : await res.json();
if (res.status === 200) { if (res.status === 200) {
@ -25,27 +26,28 @@ window.onload = async () => {
} else { } else {
reject(body.error); reject(body.error);
} }
}).catch(reject); })
.catch(reject);
}); });
return promise; return promise;
}; };
document.getElementById('submit').addEventListener('click', () => { document.getElementById("submit").addEventListener("click", () => {
api('notes/create', { api("notes/create", {
text: document.getElementById('text').value text: document.getElementById("text").value,
}).then(() => { }).then(() => {
location.reload(); location.reload();
}); });
}); });
api('notes/timeline').then(notes => { api("notes/timeline").then((notes) => {
const tl = document.getElementById('tl'); const tl = document.getElementById("tl");
for (const note of notes) { for (const note of notes) {
const el = document.createElement('div'); const el = document.createElement("div");
const name = document.createElement('header'); const name = document.createElement("header");
name.textContent = `${note.user.name} @${note.user.username}`; name.textContent = `${note.user.name} @${note.user.username}`;
const text = document.createElement('div'); const text = document.createElement("div");
text.textContent = `${note.text}`; text.textContent = `${note.text}`;
el.appendChild(name); el.appendChild(name);
el.appendChild(text); el.appendChild(text);

View file

@ -634,6 +634,10 @@ router.get("/streaming", async (ctx) => {
ctx.status = 503; ctx.status = 503;
ctx.set("Cache-Control", "private, max-age=0"); ctx.set("Cache-Control", "private, max-age=0");
}); });
router.get("/api/v1/streaming", async (ctx) => {
ctx.status = 503;
ctx.set("Cache-Control", "private, max-age=0");
});
// Render base html for all requests // Render base html for all requests
router.get("(.*)", async (ctx) => { router.get("(.*)", async (ctx) => {

View file

@ -1,4 +1,4 @@
html { html, body {
background-color: var(--bg); background-color: var(--bg);
color: var(--fg); color: var(--fg);
} }

View file

@ -44,6 +44,23 @@ export const urlPreviewHandler = async (ctx: Koa.Context) => {
logger.succ(`Got preview of ${url}: ${summary.title}`); logger.succ(`Got preview of ${url}: ${summary.title}`);
if (
summary.url &&
!(summary.url.startsWith("http://") || summary.url.startsWith("https://"))
) {
throw new Error("unsupported schema included");
}
if (
summary.player?.url &&
!(
summary.player.url.startsWith("http://") ||
summary.player.url.startsWith("https://")
)
) {
throw new Error("unsupported schema included");
}
summary.icon = wrap(summary.icon); summary.icon = wrap(summary.icon);
summary.thumbnail = wrap(summary.thumbnail); summary.thumbnail = wrap(summary.thumbnail);

View file

@ -23,7 +23,7 @@ block og
meta(property='og:description' content= summary) meta(property='og:description' content= summary)
meta(property='og:url' content= url) meta(property='og:url' content= url)
meta(property='og:image' content= imageUrl) meta(property='og:image' content= imageUrl)
if isImage if isImage && !note.files[0].isSensitive
meta(property='og:image:width' content=note.files[0].properties.width) meta(property='og:image:width' content=note.files[0].properties.width)
meta(property='og:image:height' content=note.files[0].properties.height) meta(property='og:image:height' content=note.files[0].properties.height)
meta(property='og:image:type' content=note.files[0].type) meta(property='og:image:type' content=note.files[0].type)

View file

@ -1,6 +1,10 @@
import * as mfm from "mfm-js"; import * as mfm from "mfm-js";
import es from "../../db/elasticsearch.js"; import es from "../../db/elasticsearch.js";
import { publishMainStream, publishNotesStream } from "@/services/stream.js"; import {
publishMainStream,
publishNotesStream,
publishNoteStream,
} from "@/services/stream.js";
import DeliverManager from "@/remote/activitypub/deliver-manager.js"; import DeliverManager from "@/remote/activitypub/deliver-manager.js";
import renderNote from "@/remote/activitypub/renderer/note.js"; import renderNote from "@/remote/activitypub/renderer/note.js";
import renderCreate from "@/remote/activitypub/renderer/create.js"; import renderCreate from "@/remote/activitypub/renderer/create.js";
@ -430,6 +434,12 @@ export default async (
} }
publishNotesStream(note); publishNotesStream(note);
if (note.replyId != null) {
// Only provide the reply note id here as the recipient may not be authorized to see the note.
publishNoteStream(note.replyId, "replied", {
id: note.id,
});
}
const webhooks = await getActiveWebhooks().then((webhooks) => const webhooks = await getActiveWebhooks().then((webhooks) =>
webhooks.filter((x) => x.userId === user.id && x.on.includes("note")), webhooks.filter((x) => x.userId === user.id && x.on.includes("note")),

View file

@ -21,6 +21,16 @@ subscriber.on("message", async (_, data) => {
if (obj.channel === "internal") { if (obj.channel === "internal") {
const { type, body } = obj.message; const { type, body } = obj.message;
switch (type) { switch (type) {
case "localUserUpdated": {
userByIdCache.delete(body.id);
localUserByIdCache.delete(body.id);
localUserByNativeTokenCache.cache.forEach((v, k) => {
if (v.value?.id === body.id) {
localUserByNativeTokenCache.delete(k);
}
});
break;
}
case "userChangeSuspendedState": case "userChangeSuspendedState":
case "userChangeSilencedState": case "userChangeSilencedState":
case "userChangeModeratorState": case "userChangeModeratorState":

Some files were not shown because too many files have changed in this diff Show more