diff --git a/README.md b/README.md index 55f599235..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 @@ -136,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/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..ee3446c85 --- /dev/null +++ b/docs/migrate.md @@ -0,0 +1,52 @@ +# 🚚 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 +# 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 +# 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 +# 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/packages/backend/src/server/api/index.ts b/packages/backend/src/server/api/index.ts index 3ea3ff67e..752303e95 100644 --- a/packages/backend/src/server/api/index.ts +++ b/packages/backend/src/server/api/index.ts @@ -7,9 +7,10 @@ import Router from "@koa/router"; import multer from "@koa/multer"; import bodyParser from "koa-bodyparser"; import cors from "@koa/cors"; -import { apiMastodonCompatible } from "./mastodon/ApiMastodonCompatibleService.js"; +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"; @@ -39,6 +40,7 @@ app.use(async (ctx, next) => { // Init router const router = new Router(); const mastoRouter = new Router(); +const mastoFileRouter = new Router(); const errorRouter = new Router(); // Init multer instance @@ -68,6 +70,46 @@ mastoRouter.use( }), ); + +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.buffer); + 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.buffer); + 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) { @@ -170,7 +212,9 @@ errorRouter.all("(.*)", async (ctx) => { }); // Register router +app.use(mastoFileRouter.routes()); app.use(mastoRouter.routes()); +app.use(mastoRouter.allowedMethods()); app.use(router.routes()); app.use(errorRouter.routes()); diff --git a/packages/backend/src/server/api/mastodon/endpoints/meta.ts b/packages/backend/src/server/api/mastodon/endpoints/meta.ts index 5fba6f8a6..67f3901e4 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/meta.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/meta.ts @@ -1,9 +1,13 @@ 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 || "", @@ -12,7 +16,11 @@ export async function getInstance(response: Entity.Instance) { email: response.email || "", version: "3.0.0 compatible (Calckey)", urls: response.urls, - stats: response.stats, + stats: { + user_count: (await totalUsers), + status_count: (await totalStatuses), + domain_count: response.stats.domain_count + }, thumbnail: response.thumbnail || "", languages: meta.langs, registrations: !meta.disableRegistration || response.registrations, @@ -80,17 +88,17 @@ export async function getInstance(response: Entity.Instance) { bot: true, discoverable: false, group: false, - created_at: Math.floor(new Date().getTime() / 1000), - note: "Please refer to the original instance for the actual admin contact.", - url: "/", - avatar: "/static-assets/badges/info.png", - avatar_static: "/static-assets/badges/info.png", + 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: Math.floor(new Date().getTime() / 1000), + last_status_at: new Date().toISOString(), noindex: true, emojis: [], fields: [], diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index a72ac2c7e..3981a1781 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -1,16 +1,10 @@ import Router from "@koa/router"; -import megalodon, { MegalodonInterface } from "@calckey/megalodon"; import { getClient } from "../ApiMastodonCompatibleService.js"; -import fs from "fs"; -import { pipeline } from "node:stream"; -import { promisify } from "node:util"; -import { createTemp } from "@/misc/create-temp.js"; -import { emojiRegex, emojiRegexAtStartToEnd } from "@/misc/emoji-regex.js"; +import { emojiRegexAtStartToEnd } from "@/misc/emoji-regex.js"; import axios from "axios"; -const pump = promisify(pipeline); export function apiStatusMastodon(router: Router): void { - router.post("/v1/statuses", async (ctx, reply) => { + router.post("/v1/statuses", async (ctx) => { const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; const accessTokens = ctx.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -52,7 +46,7 @@ export function apiStatusMastodon(router: Router): void { }); router.get<{ Params: { id: string } }>( "/v1/statuses/:id", - async (ctx, reply) => { + async (ctx) => { const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; const accessTokens = ctx.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -68,7 +62,7 @@ export function apiStatusMastodon(router: Router): void { ); router.delete<{ Params: { id: string } }>( "/v1/statuses/:id", - async (ctx, reply) => { + async (ctx) => { const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; const accessTokens = ctx.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -90,7 +84,7 @@ export function apiStatusMastodon(router: Router): void { } router.get<{ Params: { id: string } }>( "/v1/statuses/:id/context", - async (ctx, reply) => { + async (ctx) => { const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; const accessTokens = ctx.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -123,7 +117,7 @@ export function apiStatusMastodon(router: Router): void { ); router.get<{ Params: { id: string } }>( "/v1/statuses/:id/reblogged_by", - async (ctx, reply) => { + async (ctx) => { const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; const accessTokens = ctx.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -139,13 +133,13 @@ export function apiStatusMastodon(router: Router): void { ); router.get<{ Params: { id: string } }>( "/v1/statuses/:id/favourited_by", - async (ctx, reply) => { + async (ctx) => { ctx.body = []; }, ); router.post<{ Params: { id: string } }>( "/v1/statuses/:id/favourite", - async (ctx, reply) => { + async (ctx) => { const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; const accessTokens = ctx.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -167,7 +161,7 @@ export function apiStatusMastodon(router: Router): void { ); router.post<{ Params: { id: string } }>( "/v1/statuses/:id/unfavourite", - async (ctx, reply) => { + async (ctx) => { const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; const accessTokens = ctx.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -185,7 +179,7 @@ export function apiStatusMastodon(router: Router): void { router.post<{ Params: { id: string } }>( "/v1/statuses/:id/reblog", - async (ctx, reply) => { + async (ctx) => { const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; const accessTokens = ctx.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -202,7 +196,7 @@ export function apiStatusMastodon(router: Router): void { router.post<{ Params: { id: string } }>( "/v1/statuses/:id/unreblog", - async (ctx, reply) => { + async (ctx) => { const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; const accessTokens = ctx.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -219,7 +213,7 @@ export function apiStatusMastodon(router: Router): void { router.post<{ Params: { id: string } }>( "/v1/statuses/:id/bookmark", - async (ctx, reply) => { + async (ctx) => { const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; const accessTokens = ctx.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -236,7 +230,7 @@ export function apiStatusMastodon(router: Router): void { router.post<{ Params: { id: string } }>( "/v1/statuses/:id/unbookmark", - async (ctx, reply) => { + async (ctx) => { const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; const accessTokens = ctx.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -253,7 +247,7 @@ export function apiStatusMastodon(router: Router): void { router.post<{ Params: { id: string } }>( "/v1/statuses/:id/pin", - async (ctx, reply) => { + async (ctx) => { const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; const accessTokens = ctx.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -270,7 +264,7 @@ export function apiStatusMastodon(router: Router): void { router.post<{ Params: { id: string } }>( "/v1/statuses/:id/unpin", - async (ctx, reply) => { + async (ctx) => { const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; const accessTokens = ctx.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -284,51 +278,9 @@ export function apiStatusMastodon(router: Router): void { } }, ); - router.post("/v1/media", async (ctx, reply) => { - const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; - const accessTokens = ctx.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const multipartData = await ctx.file; - if (!multipartData) { - ctx.body = { error: "No image" }; - return; - } - const [path] = await createTemp(); - await pump(multipartData.buffer, fs.createWriteStream(path)); - const image = fs.readFileSync(path); - const data = await client.uploadMedia(image); - ctx.body = data.data; - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } - }); - router.post("/v2/media", async (ctx, reply) => { - const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; - const accessTokens = ctx.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const multipartData = await ctx.file; - if (!multipartData) { - ctx.body = { error: "No image" }; - return; - } - const [path] = await createTemp(); - await pump(multipartData.buffer, fs.createWriteStream(path)); - const image = fs.readFileSync(path); - const data = await client.uploadMedia(image); - 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, reply) => { + async (ctx) => { const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; const accessTokens = ctx.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -344,7 +296,7 @@ export function apiStatusMastodon(router: Router): void { ); router.put<{ Params: { id: string } }>( "/v1/media/:id", - async (ctx, reply) => { + async (ctx) => { const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; const accessTokens = ctx.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -363,7 +315,7 @@ export function apiStatusMastodon(router: Router): void { ); router.get<{ Params: { id: string } }>( "/v1/polls/:id", - async (ctx, reply) => { + async (ctx) => { const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; const accessTokens = ctx.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -379,7 +331,7 @@ export function apiStatusMastodon(router: Router): void { ); router.post<{ Params: { id: string } }>( "/v1/polls/:id/votes", - async (ctx, reply) => { + async (ctx) => { const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; const accessTokens = ctx.headers.authorization; const client = getClient(BASE_URL, accessTokens); diff --git a/packages/client/src/components/MkNote.vue b/packages/client/src/components/MkNote.vue index eaf0d159a..753e98b0d 100644 --- a/packages/client/src/components/MkNote.vue +++ b/packages/client/src/components/MkNote.vue @@ -163,7 +163,8 @@ const isDeleted = ref(false); const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords)); const translation = ref(null); const translating = ref(false); -const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null; +const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)).slice(0, 5) : null; +const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance); const keymap = { 'r': () => reply(true), diff --git a/packages/client/src/components/MkNoteDetailed.vue b/packages/client/src/components/MkNoteDetailed.vue index fa6b0bd89..864b27022 100644 --- a/packages/client/src/components/MkNoteDetailed.vue +++ b/packages/client/src/components/MkNoteDetailed.vue @@ -187,7 +187,7 @@ const isDeleted = ref(false); const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords)); const translation = ref(null); const translating = ref(false); -const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null; +const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)).slice(0, 5) : null; const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance); const conversation = ref