diff --git a/.config/example.yml b/.config/example.yml index 900ac0905..02661b7fb 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -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 diff --git a/.gitignore b/.gitignore index 135bf9660..52139614c 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,8 @@ api-docs.json files ormconfig.json packages/backend/assets/instance.css +packages/backend/assets/sounds/None.mp3 + # blender backups *.blend1 diff --git a/Dockerfile b/Dockerfile index 03ede16c7..a0325265f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 . ./ diff --git a/README.md b/README.md index 91a2c8809..c4fdcf5c6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/custom/assets/badges/error.png b/custom/assets/badges/error.png new file mode 100644 index 000000000..b2fd48b23 --- /dev/null +++ b/custom/assets/badges/error.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:227326c64cce0cd93b18b0065c97bc4887b1a895f377382a32c3058c5375abd0 +size 58350 diff --git a/custom/assets/badges/info.png b/custom/assets/badges/info.png new file mode 100644 index 000000000..10b1ef217 --- /dev/null +++ b/custom/assets/badges/info.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e7a4f2460b5ccbee9fab5bb93bc312125efd0eb1cd82cd56e5ecf0b4fc5d8ef7 +size 56191 diff --git a/custom/assets/badges/not-found.png b/custom/assets/badges/not-found.png new file mode 100644 index 000000000..eaf62f9e3 --- /dev/null +++ b/custom/assets/badges/not-found.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb357df23841d87e0cda813bcaffe6152929c71703f8250206819fb0ed8ef1d8 +size 56874 diff --git a/packages/client/assets/sounds/None.mp3 b/custom/assets/sounds/None.mp3 similarity index 100% rename from packages/client/assets/sounds/None.mp3 rename to custom/assets/sounds/None.mp3 diff --git a/docker-README.md b/docs/docker.md similarity index 100% rename from docker-README.md rename to docs/docker.md diff --git a/docs/migrate.md b/docs/migrate.md new file mode 100644 index 000000000..cdb59951f --- /dev/null +++ b/docs/migrate.md @@ -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 +``` diff --git a/docs/mkv13.patch b/docs/mkv13.patch new file mode 100644 index 000000000..e6106b16f --- /dev/null +++ b/docs/mkv13.patch @@ -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"`); diff --git a/gulpfile.js b/gulpfile.js index 89a6acb83..f994f0029 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -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')); diff --git a/locales/ca-ES.yml b/locales/ca-ES.yml index f32927519..360ef4c22 100644 --- a/locales/ca-ES.yml +++ b/locales/ca-ES.yml @@ -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:" diff --git a/locales/en-US.yml b/locales/en-US.yml index f43f01fb7..47cbb783c 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -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" diff --git a/locales/fr-FR.yml b/locales/fr-FR.yml index 9f7028d3b..2d918b5ad 100644 --- a/locales/fr-FR.yml +++ b/locales/fr-FR.yml @@ -70,8 +70,8 @@ exportRequested: "Vous avez demandé une exportation. L’opération pourrait pr importRequested: "Vous avez initié un import. Cela pourrait prendre un peu de temps." lists: "Listes" noLists: "Vous n’avez aucune liste" -note: "Notes" -notes: "Notes" +note: "Post" +notes: "Posts" following: "Abonnements" followers: "Abonné·e·s" followsYou: "Vous suit" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 5372c8e3d..fe672c16e 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -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" diff --git a/package.json b/package.json index af7d75999..bf5c1db69 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/packages/backend/assets/favicon.svg b/packages/backend/assets/favicon.svg new file mode 100644 index 000000000..7f55f6312 --- /dev/null +++ b/packages/backend/assets/favicon.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4f17cc091606efe4c5e6fc3dbf04b018bc169705f352d52c43dc771d5a716a1d +size 4285 diff --git a/packages/backend/assets/inverse wordmark.svg b/packages/backend/assets/inverse wordmark.svg new file mode 100644 index 000000000..fe9a77be9 --- /dev/null +++ b/packages/backend/assets/inverse wordmark.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:be36c9edc904f05d7f4a96f2092154b14cd7696fc2b9a317e77e56d85f1f06a0 +size 4395 diff --git a/packages/client/assets/sounds/aisha/1.mp3 b/packages/backend/assets/sounds/aisha/1.mp3 similarity index 100% rename from packages/client/assets/sounds/aisha/1.mp3 rename to packages/backend/assets/sounds/aisha/1.mp3 diff --git a/packages/client/assets/sounds/aisha/2.mp3 b/packages/backend/assets/sounds/aisha/2.mp3 similarity index 100% rename from packages/client/assets/sounds/aisha/2.mp3 rename to packages/backend/assets/sounds/aisha/2.mp3 diff --git a/packages/client/assets/sounds/aisha/3.mp3 b/packages/backend/assets/sounds/aisha/3.mp3 similarity index 100% rename from packages/client/assets/sounds/aisha/3.mp3 rename to packages/backend/assets/sounds/aisha/3.mp3 diff --git a/packages/client/assets/sounds/noizenecio/kick_gaba.mp3 b/packages/backend/assets/sounds/noizenecio/kick_gaba.mp3 similarity index 100% rename from packages/client/assets/sounds/noizenecio/kick_gaba.mp3 rename to packages/backend/assets/sounds/noizenecio/kick_gaba.mp3 diff --git a/packages/client/assets/sounds/noizenecio/kick_gaba2.mp3 b/packages/backend/assets/sounds/noizenecio/kick_gaba2.mp3 similarity index 100% rename from packages/client/assets/sounds/noizenecio/kick_gaba2.mp3 rename to packages/backend/assets/sounds/noizenecio/kick_gaba2.mp3 diff --git a/packages/client/assets/sounds/syuilo/down.mp3 b/packages/backend/assets/sounds/syuilo/down.mp3 similarity index 100% rename from packages/client/assets/sounds/syuilo/down.mp3 rename to packages/backend/assets/sounds/syuilo/down.mp3 diff --git a/packages/client/assets/sounds/syuilo/kick.mp3 b/packages/backend/assets/sounds/syuilo/kick.mp3 similarity index 100% rename from packages/client/assets/sounds/syuilo/kick.mp3 rename to packages/backend/assets/sounds/syuilo/kick.mp3 diff --git a/packages/client/assets/sounds/syuilo/pirori-square-wet.mp3 b/packages/backend/assets/sounds/syuilo/pirori-square-wet.mp3 similarity index 100% rename from packages/client/assets/sounds/syuilo/pirori-square-wet.mp3 rename to packages/backend/assets/sounds/syuilo/pirori-square-wet.mp3 diff --git a/packages/client/assets/sounds/syuilo/pirori-wet.mp3 b/packages/backend/assets/sounds/syuilo/pirori-wet.mp3 similarity index 100% rename from packages/client/assets/sounds/syuilo/pirori-wet.mp3 rename to packages/backend/assets/sounds/syuilo/pirori-wet.mp3 diff --git a/packages/client/assets/sounds/syuilo/pirori.mp3 b/packages/backend/assets/sounds/syuilo/pirori.mp3 similarity index 100% rename from packages/client/assets/sounds/syuilo/pirori.mp3 rename to packages/backend/assets/sounds/syuilo/pirori.mp3 diff --git a/packages/client/assets/sounds/syuilo/poi1.mp3 b/packages/backend/assets/sounds/syuilo/poi1.mp3 similarity index 100% rename from packages/client/assets/sounds/syuilo/poi1.mp3 rename to packages/backend/assets/sounds/syuilo/poi1.mp3 diff --git a/packages/client/assets/sounds/syuilo/poi2.mp3 b/packages/backend/assets/sounds/syuilo/poi2.mp3 similarity index 100% rename from packages/client/assets/sounds/syuilo/poi2.mp3 rename to packages/backend/assets/sounds/syuilo/poi2.mp3 diff --git a/packages/client/assets/sounds/syuilo/pope1.mp3 b/packages/backend/assets/sounds/syuilo/pope1.mp3 similarity index 100% rename from packages/client/assets/sounds/syuilo/pope1.mp3 rename to packages/backend/assets/sounds/syuilo/pope1.mp3 diff --git a/packages/client/assets/sounds/syuilo/pope2.mp3 b/packages/backend/assets/sounds/syuilo/pope2.mp3 similarity index 100% rename from packages/client/assets/sounds/syuilo/pope2.mp3 rename to packages/backend/assets/sounds/syuilo/pope2.mp3 diff --git a/packages/client/assets/sounds/syuilo/popo.mp3 b/packages/backend/assets/sounds/syuilo/popo.mp3 similarity index 100% rename from packages/client/assets/sounds/syuilo/popo.mp3 rename to packages/backend/assets/sounds/syuilo/popo.mp3 diff --git a/packages/client/assets/sounds/syuilo/queue-jammed.mp3 b/packages/backend/assets/sounds/syuilo/queue-jammed.mp3 similarity index 100% rename from packages/client/assets/sounds/syuilo/queue-jammed.mp3 rename to packages/backend/assets/sounds/syuilo/queue-jammed.mp3 diff --git a/packages/client/assets/sounds/syuilo/reverved.mp3 b/packages/backend/assets/sounds/syuilo/reverved.mp3 similarity index 100% rename from packages/client/assets/sounds/syuilo/reverved.mp3 rename to packages/backend/assets/sounds/syuilo/reverved.mp3 diff --git a/packages/client/assets/sounds/syuilo/ryukyu.mp3 b/packages/backend/assets/sounds/syuilo/ryukyu.mp3 similarity index 100% rename from packages/client/assets/sounds/syuilo/ryukyu.mp3 rename to packages/backend/assets/sounds/syuilo/ryukyu.mp3 diff --git a/packages/client/assets/sounds/syuilo/snare.mp3 b/packages/backend/assets/sounds/syuilo/snare.mp3 similarity index 100% rename from packages/client/assets/sounds/syuilo/snare.mp3 rename to packages/backend/assets/sounds/syuilo/snare.mp3 diff --git a/packages/client/assets/sounds/syuilo/square-pico.mp3 b/packages/backend/assets/sounds/syuilo/square-pico.mp3 similarity index 100% rename from packages/client/assets/sounds/syuilo/square-pico.mp3 rename to packages/backend/assets/sounds/syuilo/square-pico.mp3 diff --git a/packages/client/assets/sounds/syuilo/triple.mp3 b/packages/backend/assets/sounds/syuilo/triple.mp3 similarity index 100% rename from packages/client/assets/sounds/syuilo/triple.mp3 rename to packages/backend/assets/sounds/syuilo/triple.mp3 diff --git a/packages/client/assets/sounds/syuilo/up.mp3 b/packages/backend/assets/sounds/syuilo/up.mp3 similarity index 100% rename from packages/client/assets/sounds/syuilo/up.mp3 rename to packages/backend/assets/sounds/syuilo/up.mp3 diff --git a/packages/client/assets/sounds/syuilo/waon.mp3 b/packages/backend/assets/sounds/syuilo/waon.mp3 similarity index 100% rename from packages/client/assets/sounds/syuilo/waon.mp3 rename to packages/backend/assets/sounds/syuilo/waon.mp3 diff --git a/packages/backend/check_connect.js b/packages/backend/check_connect.js new file mode 100644 index 000000000..8bf134a10 --- /dev/null +++ b/packages/backend/check_connect.js @@ -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; +}); diff --git a/packages/backend/migration/1676093997212-AntennaInstances.js b/packages/backend/migration/1676093997212-AntennaInstances.js new file mode 100644 index 000000000..7553e1255 --- /dev/null +++ b/packages/backend/migration/1676093997212-AntennaInstances.js @@ -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"`); + } +} diff --git a/packages/backend/migration/1677935903517-DriveComment.js b/packages/backend/migration/1677935903517-DriveComment.js new file mode 100644 index 000000000..41c7556f0 --- /dev/null +++ b/packages/backend/migration/1677935903517-DriveComment.js @@ -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)`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index dda128eca..910ffd374 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -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", diff --git a/packages/backend/src/@types/koa-remove-trailing-slashes/index.d.ts b/packages/backend/src/@types/koa-remove-trailing-slashes/index.d.ts new file mode 100644 index 000000000..eda0cf142 --- /dev/null +++ b/packages/backend/src/@types/koa-remove-trailing-slashes/index.d.ts @@ -0,0 +1 @@ +declare module 'koa-remove-trailing-slashes'; diff --git a/packages/backend/src/config/types.ts b/packages/backend/src/config/types.ts index e9c42c08f..ed9b0ece0 100644 --- a/packages/backend/src/config/types.ts +++ b/packages/backend/src/config/types.ts @@ -74,6 +74,7 @@ export type Source = { maxUserSignups?: number; isManagedHosting?: boolean; maxNoteLength?: number; + maxCaptionLength?: number; deepl: { managed?: boolean; authKey?: string; diff --git a/packages/backend/src/const.ts b/packages/backend/src/const.ts index 82a093100..7e8f96444 100644 --- a/packages/backend/src/const.ts +++ b/packages/backend/src/const.ts @@ -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? diff --git a/packages/backend/src/misc/check-hit-antenna.ts b/packages/backend/src/misc/check-hit-antenna.ts index aa38d9e27..adcdd190f 100644 --- a/packages/backend/src/misc/check-hit-antenna.ts +++ b/packages/backend/src/misc/check-hit-antenna.ts @@ -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 diff --git a/packages/backend/src/misc/emoji-regex.ts b/packages/backend/src/misc/emoji-regex.ts index 573034f6b..08b44788d 100644 --- a/packages/backend/src/misc/emoji-regex.ts +++ b/packages/backend/src/misc/emoji-regex.ts @@ -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})$`); diff --git a/packages/backend/src/misc/gen-identicon.ts b/packages/backend/src/misc/gen-identicon.ts index 79297f8f2..1e51dfe2a 100644 --- a/packages/backend/src/misc/gen-identicon.ts +++ b/packages/backend/src/misc/gen-identicon.ts @@ -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; diff --git a/packages/backend/src/misc/hard-limits.ts b/packages/backend/src/misc/hard-limits.ts index 4ba90293c..51d2c0f5d 100644 --- a/packages/backend/src/misc/hard-limits.ts +++ b/packages/backend/src/misc/hard-limits.ts @@ -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; diff --git a/packages/backend/src/misc/reaction-lib.ts b/packages/backend/src/misc/reaction-lib.ts index 7d78904bb..e25b2d661 100644 --- a/packages/backend/src/misc/reaction-lib.ts +++ b/packages/backend/src/misc/reaction-lib.ts @@ -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) { 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; } diff --git a/packages/backend/src/models/entities/antenna.ts b/packages/backend/src/models/entities/antenna.ts index 45d9553e4..c653b2a05 100644 --- a/packages/backend/src/models/entities/antenna.ts +++ b/packages/backend/src/models/entities/antenna.ts @@ -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: [], }) diff --git a/packages/backend/src/models/entities/drive-file.ts b/packages/backend/src/models/entities/drive-file.ts index 1fa91b1a9..32e19bc6e 100644 --- a/packages/backend/src/models/entities/drive-file.ts +++ b/packages/backend/src/models/entities/drive-file.ts @@ -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; diff --git a/packages/backend/src/models/repositories/antenna.ts b/packages/backend/src/models/repositories/antenna.ts index 57ce2fc9e..c325e2589 100644 --- a/packages/backend/src/models/repositories/antenna.ts +++ b/packages/backend/src/models/repositories/antenna.ts @@ -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, diff --git a/packages/backend/src/models/repositories/note.ts b/packages/backend/src/models/repositories/note.ts index 2bc3b90ca..882cfdd31 100644 --- a/packages/backend/src/models/repositories/note.ts +++ b/packages/backend/src/models/repositories/note.ts @@ -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, diff --git a/packages/backend/src/models/schema/antenna.ts b/packages/backend/src/models/schema/antenna.ts index c7eed092e..990e2daa2 100644 --- a/packages/backend/src/models/schema/antenna.ts +++ b/packages/backend/src/models/schema/antenna.ts @@ -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, diff --git a/packages/backend/src/models/schema/note.ts b/packages/backend/src/models/schema/note.ts index 4a7bd80fc..e17f054e8 100644 --- a/packages/backend/src/models/schema/note.ts +++ b/packages/backend/src/models/schema/note.ts @@ -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", diff --git a/packages/backend/src/queue/processors/webhook-deliver.ts b/packages/backend/src/queue/processors/webhook-deliver.ts index a130fcd38..2edf4f696 100644 --- a/packages/backend/src/queue/processors/webhook-deliver.ts +++ b/packages/backend/src/queue/processors/webhook-deliver.ts @@ -20,6 +20,7 @@ export default async (job: Bull.Job) => { "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, diff --git a/packages/backend/src/remote/activitypub/models/note.ts b/packages/backend/src/remote/activitypub/models/note.ts index 72953c5bf..a0945ae7b 100644 --- a/packages/backend/src/remote/activitypub/models/note.ts +++ b/packages/backend/src/remote/activitypub/models/note.ts @@ -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, ); diff --git a/packages/backend/src/remote/activitypub/models/person.ts b/packages/backend/src/remote/activitypub/models/person.ts index 0ec671f0a..16b265b0b 100644 --- a/packages/backend/src/remote/activitypub/models/person.ts +++ b/packages/backend/src/remote/activitypub/models/person.ts @@ -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; @@ -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) diff --git a/packages/backend/src/server/api/common/signup.ts b/packages/backend/src/server/api/common/signup.ts index 7ae9e10fb..bb178506b 100644 --- a/packages/backend/src/server/api/common/signup.ts +++ b/packages/backend/src/server/api/common/signup.ts @@ -107,6 +107,7 @@ export async function signup(opts: { isAdmin: (await Users.countBy({ host: IsNull(), + isAdmin: true, })) === 0, }), ); diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index b6d9c3e1f..7fb5fd320 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -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, }; }); diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts index 11ef2273c..2e035d169 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts @@ -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"); diff --git a/packages/backend/src/server/api/endpoints/admin/drive-capacity-override.ts b/packages/backend/src/server/api/endpoints/admin/drive-capacity-override.ts index 1f6ac5f27..c8be34469 100644 --- a/packages/backend/src/server/api/endpoints/admin/drive-capacity-override.ts +++ b/packages/backend/src/server/api/endpoints/admin/drive-capacity-override.ts @@ -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, }); diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 1808de118..c8c639f50 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -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, diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts index 171fc2c64..c1ba7bcdf 100644 --- a/packages/backend/src/server/api/endpoints/antennas/create.ts +++ b/packages/backend/src/server/api/endpoints/antennas/create.ts @@ -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, diff --git a/packages/backend/src/server/api/endpoints/antennas/update.ts b/packages/backend/src/server/api/endpoints/antennas/update.ts index 6c48cd369..f491c0b63 100644 --- a/packages/backend/src/server/api/endpoints/antennas/update.ts +++ b/packages/backend/src/server/api/endpoints/antennas/update.ts @@ -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, diff --git a/packages/backend/src/server/api/endpoints/channels/followed.ts b/packages/backend/src/server/api/endpoints/channels/followed.ts index 63483a79a..993a211f7 100644 --- a/packages/backend/src/server/api/endpoints/channels/followed.ts +++ b/packages/backend/src/server/api/endpoints/channels/followed.ts @@ -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(); diff --git a/packages/backend/src/server/api/endpoints/federation/instances.ts b/packages/backend/src/server/api/endpoints/federation/instances.ts index 3ab37ff6f..8f6184b19 100644 --- a/packages/backend/src/server/api/endpoints/federation/instances.ts +++ b/packages/backend/src/server/api/endpoints/federation/instances.ts @@ -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, }); diff --git a/packages/backend/src/server/api/endpoints/get-sounds.ts b/packages/backend/src/server/api/endpoints/get-sounds.ts new file mode 100644 index 000000000..f7edd3860 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/get-sounds.ts @@ -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; +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/get-unsecure.ts b/packages/backend/src/server/api/endpoints/i/registry/get-unsecure.ts new file mode 100644 index 000000000..f98c6c929 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/get-unsecure.ts @@ -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; +}); diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts index 005d0800a..4dc1c941e 100644 --- a/packages/backend/src/server/api/endpoints/meta.ts +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -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, } : {}), diff --git a/packages/backend/src/server/api/endpoints/notes/featured.ts b/packages/backend/src/server/api/endpoints/notes/featured.ts index cd7e44296..a6d764bf3 100644 --- a/packages/backend/src/server/api/endpoints/notes/featured.ts +++ b/packages/backend/src/server/api/endpoints/notes/featured.ts @@ -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); diff --git a/packages/backend/src/server/api/index.ts b/packages/backend/src/server/api/index.ts index b84bbdbb3..4eb87a614 100644 --- a/packages/backend/src/server/api/index.ts +++ b/packages/backend/src/server/api/index.ts @@ -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; diff --git a/packages/backend/src/server/api/mastodon/ApiMastodonCompatibleService.ts b/packages/backend/src/server/api/mastodon/ApiMastodonCompatibleService.ts new file mode 100644 index 000000000..9a690e19f --- /dev/null +++ b/packages/backend/src/server/api/mastodon/ApiMastodonCompatibleService.ts @@ -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; + } + }); +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts new file mode 100644 index 000000000..1c5e31fe8 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -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; + } + }, + ); +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/auth.ts b/packages/backend/src/server/api/mastodon/endpoints/auth.ts new file mode 100644 index 000000000..f1c54be0a --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/auth.ts @@ -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(); + 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; + } + }); +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/filter.ts b/packages/backend/src/server/api/mastodon/endpoints/filter.ts new file mode 100644 index 000000000..d21bc1d33 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/filter.ts @@ -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; + } + }); +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/meta.ts b/packages/backend/src/server/api/mastodon/endpoints/meta.ts new file mode 100644 index 000000000..e5e0f2622 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/meta.ts @@ -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: "

