Merge branch 'develop' into beta

This commit is contained in:
ThatOneCalculator 2023-03-14 16:28:43 -07:00
commit ecf05b1d9e
No known key found for this signature in database
GPG key ID: 8703CACD01000000
406 changed files with 7530 additions and 23920 deletions

View file

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

2
.gitignore vendored
View file

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

View file

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

View file

@ -66,7 +66,7 @@ If you have access to a server that supports one of the sources below, I recomme
### 🐋 Docker
[How to run Calckey with Docker](./docker-README.md).
[How to run Calckey with Docker](./docs/docker.md).
## 🧑‍💻 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 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 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`.
## 🧑‍🔬 Configuring a new instance
@ -134,12 +136,7 @@ psql postgres -c "create database calckey with encoding = 'UTF8';"
## 🚚 Migrating from Misskey to Calckey
> ⚠️ Because of their changes, migrating from Foundkey is not supported.
```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 .
```
For migrating from Misskey v13, Misskey v12, and Foundkey, read [this document](./docs/migrate.md).
## 🍀 NGINX

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

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.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.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 => {
fs.mkdirSync('./built/_client_dist_/locales', { recursive: true });
@ -58,7 +55,7 @@ gulp.task('build:backend:style', () => {
});
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'));

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."
lists: "Llistes"
noLists: "No tens cap llista"
note: "Nota"
notes: "Notes"
note: "Post"
notes: "Posts"
following: "Seguint"
followers: "Seguidors"
followsYou: "Et segueix"
@ -141,7 +141,7 @@ _theme:
mention: "Menció"
renote: "Renotar"
_sfx:
note: "Notes"
note: "Posts"
notification: "Notificacions"
_2fa:
step2Url: "També pots inserir aquest enllaç i utilitzes una aplicació d'escriptori:"

View file

@ -32,6 +32,7 @@ uploading: "Uploading..."
save: "Save"
users: "Users"
addUser: "Add a user"
addInstance: "Add an instance"
favorite: "Add to bookmarks"
favorites: "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."
host: "Host"
selectUser: "Select a user"
selectInstance: "Select an instance"
recipient: "Recipient(s)"
annotation: "Comments"
federation: "Federation"
@ -197,6 +199,7 @@ muteAndBlock: "Mutes and Blocks"
mutedUsers: "Muted users"
blockedUsers: "Blocked users"
noUsers: "There are no users"
noInstances: "There are no instances"
editProfile: "Edit profile"
noteDeleteConfirm: "Are you sure you want to delete this post?"
pinLimitExceeded: "You cannot pin any more posts"
@ -363,6 +366,7 @@ notifyAntenna: "Notify about new posts"
withFileAntenna: "Only posts with files"
enableServiceworker: "Enable Push-Notifications for your Browser"
antennaUsersDescription: "List one username per line"
antennaInstancesDescription: "List one instance host per line"
caseSensitive: "Case sensitive"
withReplies: "Include replies"
connectedTo: "Following account(s) are connected"
@ -1294,12 +1298,14 @@ _auth:
pleaseGoBack: "Please go back to the application"
callback: "Returning to the application"
denied: "Access denied"
copyAsk: "Please paste the following authorization code to the application"
_antennaSources:
all: "All posts"
homeTimeline: "Posts from followed users"
users: "Posts from specific users"
userList: "Posts from a specified list of users"
userGroup: "Posts from users in a specified group"
instances: "Posts from all users on an instance"
_weekday:
sunday: "Sunday"
monday: "Monday"
@ -1394,6 +1400,7 @@ _profile:
metadataContent: "Content"
changeAvatar: "Change avatar"
changeBanner: "Change banner"
locationDescription: "If entered properly, this will display your local time to other users."
_exportOrImport:
allNotes: "All posts"
followingList: "Followed users"
@ -1799,7 +1806,7 @@ _apps:
pwa: "Install PWA"
kaiteki: "Kaiteki"
milktea: "Milktea"
subwayTooter: "Subway Tooter"
kimis: "Kimis"
missLi: "MissLi"
mona: "Mona"
theDesk: "TheDesk"
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."
lists: "Listes"
noLists: "Vous navez aucune liste"
note: "Notes"
notes: "Notes"
note: "Post"
notes: "Posts"
following: "Abonnements"
followers: "Abonné·e·s"
followsYou: "Vous suit"

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

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",
"migrate": "typeorm migration:run -d ormconfig.js",
"revertmigration": "typeorm migration:revert -d ormconfig.js",
"check:connect": "node ./check_connect.js",
"build": "pnpm swc src -d built -D",
"watch": "pnpm swc src -d built -D -w",
"lint": "pnpm rome check \"src/**/*.ts\"",
@ -37,13 +38,17 @@
"@tensorflow/tfjs": "^4.2.0",
"ajv": "8.11.2",
"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",
"aws-sdk": "2.1277.0",
"bcryptjs": "2.4.3",
"blurhash": "1.1.5",
"bull": "4.10.2",
"cacheable-lookup": "7.0.0",
"calckey-js": "^0.0.20",
"calckey-js": "^0.0.22",
"cbor": "8.1.0",
"chalk": "5.2.0",
"chalk-template": "0.4.0",
@ -67,6 +72,7 @@
"jsonld": "6.0.0",
"jsrsasign": "10.6.1",
"koa": "2.13.4",
"koa-remove-trailing-slashes": "2.0.3",
"koa-bodyparser": "4.3.0",
"koa-favicon": "2.1.0",
"koa-json-body": "5.3.0",
@ -75,6 +81,7 @@
"koa-send": "5.0.1",
"koa-slow": "2.1.0",
"koa-views": "7.0.2",
"@calckey/megalodon": "5.1.2",
"mfm-js": "0.23.2",
"mime-types": "2.1.35",
"multer": "1.4.4-lts.1",
@ -92,6 +99,7 @@
"punycode": "2.1.1",
"pureimage": "0.3.15",
"qrcode": "1.5.1",
"qs": "6.9.7",
"random-seed": "0.3.0",
"ratelimiter": "3.4.1",
"re2": "1.18.0",
@ -152,6 +160,7 @@
"@types/pug": "2.0.6",
"@types/punycode": "2.1.0",
"@types/qrcode": "1.5.0",
"@types/qs": "6.9.7",
"@types/random-seed": "0.3.3",
"@types/ratelimiter": "3.4.4",
"@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;
isManagedHosting?: boolean;
maxNoteLength?: number;
maxCaptionLength?: number;
deepl: {
managed?: boolean;
authKey?: string;

View file

@ -1,7 +1,12 @@
import config from "@/config/index.js";
import { DB_MAX_IMAGE_COMMENT_LENGTH } from "@/misc/hard-limits.js";
export const MAX_NOTE_TEXT_LENGTH =
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 SEC = 1000; // why do we need this duplicate here?

View file

@ -80,6 +80,13 @@ export async function checkHitAntenna(
)
)
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

View file

@ -2,3 +2,4 @@ import twemoji from "twemoji-parser/dist/lib/regex.js";
const twemojiRegex = twemoji.default;
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 margin = size / 4;
const colors = [
["#FF512F", "#DD2476"],
["#FF61D2", "#FE9090"],
["#72FFB6", "#10D164"],
["#FD8451", "#FFBD6F"],
["#305170", "#6DFC6B"],
["#00C0FF", "#4218B8"],
["#009245", "#FCEE21"],
["#0100EC", "#FB36F4"],
["#FDABDD", "#374A5A"],
["#38A2D7", "#561139"],
["#121C84", "#8278DA"],
["#5761B2", "#1FC5A8"],
["#FFDB01", "#0E197D"],
["#FF3E9D", "#0E1F40"],
["#766eff", "#00d4ff"],
["#9bff6e", "#00d4ff"],
["#ff6e94", "#00d4ff"],
["#ffa96e", "#00d4ff"],
["#ffa96e", "#ff009d"],
["#ffdd6e", "#ff009d"],
["#eb6f92", "#b4637a"],
["#f6c177", "#ea9d34"],
["#ebbcba", "#d7827e"],
["#9ccfd8", "#56949f"],
["#c4a7e7", "#907aa9"],
["#eb6f92", "#f6c177"],
["#eb6f92", "#ebbcba"],
["#eb6f92", "#31748f"],
["#eb6f92", "#9ccfd8"],
["#eb6f92", "#c4a7e7"],
["#f6c177", "#eb6f92"],
["#f6c177", "#ebbcba"],
["#f6c177", "#31748f"],
["#f6c177", "#9ccfd8"],
["#f6c177", "#c4a7e7"],
["#ebbcba", "#eb6f92"],
["#ebbcba", "#f6c177"],
["#ebbcba", "#31748f"],
["#ebbcba", "#9ccfd8"],
["#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;

View file

@ -10,4 +10,4 @@ export const DB_MAX_NOTE_TEXT_LENGTH = 8192;
* Maximum image description length that can be stored in DB.
* 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";
const legacies = new Map([
['like', '👍'],
['love', '❤️'],
['laugh', '😆'],
['hmm', '🤔'],
['surprise', '😮'],
['congrats', '🎉'],
['angry', '💢'],
['confused', '😥'],
['rip', '😇'],
['pudding', '🍮'],
['star', '⭐'],
["like", "👍"],
["love", "❤️"],
["laugh", "😆"],
["hmm", "🤔"],
["surprise", "😮"],
["congrats", "🎉"],
["angry", "💢"],
["confused", "😥"],
["rip", "😇"],
["pudding", "🍮"],
["star", "⭐"],
]);
export async function getFallbackReaction() {
@ -42,7 +42,10 @@ export function convertLegacyReactions(reactions: Record<string, number>) {
if (emoji) {
_reactions.set(emoji, (_reactions.get(emoji) || 0) + reactions[reaction]);
} else {
_reactions.set(reaction, (_reactions.get(reaction) || 0) + reactions[reaction]);
_reactions.set(
reaction,
(_reactions.get(reaction) || 0) + reactions[reaction],
);
}
}
@ -127,7 +130,7 @@ export function decodeReaction(str: string): DecodedReaction {
}
export function convertLegacyReaction(reaction: string): string {
const decoded = decodeReaction(reaction).reaction;
if (legacies.has(decoded)) return legacies.get(decoded)!;
return decoded;
const decoded = decodeReaction(reaction).reaction;
if (legacies.has(decoded)) return legacies.get(decoded)!;
return decoded;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -111,10 +111,37 @@ export async function createNote(
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}`);
// 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
const actor = (await resolvePerson(
getOneApId(note.attributedTo),
@ -123,7 +150,10 @@ export async function createNote(
// Skip if author is suspended.
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);
@ -344,7 +374,7 @@ export async function createNote(
apEmojis,
poll,
uri: note.id,
url: getOneApHrefNullable(note.url),
url: url,
},
silent,
);

View file

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

View file

@ -107,6 +107,7 @@ export async function signup(opts: {
isAdmin:
(await Users.countBy({
host: IsNull(),
isAdmin: true,
})) === 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_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_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_keysWithType from "./endpoints/i/registry/keys-with-type.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_read from "./endpoints/messaging/messages/read.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___mute_create from "./endpoints/mute/create.js";
import * as ep___mute_delete from "./endpoints/mute/delete.js";
@ -538,6 +540,7 @@ const eps = [
["i/regenerate-token", ep___i_regenerateToken],
["i/registry/get-all", ep___i_registry_getAll],
["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/keys-with-type", ep___i_registry_keysWithType],
["i/registry/keys", ep___i_registry_keys],
@ -666,6 +669,7 @@ const eps = [
["users/stats", ep___users_stats],
["admin/drive-capacity-override", ep___admin_driveCapOverride],
["fetch-rss", ep___fetchRss],
["get-sounds", ep___sounds],
];
export interface IEndpointMeta {
@ -766,16 +770,16 @@ export interface IEndpointMeta {
export interface IEndpoint {
name: string;
exec: any;
exec: any; // TODO: may be obosolete @ThatOneCalculator
meta: IEndpointMeta;
params: Schema;
}
const endpoints: IEndpoint[] = eps.map(([name, ep]) => {
const endpoints: IEndpoint[] = (eps as [string, any]).map(([name, ep]) => {
return {
name: name,
exec: ep.default,
meta: ep.meta || {},
meta: ep.meta ?? {},
params: ep.paramDef,
};
});

View file

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

View file

@ -1,7 +1,7 @@
import define from "../../define.js";
import { Users } from "@/models/index.js";
import { User } from "@/models/entities/user.js";
import { insertModerationLog } from "@/services/insert-moderation-log.js";
import { publishInternalEvent } from "@/services/stream.js";
export const meta = {
tags: ["admin"],
@ -29,17 +29,14 @@ export default define(meta, paramDef, async (ps, me) => {
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, {
driveCapacityOverrideMb: ps.overrideMb,
});
publishInternalEvent("localUserUpdated", {
id: user.id,
});
insertModerationLog(me, "change-drive-capacity-override", {
targetId: user.id,
});

View file

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

View file

@ -37,7 +37,10 @@ export const paramDef = {
type: "object",
properties: {
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 },
userGroupId: { type: "string", format: "misskey:id", nullable: true },
keywords: {
@ -64,6 +67,12 @@ export const paramDef = {
type: "string",
},
},
instances: {
type: "array",
items: {
type: "string",
},
},
caseSensitive: { type: "boolean" },
withReplies: { type: "boolean" },
withFile: { type: "boolean" },
@ -75,6 +84,7 @@ export const paramDef = {
"keywords",
"excludeKeywords",
"users",
"instances",
"caseSensitive",
"withReplies",
"withFile",
@ -118,6 +128,7 @@ export default define(meta, paramDef, async (ps, user) => {
keywords: ps.keywords,
excludeKeywords: ps.excludeKeywords,
users: ps.users,
instances: ps.instances,
caseSensitive: ps.caseSensitive,
withReplies: ps.withReplies,
withFile: ps.withFile,

View file

@ -43,7 +43,10 @@ export const paramDef = {
properties: {
antennaId: { type: "string", format: "misskey:id" },
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 },
userGroupId: { type: "string", format: "misskey:id", nullable: true },
keywords: {
@ -70,6 +73,12 @@ export const paramDef = {
type: "string",
},
},
instances: {
type: "array",
items: {
type: "string",
},
},
caseSensitive: { type: "boolean" },
withReplies: { type: "boolean" },
withFile: { type: "boolean" },
@ -82,6 +91,7 @@ export const paramDef = {
"keywords",
"excludeKeywords",
"users",
"instances",
"caseSensitive",
"withReplies",
"withFile",
@ -131,6 +141,7 @@ export default define(meta, paramDef, async (ps, user) => {
keywords: ps.keywords,
excludeKeywords: ps.excludeKeywords,
users: ps.users,
instances: ps.instances,
caseSensitive: ps.caseSensitive,
withReplies: ps.withReplies,
withFile: ps.withFile,

View file

@ -1,6 +1,5 @@
import define from "../../define.js";
import { Channels, ChannelFollowings } from "@/models/index.js";
import { makePaginationQuery } from "../../common/make-pagination-query.js";
export const meta = {
tags: ["channels", "account"],
@ -33,11 +32,24 @@ export const paramDef = {
} as const;
export default define(meta, paramDef, async (ps, me) => {
const query = makePaginationQuery(
ChannelFollowings.createQueryBuilder(),
ps.sinceId,
ps.untilId,
).andWhere({ followerId: me.id });
const query = ChannelFollowings.createQueryBuilder("following").andWhere({
followerId: me.id,
});
if (ps.sinceId) {
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();

View file

@ -102,10 +102,13 @@ export default define(meta, paramDef, async (ps, me) => {
if (typeof ps.blocked === "boolean") {
const meta = await fetchMeta(true);
if (ps.blocked) {
if (meta.blockedHosts.length === 0) {
return [];
}
query.andWhere("instance.host IN (:...blocks)", {
blocks: meta.blockedHosts,
});
} else {
} else if (meta.blockedHosts.length > 0) {
query.andWhere("instance.host NOT IN (:...blocks)", {
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 { fetchMeta } from "@/misc/fetch-meta.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 } from "@/const.js";
import { MAX_NOTE_TEXT_LENGTH, MAX_CAPTION_TEXT_LENGTH } from "@/const.js";
import define from "../define.js";
export const meta = {
@ -178,6 +177,11 @@ export const meta = {
optional: false,
nullable: false,
},
maxCaptionTextLength: {
type: "number",
optional: false,
nullable: false,
},
emojis: {
type: "array",
optional: false,
@ -456,6 +460,7 @@ export default define(meta, paramDef, async (ps, me) => {
backgroundImageUrl: instance.backgroundImageUrl,
logoImageUrl: instance.logoImageUrl,
maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, // 後方互換性のため
maxCaptionTextLength: MAX_CAPTION_TEXT_LENGTH,
emojis: instance.privateMode && !me ? [] : await Emojis.packMany(emojis),
defaultLightTheme: instance.defaultLightTheme,
defaultDarkTheme: instance.defaultDarkTheme,
@ -489,6 +494,7 @@ export default define(meta, paramDef, async (ps, me) => {
requireSetup:
(await Users.countBy({
host: IsNull(),
isAdmin: true,
})) === 0,
}
: {}),

View file

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

View file

@ -7,8 +7,10 @@ import Router from "@koa/router";
import multer from "@koa/multer";
import bodyParser from "koa-bodyparser";
import cors from "@koa/cors";
import { apiMastodonCompatible, getClient } from "./mastodon/ApiMastodonCompatibleService.js";
import { Instances, AccessTokens, Users } from "@/models/index.js";
import config from "@/config/index.js";
import fs from "fs";
import endpoints from "./endpoints.js";
import compatibility from "./compatibility.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 github from "./service/github.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
const app = new Koa();
@ -34,16 +66,11 @@ app.use(async (ctx, next) => {
await next();
});
app.use(
bodyParser({
// リクエストが multipart/form-data でない限りはJSONだと見なす
detectJSON: (ctx) =>
!(
ctx.is("multipart/form-data") ||
ctx.is("application/x-www-form-urlencoded")
),
}),
);
// Init router
const router = new Router();
const mastoRouter = new Router();
const mastoFileRouter = new Router();
const errorRouter = new Router();
// Init multer instance
const upload = multer({
@ -54,8 +81,76 @@ const upload = multer({
},
});
// Init router
const router = new Router();
router.use(
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
@ -141,11 +236,15 @@ router.post("/miauth/:session/check", async (ctx) => {
});
// Return 404 for unknown API
router.all("(.*)", async (ctx) => {
errorRouter.all("(.*)", async (ctx) => {
ctx.status = 404;
});
// Register router
app.use(mastoFileRouter.routes());
app.use(mastoRouter.routes());
app.use(mastoRouter.allowedMethods());
app.use(router.routes());
app.use(errorRouter.routes());
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 type Channel from "./channel.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
@ -41,17 +44,27 @@ export default class Connection {
private channels: Channel[] = [];
private subscribingNotes: any = {};
private cachedNotes: Packed<"Note">[] = [];
private isMastodonCompatible: boolean = false;
private host: string;
private accessToken: string;
private currentSubscribe: string[][] = [];
constructor(
wsConnection: websocket.connection,
subscriber: EventEmitter,
user: User | null | undefined,
token: AccessToken | null | undefined,
host: string,
accessToken: string,
prepareStream: string | undefined,
) {
console.log("constructor", prepareStream);
this.wsConnection = wsConnection;
this.subscriber = subscriber;
if (user) this.user = user;
if (token) this.token = token;
if (host) this.host = host;
if (accessToken) this.accessToken = accessToken;
this.onWsConnectionMessage = this.onWsConnectionMessage.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);
}
console.log("prepare", prepareStream);
if (prepareStream) {
this.onWsConnectionMessage({
type: "utf8",
utf8Data: JSON.stringify({ stream: prepareStream, type: "subscribe" }),
});
}
}
private onUserEvent(data: StreamMessages["user"]["payload"]) {
@ -125,58 +145,149 @@ export default class Connection {
if (data.type !== "utf8") return;
if (data.utf8Data == null) return;
let obj: Record<string, any>;
let objs: Record<string, any>[];
try {
obj = JSON.parse(data.utf8Data);
objs = [JSON.parse(data.utf8Data)];
} catch (e) {
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,
},
});
}
}
}
switch (type) {
case "readNotification":
this.onReadNotification(body);
break;
case "subNote":
this.onSubscribeNote(body);
break;
case "s":
this.onSubscribeNote(body);
break; // alias
case "sr":
this.onSubscribeNote(body);
this.readNote(body);
break;
case "unsubNote":
this.onUnsubscribeNote(body);
break;
case "un":
this.onUnsubscribeNote(body);
break; // alias
case "connect":
this.onChannelConnectRequested(body);
break;
case "disconnect":
this.onChannelDisconnectRequested(body);
break;
case "channel":
this.onChannelMessageRequested(body);
break;
case "ch":
this.onChannelMessageRequested(body);
break; // alias
for (const obj of objs) {
const { type, body } = obj;
console.log(type, body);
switch (type) {
case "readNotification":
this.onReadNotification(body);
break;
case "subNote":
this.onSubscribeNote(body);
break;
case "s":
this.onSubscribeNote(body);
break; // alias
case "sr":
this.onSubscribeNote(body);
this.readNote(body);
break;
case "unsubNote":
this.onUnsubscribeNote(body);
break;
case "un":
this.onUnsubscribeNote(body);
break; // alias
case "connect":
this.onChannelConnectRequested(body);
break;
case "disconnect":
this.onChannelDisconnectRequested(body);
break;
case "channel":
this.onChannelMessageRequested(body);
break;
case "ch":
this.onChannelMessageRequested(body);
break; // alias
// 個々のチャンネルではなくルートレベルでこれらのメッセージを受け取る理由は、
// クライアントの事情を考慮したとき、入力フォームはノートチャンネルやメッセージのメインコンポーネントとは別
// なこともあるため、それらのコンポーネントがそれぞれ各チャンネルに接続するようにするのは面倒なため。
case "typingOnChannel":
this.typingOnChannel(body.channel);
break;
case "typingOnMessaging":
this.typingOnMessaging(body);
break;
// 個々のチャンネルではなくルートレベルでこれらのメッセージを受け取る理由は、
// クライアントの事情を考慮したとき、入力フォームはノートチャンネルやメッセージのメインコンポーネントとは別
// なこともあるため、それらのコンポーネントがそれぞれ各チャンネルに接続するようにするのは面倒なため。
case "typingOnChannel":
this.typingOnChannel(body.channel);
break;
case "typingOnMessaging":
this.typingOnMessaging(body);
break;
}
}
}
@ -280,12 +391,76 @@ export default class Connection {
*
*/
public sendMessageToWs(type: string, payload: any) {
this.wsConnection.send(
JSON.stringify({
type: type,
body: payload,
}),
);
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(
JSON.stringify({
type: type,
body: payload,
}),
);
}
}
/**

View file

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

View file

@ -16,10 +16,13 @@ export const initializeStreamingServer = (server: http.Server) => {
ws.on("request", async (request) => {
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(
request.httpRequest.headers.authorization,
q.i,
accessToken,
).catch((err) => {
request.reject(403, err.message);
return [];
@ -43,8 +46,19 @@ export const initializeStreamingServer = (server: http.Server) => {
}
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
? setInterval(() => {

View file

@ -20,6 +20,7 @@ import { createTemp } from "@/misc/create-temp.js";
import { publishMainStream } from "@/services/stream.js";
import * as Acct from "@/misc/acct.js";
import { envOption } from "@/env.js";
import megalodon, { MegalodonInterface } from "@calckey/megalodon";
import activityPub from "./activitypub.js";
import nodeinfo from "./nodeinfo.js";
import wellKnown from "./well-known.js";
@ -28,6 +29,9 @@ import fileServer from "./file/index.js";
import proxyServer from "./proxy/index.js";
import webServer from "./web/index.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);
@ -35,6 +39,8 @@ export const serverLogger = new Logger("server", "gray", false);
const app = new Koa();
app.proxy = true;
app.use(removeTrailingSlash());
if (!["production", "test"].includes(process.env.NODE_ENV || "")) {
// Logger
app.use(
@ -68,6 +74,25 @@ app.use(mount("/proxy", proxyServer));
// Init 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
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
app.use(mastoRouter.routes());
app.use(router.routes());
app.use(mount(webServer));

View file

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

View file

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

View file

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

View file

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

View file

@ -634,6 +634,10 @@ router.get("/streaming", async (ctx) => {
ctx.status = 503;
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
router.get("(.*)", async (ctx) => {

View file

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

View file

@ -44,6 +44,23 @@ export const urlPreviewHandler = async (ctx: Koa.Context) => {
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.thumbnail = wrap(summary.thumbnail);

View file

@ -7,7 +7,7 @@ block vars
- const isRenote = note.renote && note.text == null && note.fileIds.length == 0 && note.poll == null;
- const isImage = note.files.length !== 0 && note.files[0].type.startsWith('image');
- const isVideo = note.files.length !== 0 && note.files[0].type.startsWith('video');
- const imageUrl = isImage ? note.files[0].url : isVideo ? note.files[0].thumbnailUrl : avatarUrl;
- const imageUrl = isImage ? note.files[0].url : isVideo ? note.files[0].thumbnailUrl : avatarUrl;
block title
= `${title} | ${instanceName}`
@ -23,7 +23,7 @@ block og
meta(property='og:description' content= summary)
meta(property='og:url' content= url)
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:height' content=note.files[0].properties.height)
meta(property='og:image:type' content=note.files[0].type)

View file

@ -1,6 +1,10 @@
import * as mfm from "mfm-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 renderNote from "@/remote/activitypub/renderer/note.js";
import renderCreate from "@/remote/activitypub/renderer/create.js";
@ -430,6 +434,12 @@ export default async (
}
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) =>
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") {
const { type, body } = obj.message;
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 "userChangeSilencedState":
case "userChangeModeratorState":

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