Please refer to the original instance for the actual admin contact.

", + 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: [], + }; +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts new file mode 100644 index 000000000..8508f1d48 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -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; + } + }); +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts new file mode 100644 index 000000000..b610e784d --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -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 []; + } +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts new file mode 100644 index 000000000..d04b7a8b9 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -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("
"); + 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: `

${content}

`, + 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, + }; +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts new file mode 100644 index 000000000..1b5afd6d0 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -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 = `

${autoLinker(t.content, host)}

${reactions.join( + ", ", + )}

`; + 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 `@${match.getMention()}`; + case "hashtag": + console.log("Hashtag: ", match.getHashtag()); + return `#${match.getHashtag()}`; + } + 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, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} +function nl2br(str: string) { + if (!str) { + return ""; + } + str = str.replace(/\r\n/g, "
"); + str = str.replace(/(\n|\r)/g, "
"); + return str; +} diff --git a/packages/backend/src/server/api/stream/index.ts b/packages/backend/src/server/api/stream/index.ts index 9675d184c..f285b2f5b 100644 --- a/packages/backend/src/server/api/stream/index.ts +++ b/packages/backend/src/server/api/stream/index.ts @@ -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; + let objs: Record[]; 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, + }), + ); + } } /** diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts index 837f42c87..9becf9f64 100644 --- a/packages/backend/src/server/api/stream/types.ts +++ b/packages/backend/src/server/api/stream/types.ts @@ -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]: { diff --git a/packages/backend/src/server/api/streaming.ts b/packages/backend/src/server/api/streaming.ts index 9e84ec307..14e07b748 100644 --- a/packages/backend/src/server/api/streaming.ts +++ b/packages/backend/src/server/api/streaming.ts @@ -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(() => { diff --git a/packages/backend/src/server/index.ts b/packages/backend/src/server/index.ts index 4d4b81d7a..174aaf222 100644 --- a/packages/backend/src/server/index.ts +++ b/packages/backend/src/server/index.ts @@ -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)); diff --git a/packages/backend/src/server/nodeinfo.ts b/packages/backend/src/server/nodeinfo.ts index a7fa0de4c..8563573d4 100644 --- a/packages/backend/src/server/nodeinfo.ts +++ b/packages/backend/src/server/nodeinfo.ts @@ -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, diff --git a/packages/backend/src/server/web/bios.js b/packages/backend/src/server/web/bios.js index 1acdafd1d..e715a0106 100644 --- a/packages/backend/src/server/web/bios.js +++ b/packages/backend/src/server/web/bios.js @@ -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(); }); diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index f4e0707a9..e7e859d20 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -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 @@
`; - errorsElement = document.getElementById('errors'); + errorsElement = document.getElementById("errors"); } - const detailsElement = document.createElement('details'); + const detailsElement = document.createElement("details"); detailsElement.innerHTML = `
@@ -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); diff --git a/packages/backend/src/server/web/cli.js b/packages/backend/src/server/web/cli.js index 3dff1d486..290453f7e 100644 --- a/packages/backend/src/server/web/cli.js +++ b/packages/backend/src/server/web/cli.js @@ -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); diff --git a/packages/backend/src/server/web/index.ts b/packages/backend/src/server/web/index.ts index 4ae8e5bfd..642a17d57 100644 --- a/packages/backend/src/server/web/index.ts +++ b/packages/backend/src/server/web/index.ts @@ -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) => { diff --git a/packages/backend/src/server/web/style.css b/packages/backend/src/server/web/style.css index 5072e0ad4..ee42b9deb 100644 --- a/packages/backend/src/server/web/style.css +++ b/packages/backend/src/server/web/style.css @@ -1,4 +1,4 @@ -html { +html, body { background-color: var(--bg); color: var(--fg); } diff --git a/packages/backend/src/server/web/url-preview.ts b/packages/backend/src/server/web/url-preview.ts index d7da4e72c..c9f3b6cac 100644 --- a/packages/backend/src/server/web/url-preview.ts +++ b/packages/backend/src/server/web/url-preview.ts @@ -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); diff --git a/packages/backend/src/server/web/views/note.pug b/packages/backend/src/server/web/views/note.pug index b2dc5f63f..25eac0942 100644 --- a/packages/backend/src/server/web/views/note.pug +++ b/packages/backend/src/server/web/views/note.pug @@ -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) diff --git a/packages/backend/src/services/note/create.ts b/packages/backend/src/services/note/create.ts index 210ea7771..968aed880 100644 --- a/packages/backend/src/services/note/create.ts +++ b/packages/backend/src/services/note/create.ts @@ -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")), diff --git a/packages/backend/src/services/user-cache.ts b/packages/backend/src/services/user-cache.ts index 1a3b2fc65..949244855 100644 --- a/packages/backend/src/services/user-cache.ts +++ b/packages/backend/src/services/user-cache.ts @@ -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": diff --git a/packages/client/assets/dummy.png b/packages/client/assets/dummy.png new file mode 100644 index 000000000..c1a3501e7 --- /dev/null +++ b/packages/client/assets/dummy.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3e770a13738887f9fbb0b62f9881c7035a36c36832676ae10de531cd5c4c2cc8 +size 14059 diff --git a/packages/client/assets/dummy_original.png b/packages/client/assets/dummy_original.png new file mode 100644 index 000000000..f9c1b2f05 --- /dev/null +++ b/packages/client/assets/dummy_original.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b706b7bc2b77a01be166d8295b962263bce583bc4c24a8905ff52b090dddd766 +size 69675 diff --git a/packages/client/package.json b/packages/client/package.json index 5a184dbc0..1f3270b1d 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -6,11 +6,10 @@ "build": "pnpm vite build", "lint": "pnpm rome check \"src/**/*.{ts,vue}\"" }, - "dependencies": { - "@khmyznikov/pwa-install": "^0.2.0" - }, "devDependencies": { "@discordapp/twemoji": "14.0.2", + "@khmyznikov/pwa-install": "^0.2.0", + "@phosphor-icons/web": "^2.0.3", "@rollup/plugin-alias": "3.1.9", "@rollup/plugin-json": "4.1.0", "@rollup/pluginutils": "^4.2.1", @@ -33,11 +32,13 @@ "blurhash": "1.1.5", "broadcast-channel": "4.19.1", "browser-image-resizer": "https://github.com/misskey-dev/browser-image-resizer.git", - "calckey-js": "^0.0.20", + "calckey-js": "^0.0.22", "chart.js": "4.1.1", "chartjs-adapter-date-fns": "2.0.1", + "chartjs-chart-matrix": "^2.0.1", "chartjs-plugin-gradient": "0.5.1", "chartjs-plugin-zoom": "1.2.1", + "city-timezones": "^1.2.1", "compare-versions": "5.0.3", "cropperjs": "2.0.0-beta.2", "cross-env": "7.0.3", @@ -45,6 +46,7 @@ "date-fns": "2.29.3", "escape-regexp": "0.0.1", "eventemitter3": "4.0.7", + "gsap": "^3.11.4", "idb-keyval": "6.2.0", "insert-text-at-cursor": "0.3.0", "json5": "2.2.3", diff --git a/packages/client/src/account.ts b/packages/client/src/account.ts index 6f8a8292e..fd9bf48e6 100644 --- a/packages/client/src/account.ts +++ b/packages/client/src/account.ts @@ -242,7 +242,7 @@ export async function openAccountMenu( ...accountItemPromises, { type: "parent", - icon: "ph-plus-bold ph-lg", + icon: "ph-plus ph-bold ph-lg", text: i18n.ts.addAccount, children: [ { @@ -261,13 +261,13 @@ export async function openAccountMenu( }, { type: "link", - icon: "ph-users-bold ph-lg", + icon: "ph-users ph-bold ph-lg", text: i18n.ts.manageAccounts, to: "/settings/accounts", }, { type: "button", - icon: "ph-sign-out-bold ph-lg", + icon: "ph-sign-out ph-bold ph-lg", text: i18n.ts.logout, action: () => { signout(); diff --git a/packages/client/src/components/MkAbuseReportWindow.vue b/packages/client/src/components/MkAbuseReportWindow.vue index b8ef6686c..f1b3ae431 100644 --- a/packages/client/src/components/MkAbuseReportWindow.vue +++ b/packages/client/src/components/MkAbuseReportWindow.vue @@ -1,7 +1,7 @@ @@ -66,7 +66,7 @@ const renote = async (viaKeyboard = false, ev?: MouseEvent) => { let buttonActions = [{ text: i18n.ts.renote, - icon: 'ph-repeat-bold ph-lg', + icon: 'ph-repeat ph-bold ph-lg', danger: false, action: () => { os.api('notes/create', { @@ -86,7 +86,7 @@ const renote = async (viaKeyboard = false, ev?: MouseEvent) => { if (!defaultStore.state.seperateRenoteQuote) { buttonActions.push({ text: i18n.ts.quote, - icon: 'ph-quotes-bold ph-lg', + icon: 'ph-quotes ph-bold ph-lg', danger: false, action: () => { os.post({ @@ -99,7 +99,7 @@ const renote = async (viaKeyboard = false, ev?: MouseEvent) => { if (hasRenotedBefore) { buttonActions.push({ text: i18n.ts.unrenote, - icon: 'ph-trash-bold ph-lg', + icon: 'ph-trash ph-bold ph-lg', danger: true, action: () => { os.api('notes/unrenote', { diff --git a/packages/client/src/components/MkSignin.vue b/packages/client/src/components/MkSignin.vue index b04f1cea6..63a5bf10e 100644 --- a/packages/client/src/components/MkSignin.vue +++ b/packages/client/src/components/MkSignin.vue @@ -11,7 +11,7 @@ - + {{ signing ? i18n.ts.loggingIn : i18n.ts.login }} @@ -30,20 +30,20 @@

{{ i18n.ts.twoStepAuthentication }}

- + - + {{ signing ? i18n.ts.loggingIn : i18n.ts.login }} diff --git a/packages/client/src/components/MkSignup.vue b/packages/client/src/components/MkSignup.vue index 9b09e852e..b3f7f473c 100644 --- a/packages/client/src/components/MkSignup.vue +++ b/packages/client/src/components/MkSignup.vue @@ -2,53 +2,53 @@
- +
- + - - + + - + - + diff --git a/packages/client/src/components/MkStarButton.vue b/packages/client/src/components/MkStarButton.vue index 9a0cca414..9327e1dd4 100644 --- a/packages/client/src/components/MkStarButton.vue +++ b/packages/client/src/components/MkStarButton.vue @@ -6,9 +6,9 @@ - - - + + + diff --git a/packages/client/src/components/MkSubNoteContent.vue b/packages/client/src/components/MkSubNoteContent.vue index b575fcc0a..5176a3825 100644 --- a/packages/client/src/components/MkSubNoteContent.vue +++ b/packages/client/src/components/MkSubNoteContent.vue @@ -2,7 +2,7 @@
({{ i18n.ts.deleted }}) - + {{ i18n.ts.quoteAttached }}: ...
diff --git a/packages/client/src/components/MkTutorialDialog.vue b/packages/client/src/components/MkTutorialDialog.vue index 0160ace21..c077d3fc5 100644 --- a/packages/client/src/components/MkTutorialDialog.vue +++ b/packages/client/src/components/MkTutorialDialog.vue @@ -13,17 +13,17 @@ -

{{ i18n.ts._tutorial.title }}

+

{{ i18n.ts._tutorial.title }}

{{ i18n.ts._tutorial.step1_1 }}

@@ -41,7 +41,7 @@
{{ i18n.ts._tutorial.step3_2 }}

- {{ i18n.ts.next }} + {{ i18n.ts.next }}

{{ i18n.ts._tutorial.step4_1 }}

@@ -64,35 +64,35 @@
  • diff --git a/packages/client/src/components/MkUpdated.vue b/packages/client/src/components/MkUpdated.vue index bd8882195..0ffe2edea 100644 --- a/packages/client/src/components/MkUpdated.vue +++ b/packages/client/src/components/MkUpdated.vue @@ -1,20 +1,21 @@ - diff --git a/packages/client/src/components/MkUrlPreview.vue b/packages/client/src/components/MkUrlPreview.vue index ef65cb796..6f0adfb14 100644 --- a/packages/client/src/components/MkUrlPreview.vue +++ b/packages/client/src/components/MkUrlPreview.vue @@ -1,6 +1,6 @@