diff --git a/.gitignore b/.gitignore index a4e031399..d511fa5ad 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,8 @@ packages/backend/assets/instance.css *.blend3 *.blend4 *.blend5 + +#intelij stuff +packages/backend/.idea/backend.iml +packages/backend/.idea/modules.xml +packages/backend/.idea/vcs.xml diff --git a/locales/de-DE.yml b/locales/de-DE.yml index d7920b361..ea0fb8846 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -112,6 +112,7 @@ reactionSettingDescription2: "Ziehe um Anzuordnen, klicke um zu löschen, drück rememberNoteVisibility: "Notizsichtbarkeit merken" attachCancel: "Anhang entfernen" markAsSensitive: "Als NSFW markieren" +accountMoved: "Benutzer hat zu einem anderen Account gewechselt." unmarkAsSensitive: "Als nicht NSFW markieren" enterFileName: "Dateinamen eingeben" mute: "Stummschalten" diff --git a/locales/en-US.yml b/locales/en-US.yml index 0f3fff72e..37fa1051b 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -59,7 +59,7 @@ followRequestAccepted: "Follow request accepted" mention: "Mention" mentions: "Mentions" directNotes: "Direct notes" -importAndExport: "Import / Export" +importAndExport: "Import/Export Data" import: "Import" export: "Export" files: "Files" @@ -149,6 +149,7 @@ addAccount: "Add account" loginFailed: "Failed to sign in" showOnRemote: "View on remote instance" general: "General" +accountMoved: "User has moved to a new account:" wallpaper: "Wallpaper" setWallpaper: "Set wallpaper" removeWallpaper: "Remove wallpaper" @@ -920,6 +921,15 @@ swipeOnDesktop: "Allow mobile-style swiping on desktop" logoImageUrl: "Logo image URL" showAdminUpdates: "Indicate a new Calckey version is avaliable (admin only)" replayTutorial: "Replay tutorial" +migration: "Migration" +moveTo: "Move current account to new account" +moveToLabel: "Account you're moving to:" +moveAccount: "Move account!" +moveAccountDescription: "This process is irriversable. Make sure you've set up an alias for this account on your new account before moving. Please enter the tag of the account formatted like @person@instance.com" +moveFrom: "Move to this account from an older account" +moveFromLabel: "Account you're moving from:" +moveFromDescription: "This will set an alias of your old account so that you can move from that account to this current one. Please enter the tag of the account formatted like @person@instance.com" +migrationConfirm: "Are you absolutely sure you want to migrate your acccount to {account}? Once you do this, you won't be able to reverse it, and you won't be able to use your account normally again." _sensitiveMediaDetection: description: "Reduces the effort of server moderation through automatically recognizing NSFW media via Machine Learning. This will slightly increase the load on the server." diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index b045f5448..90820d43a 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -149,6 +149,7 @@ addAccount: "アカウントを追加" loginFailed: "ログインに失敗しました" showOnRemote: "リモートで表示" general: "全般" +accountMoved: "このユーザーは新しいアカウントに移行しました" wallpaper: "壁紙" setWallpaper: "壁紙を設定" removeWallpaper: "壁紙を削除" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index f1fa1ca23..ead4b4a6c 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -108,7 +108,7 @@ sensitive: "열람주의" add: "추가" reaction: "리액션" reactionSetting: "선택기에 표시할 리액션" -reactionSettingDescription2: "끌어서 순서 변경, 클릭해서 삭제, +를 눌러서 추가할 수 있습니다." +reactionSettingDescription2: "끌어서 순서 변경, 클릭해서 삭제, +를 눌러서 추가할 수 있습니다." rememberNoteVisibility: "공개 범위를 기억하기" attachCancel: "첨부 취소" markAsSensitive: "열람주의로 설정" diff --git a/package.json b/package.json index 99ce731a3..8d4a87bfc 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "start:test": "yarn workspace backend run start:test", "init": "yarn migrate", "migrate": "yarn workspace backend run migrate", + "revertmigration": "yarn workspace backend run revertmigration", "migrateandstart": "yarn migrate && yarn start", "gulp": "gulp build", "watch": "yarn dev", diff --git a/packages/backend/.idea/.gitignore b/packages/backend/.idea/.gitignore new file mode 100644 index 000000000..13566b81b --- /dev/null +++ b/packages/backend/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/packages/backend/migration/1669288094000-AddMovedToAndKnownAs.js b/packages/backend/migration/1669288094000-AddMovedToAndKnownAs.js new file mode 100644 index 000000000..8b3c770ac --- /dev/null +++ b/packages/backend/migration/1669288094000-AddMovedToAndKnownAs.js @@ -0,0 +1,16 @@ +export class addMovedToAndKnownAs1669288094000 { + name = 'addMovedToAndKnownAs1669288094000' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "movedToUri" character varying(512)`); + await queryRunner.query(`ALTER TABLE "user" ADD "alsoKnownAs" TEXT`); + await queryRunner.query(`COMMENT ON COLUMN "user"."movedToUri" IS 'The URI of the new account of the User'`); + await queryRunner.query(`COMMENT ON COLUMN "user"."alsoKnownAs" IS 'URIs the user is known as too'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "movedToUri"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "alsoKnownAs"`); + } + +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 2f7a11984..503631315 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -7,6 +7,7 @@ "start": "node ./built/index.js", "start:test": "NODE_ENV=test node ./built/index.js", "migrate": "typeorm migration:run -d ormconfig.js", + "revertmigration": "typeorm migration:revert -d ormconfig.js", "build": "tsc -p tsconfig.json || echo done. && tsc-alias -p tsconfig.json", "watch": "node watch.mjs", "lint": "eslint --quiet \"src/**/*.ts\"", diff --git a/packages/backend/src/misc/fetch-meta.ts b/packages/backend/src/misc/fetch-meta.ts index e855ac28e..3e74118f0 100644 --- a/packages/backend/src/misc/fetch-meta.ts +++ b/packages/backend/src/misc/fetch-meta.ts @@ -7,7 +7,7 @@ export async function fetchMeta(noCache = false): Promise { if (!noCache && cache) return cache; return await db.transaction(async transactionalEntityManager => { - // 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する + // New IDs are prioritized because multiple records may have been created due to past bugs. const metas = await transactionalEntityManager.find(Meta, { order: { id: 'DESC', @@ -20,7 +20,7 @@ export async function fetchMeta(noCache = false): Promise { cache = meta; return meta; } else { - // metaが空のときfetchMetaが同時に呼ばれるとここが同時に呼ばれてしまうことがあるのでフェイルセーフなupsertを使う + // If fetchMeta is called at the same time when meta is empty, this part may be called at the same time, so use fail-safe upsert. const saved = await transactionalEntityManager .upsert( Meta, diff --git a/packages/backend/src/models/entities/user.ts b/packages/backend/src/models/entities/user.ts index bc9446be4..3e406bdd7 100644 --- a/packages/backend/src/models/entities/user.ts +++ b/packages/backend/src/models/entities/user.ts @@ -68,6 +68,19 @@ export class User { }) public followingCount: number; + @Column('varchar', { + length: 512, + nullable: true, + comment: 'The URI of the new account of the User', + }) + public movedToUri: string | null; + + @Column('simple-array', { + nullable: true, + comment: 'URIs the user is known as too', + }) + public alsoKnownAs: string[] | null; + @Column('integer', { default: 0, comment: 'The count of notes.', diff --git a/packages/backend/src/models/repositories/user.ts b/packages/backend/src/models/repositories/user.ts index 5c46ae27a..138929c4b 100644 --- a/packages/backend/src/models/repositories/user.ts +++ b/packages/backend/src/models/repositories/user.ts @@ -1,22 +1,49 @@ -import { EntityRepository, Repository, In, Not } from 'typeorm'; +import { URL } from 'url'; +import { In, Not } from 'typeorm'; import Ajv from 'ajv'; -import { User, ILocalUser, IRemoteUser } from '@/models/entities/user.js'; +import type { ILocalUser, IRemoteUser } from '@/models/entities/user.js'; +import { User } from '@/models/entities/user.js'; import config from '@/config/index.js'; -import { Packed } from '@/misc/schema.js'; -import { awaitAll, Promiseable } from '@/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { Promiseable } from '@/prelude/await-all.js'; +import { awaitAll } from '@/prelude/await-all.js'; import { populateEmojis } from '@/misc/populate-emojis.js'; import { getAntennas } from '@/misc/antenna-cache.js'; import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js'; import { Cache } from '@/misc/cache.js'; import { db } from '@/db/postgre.js'; -import { Instance } from '../entities/instance.js'; -import { Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages, Announcements, AnnouncementReads, Antennas, AntennaNotes, ChannelFollowings, Instances, DriveFiles } from '../index.js'; +import { isActor, getApId } from '@/remote/activitypub/type.js'; +import DbResolver from '@/remote/activitypub/db-resolver.js'; +import Resolver from '@/remote/activitypub/resolver.js'; +import { createPerson } from '@/remote/activitypub/models/person.js'; +import { + AnnouncementReads, + Announcements, + AntennaNotes, + Blockings, + ChannelFollowings, + DriveFiles, + Followings, + FollowRequests, + Instances, + MessagingMessages, + Mutings, + Notes, + NoteUnreads, + Notifications, + Pages, + UserGroupJoinings, + UserNotePinings, + UserProfiles, + UserSecurityKeys, +} from '../index.js'; +import type { Instance } from '../entities/instance.js'; const userInstanceCache = new Cache(1000 * 60 * 60 * 3); type IsUserDetailed = Detailed extends true ? Packed<'UserDetailed'> : Packed<'UserLite'>; type IsMeAndIsUserDetailed = - Detailed extends true ? + Detailed extends true ? ExpectsMe extends true ? Packed<'MeDetailed'> : ExpectsMe extends false ? Packed<'UserDetailedNotMe'> : Packed<'UserDetailed'> : @@ -33,12 +60,24 @@ const birthdaySchema = { type: 'string', pattern: /^([0-9]{4})-([0-9]{2})-([0-9] function isLocalUser(user: User): user is ILocalUser; function isLocalUser(user: T): user is T & { host: null; }; +/** + * Returns true if the user is local. + * + * @param user The user to check. + * @returns True if the user is local. + */ function isLocalUser(user: User | { host: User['host'] }): boolean { return user.host == null; } function isRemoteUser(user: User): user is IRemoteUser; function isRemoteUser(user: T): user is T & { host: string; }; +/** + * Returns true if the user is remote. + * + * @param user The user to check. + * @returns True if the user is remote. + */ function isRemoteUser(user: User | { host: User['host'] }): boolean { return !isLocalUser(user); } @@ -156,6 +195,27 @@ export const UserRepository = db.getRepository(User).extend({ return count > 0; }, + async userFromURI(uri: string): Promise { + const dbResolver = new DbResolver(); + let local = await dbResolver.getUserFromApId(uri); + if (local) { + return local; + } + + // fetching Object once from remote + const resolver = new Resolver(); + const object = await resolver.resolve(uri) as any; + + // /@user If a URI other than the id is specified, + // the URI is determined here + if (uri !== object.id) { + local = await dbResolver.getUserFromApId(object.id); + if (local != null) return local; + } + + return isActor(object) ? await createPerson(getApId(object)) : null; + }, + async getHasUnreadAntenna(userId: User['id']): Promise { const myAntennas = (await getAntennas()).filter(a => a.userId === userId); @@ -320,6 +380,8 @@ export const UserRepository = db.getRepository(User).extend({ ...(opts.detail ? { url: profile!.url, uri: user.uri, + movedToUri: user.movedToUri ? await this.userFromURI(user.movedToUri) : null, + alsoKnownAs: user.alsoKnownAs, createdAt: user.createdAt.toISOString(), updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null, lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null, diff --git a/packages/backend/src/models/schema/user.ts b/packages/backend/src/models/schema/user.ts index 218d861e5..b70210a0f 100644 --- a/packages/backend/src/models/schema/user.ts +++ b/packages/backend/src/models/schema/user.ts @@ -96,6 +96,16 @@ export const packedUserDetailedNotMeOnlySchema = { format: 'uri', nullable: true, optional: false, }, + movedToUri: { + type: 'string', + format: 'uri', + nullable: true, optional: false, + }, + alsoKnownAs: { + type: 'array', + format: 'uri', + nullable: true, optional: false, + }, createdAt: { type: 'string', nullable: false, optional: false, diff --git a/packages/backend/src/queue/processors/inbox.ts b/packages/backend/src/queue/processors/inbox.ts index 420d74bb0..33949672c 100644 --- a/packages/backend/src/queue/processors/inbox.ts +++ b/packages/backend/src/queue/processors/inbox.ts @@ -20,7 +20,7 @@ import { UserPublickey } from '@/models/entities/user-publickey.js'; const logger = new Logger('inbox'); -// ユーザーのinboxにアクティビティが届いた時の処理 +// Processing when an activity arrives in the user's inbox export default async (job: Bull.Job): Promise => { const signature = job.data.signature; // HTTP-signature const activity = job.data.activity; @@ -30,16 +30,15 @@ export default async (job: Bull.Job): Promise => { delete info['@context']; logger.debug(JSON.stringify(info, null, 2)); //#endregion - const host = toPuny(new URL(signature.keyId).hostname); - // ブロックしてたら中断 + // interrupt if blocked const meta = await fetchMeta(); if (meta.blockedHosts.includes(host)) { return `Blocked request: ${host}`; } - // 非公開モードなら許可なインスタンスのみ + // only whitelisted instances in private mode if (meta.privateMode && !meta.allowedHosts.includes(host)) { return `Blocked request: ${host}`; } @@ -51,7 +50,7 @@ export default async (job: Bull.Job): Promise => { const dbResolver = new DbResolver(); - // HTTP-Signature keyIdを元にDBから取得 + // HTTP-Signature keyId from DB let authUser: { user: CacheableRemoteUser; key: UserPublickey | null; @@ -62,7 +61,7 @@ export default async (job: Bull.Job): Promise => { try { authUser = await dbResolver.getAuthUserFromApId(getApId(activity.actor)); } catch (e) { - // 対象が4xxならスキップ + // Skip if target is 4xx if (e instanceof StatusError) { if (e.isClientError) { return `skip: Ignored deleted actors on both ends ${activity.actor} - ${e.statusCode}`; diff --git a/packages/backend/src/remote/activitypub/kernel/block/index.ts b/packages/backend/src/remote/activitypub/kernel/block/index.ts index 5e230ad7b..c8b60f7b9 100644 --- a/packages/backend/src/remote/activitypub/kernel/block/index.ts +++ b/packages/backend/src/remote/activitypub/kernel/block/index.ts @@ -5,7 +5,7 @@ import DbResolver from '../../db-resolver.js'; import { Users } from '@/models/index.js'; export default async (actor: CacheableRemoteUser, activity: IBlock): Promise => { - // ※ activity.objectにブロック対象があり、それは存在するローカルユーザーのはず + // ※ There is a block target in activity.object, which should be a local user that exists. const dbResolver = new DbResolver(); const blockee = await dbResolver.getUserFromApId(activity.object); @@ -15,7 +15,7 @@ export default async (actor: CacheableRemoteUser, activity: IBlock): Promise => { + // ※ There is a block target in activity.object, which should be a local user that exists. + + const dbResolver = new DbResolver(); + const resolver = new Resolver(); + let new_acc = await dbResolver.getUserFromApId(activity.target); + let actor_new; + if (!new_acc) actor_new = await resolver.resolve(activity.target) as IActor; + + if ((!new_acc || new_acc.uri === null) && (!actor_new || actor_new.id === null)) { + return 'move: new acc not found'; + } + + let newUri: string | null | undefined + newUri = new_acc ? new_acc.uri : + actor_new?.url?.toString(); + + if(newUri === null || newUri === undefined) return 'move: new acc not found #2'; + + await updatePerson(newUri); + await updatePerson(actor.uri!); + + new_acc = await dbResolver.getUserFromApId(newUri); + let old = await dbResolver.getUserFromApId(actor.uri!); + + if (old === null || old.uri === null || !new_acc?.alsoKnownAs?.includes(old.uri)) return 'move: accounts invalid'; + + old.movedToUri = new_acc.uri; + + const followee = await getUser(actor.id).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + const followeeNew = await getUser(new_acc.id).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + const followings = await Followings.findBy({ + followeeId: followee.id, + }); + + //TODO remove this + console.log(followings); + + followings.forEach(async following => { + //if follower is local + if (!following.followerHost) { + const follower = await getUser(following.followerId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + await deleteFollowing(follower!, followee); + try { + await create(follower!, followeeNew); + } catch (e) { + if (e instanceof IdentifiableError) { + if (e.id === '710e8fb0-b8c3-4922-be49-d5d93d8e6a6e') return meta.errors.blocking; + if (e.id === '3338392a-f764-498d-8855-db939dcf8c48') return meta.errors.blocked; + } + return e; + } + } + }); + + return 'ok'; +}; diff --git a/packages/backend/src/remote/activitypub/models/person.ts b/packages/backend/src/remote/activitypub/models/person.ts index 5ef04588e..21ef83af7 100644 --- a/packages/backend/src/remote/activitypub/models/person.ts +++ b/packages/backend/src/remote/activitypub/models/person.ts @@ -172,6 +172,8 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise { if (typeof uri !== 'string') throw new Error('uri is not string'); - // URIがこのサーバーを指しているならスキップ + // Skip if the URI points to this server if (uri.startsWith(config.url + '/')) { return; } - //#region このサーバーに既に登録されているか + //#region Already registered on this server? const exist = await Users.findOneBy({ uri }) as IRemoteUser; if (exist == null) { @@ -307,7 +309,7 @@ export async function updatePerson(uri: string, resolver?: Resolver | null, hint logger.info(`Updating the Person: ${person.id}`); - // アバターとヘッダー画像をフェッチ + // Fetch avatar and header image const [avatar, banner] = await Promise.all([ person.icon, person.image, @@ -317,7 +319,7 @@ export async function updatePerson(uri: string, resolver?: Resolver | null, hint : resolveImage(exist, img).catch(() => null), )); - // カスタム絵文字取得 + // Custom pictogram acquisition const emojis = await extractEmojis(person.tag || [], exist.host).catch(e => { logger.info(`extractEmojis: ${e}`); return [] as Emoji[]; @@ -343,6 +345,8 @@ export async function updatePerson(uri: string, resolver?: Resolver | null, hint isBot: getApType(object) === 'Service', isCat: (person as any).isCat === true, isLocked: !!person.manuallyApprovesFollowers, + movedToUri: person.movedTo, + alsoKnownAs: person.alsoKnownAs, isExplorable: !!person.discoverable, } as Partial; @@ -374,10 +378,10 @@ export async function updatePerson(uri: string, resolver?: Resolver | null, hint publishInternalEvent('remoteUserUpdated', { id: exist.id }); - // ハッシュタグ更新 + // Hashtag Update updateUsertags(exist, tags); - // 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする + // If the user in question is a follower, followers will also be updated. await Followings.update({ followerId: exist.id, }, { @@ -388,15 +392,15 @@ export async function updatePerson(uri: string, resolver?: Resolver | null, hint } /** - * Personを解決します。 + * Resolve Person. * - * Misskeyに対象のPersonが登録されていればそれを返し、そうでなければ - * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 + * If the target person is registered in Calckey, it returns it; + * otherwise, it fetches it from the remote server, registers it in Calckey, and returns it. */ export async function resolvePerson(uri: string, resolver?: Resolver): Promise { if (typeof uri !== 'string') throw new Error('uri is not string'); - //#region このサーバーに既に登録されていたらそれを返す + //#region If already registered on this server, return it. const exist = await fetchPerson(uri); if (exist) { @@ -404,7 +408,7 @@ export async function resolvePerson(uri: string, resolver?: Resolver): Promise(2); const featuredNotes = await Promise.all(items - .filter(item => getApType(item) === 'Note') // TODO: Noteでなくてもいいかも + .filter(item => getApType(item) === 'Note') // TODO: Maybe it doesn't have to be a Note. .slice(0, 5) .map(item => limit(() => resolveNote(item, resolver)))); await db.transaction(async transactionalEntityManager => { await transactionalEntityManager.delete(UserNotePining, { userId: user.id }); - // とりあえずidを別の時間で生成して順番を維持 + // For now, generate the id at a different time and maintain the order. let td = 0; for (const note of featuredNotes.filter(note => note != null)) { td -= 1000; diff --git a/packages/backend/src/remote/activitypub/renderer/index.ts b/packages/backend/src/remote/activitypub/renderer/index.ts index f100b77ce..2e1fbf1dd 100644 --- a/packages/backend/src/remote/activitypub/renderer/index.ts +++ b/packages/backend/src/remote/activitypub/renderer/index.ts @@ -1,9 +1,9 @@ -import config from '@/config/index.js'; import { v4 as uuid } from 'uuid'; -import { IActivity } from '../type.js'; -import { LdSignature } from '../misc/ld-signature.js'; +import config from '@/config/index.js'; import { getUserKeypair } from '@/misc/keypair-store.js'; -import { User } from '@/models/entities/user.js'; +import type { User } from '@/models/entities/user.js'; +import { LdSignature } from '../misc/ld-signature.js'; +import type { IActivity } from '../type.js'; export const renderActivity = (x: any): IActivity | null => { if (x == null) return null; @@ -19,6 +19,7 @@ export const renderActivity = (x: any): IActivity | null => { { // as non-standards manuallyApprovesFollowers: 'as:manuallyApprovesFollowers', + movedToUri: 'as:movedTo', sensitive: 'as:sensitive', Hashtag: 'as:Hashtag', quoteUrl: 'as:quoteUrl', diff --git a/packages/backend/src/remote/activitypub/renderer/person.ts b/packages/backend/src/remote/activitypub/renderer/person.ts index cd2fd74d4..a5d076251 100644 --- a/packages/backend/src/remote/activitypub/renderer/person.ts +++ b/packages/backend/src/remote/activitypub/renderer/person.ts @@ -1,16 +1,16 @@ import { URL } from 'node:url'; import * as mfm from 'mfm-js'; -import renderImage from './image.js'; -import renderKey from './key.js'; import config from '@/config/index.js'; -import { ILocalUser } from '@/models/entities/user.js'; -import { toHtml } from '../../../mfm/to-html.js'; -import { getEmojis } from './note.js'; -import renderEmoji from './emoji.js'; -import { IIdentifier } from '../models/identifier.js'; -import renderHashtag from './hashtag.js'; +import type { ILocalUser } from '@/models/entities/user.js'; import { DriveFiles, UserProfiles } from '@/models/index.js'; import { getUserKeypair } from '@/misc/keypair-store.js'; +import { toHtml } from '../../../mfm/to-html.js'; +import renderImage from './image.js'; +import renderKey from './key.js'; +import { getEmojis } from './note.js'; +import renderEmoji from './emoji.js'; +import renderHashtag from './hashtag.js'; +import type { IIdentifier } from '../models/identifier.js'; export async function renderPerson(user: ILocalUser) { const id = `${config.url}/users/${user.id}`; @@ -71,17 +71,18 @@ export async function renderPerson(user: ILocalUser) { image: banner ? renderImage(banner) : null, tag, manuallyApprovesFollowers: user.isLocked, + movedToUri: user.movedToUri, discoverable: !!user.isExplorable, - publicKey: renderKey(user, keypair, `#main-key`), + publicKey: renderKey(user, keypair, '#main-key'), isCat: user.isCat, attachment: attachment.length ? attachment : undefined, } as any; - if (profile?.birthday) { + if (profile.birthday) { person['vcard:bday'] = profile.birthday; } - if (profile?.location) { + if (profile.location) { person['vcard:Address'] = profile.location; } diff --git a/packages/backend/src/remote/activitypub/type.ts b/packages/backend/src/remote/activitypub/type.ts index de7eb0ed8..aabbd0679 100644 --- a/packages/backend/src/remote/activitypub/type.ts +++ b/packages/backend/src/remote/activitypub/type.ts @@ -156,9 +156,11 @@ export interface IActor extends IObject { name?: string; preferredUsername?: string; manuallyApprovesFollowers?: boolean; + movedTo?: string; + alsoKnownAs?: string[]; discoverable?: boolean; inbox: string; - sharedInbox?: string; // 後方互換性のため + sharedInbox?: string; // backward compatibility.. ig publicKey?: { id: string; publicKeyPem: string; @@ -279,6 +281,11 @@ export interface IFlag extends IActivity { type: 'Flag'; } +export interface IMove extends IActivity { + type: 'Move'; + target: IObject | string; +} + export const isCreate = (object: IObject): object is ICreate => getApType(object) === 'Create'; export const isDelete = (object: IObject): object is IDelete => getApType(object) === 'Delete'; export const isUpdate = (object: IObject): object is IUpdate => getApType(object) === 'Update'; @@ -293,3 +300,4 @@ export const isLike = (object: IObject): object is ILike => getApType(object) == export const isAnnounce = (object: IObject): object is IAnnounce => getApType(object) === 'Announce'; export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block'; export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag'; +export const isMove = (object: IObject): object is IMove => getApType(object) === 'Move'; diff --git a/packages/backend/src/remote/resolve-user.ts b/packages/backend/src/remote/resolve-user.ts index 6fc6f2c4d..b44981bb8 100644 --- a/packages/backend/src/remote/resolve-user.ts +++ b/packages/backend/src/remote/resolve-user.ts @@ -49,9 +49,9 @@ export async function resolveUser(username: string, host: string | null): Promis return await createPerson(self.href); } - // ユーザー情報が古い場合は、WebFilgerからやりなおして返す + // If user information is out of date, return it by starting over from WebFilger if (user.lastFetchedAt == null || Date.now() - user.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { - // 繋がらないインスタンスに何回も試行するのを防ぐ, 後続の同様処理の連続試行を防ぐ ため 試行前にも更新する + // Prevent multiple attempts to connect to unconnected instances, update before each attempt to prevent subsequent similar attempts await Users.update(user.id, { lastFetchedAt: new Date(), }); diff --git a/packages/backend/src/server/activitypub.ts b/packages/backend/src/server/activitypub.ts index 250a39bf0..782915756 100644 --- a/packages/backend/src/server/activitypub.ts +++ b/packages/backend/src/server/activitypub.ts @@ -38,6 +38,7 @@ function inbox(ctx: Router.RouterContext) { return; } + // @ts-ignore processInbox(ctx.request.body, signature); ctx.status = 202; @@ -86,7 +87,7 @@ router.get('/notes/:note', async (ctx, next) => { return; } - // リモートだったらリダイレクト + // redirect if remote if (note.userHost != null) { if (note.uri == null || isSelfHost(note.userHost)) { ctx.status = 500; diff --git a/packages/backend/src/server/activitypub/following.ts b/packages/backend/src/server/activitypub/following.ts index f843d79f0..26cee819b 100644 --- a/packages/backend/src/server/activitypub/following.ts +++ b/packages/backend/src/server/activitypub/following.ts @@ -61,7 +61,7 @@ export default async (ctx: Router.RouterContext) => { followerId: user.id, } as FindOptionsWhere; - // カーソルが指定されている場合 + // If a cursor is specified if (cursor) { query.id = LessThan(cursor); } @@ -73,7 +73,7 @@ export default async (ctx: Router.RouterContext) => { order: { id: -1 }, }); - // 「次のページ」があるかどうか + // Whether there is a "next page" or not const inStock = followings.length === limit + 1; if (inStock) followings.pop(); diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index d56dd316e..912998376 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -325,6 +325,10 @@ import * as ep___users_stats from './endpoints/users/stats.js'; import * as ep___fetchRss from './endpoints/fetch-rss.js'; import * as ep___admin_driveCapOverride from './endpoints/admin/drive-capacity-override.js'; +//Calckey Move +import * as ep___i_move from './endpoints/i/move.js'; +import * as ep___i_known_as from './endpoints/i/known-as.js'; + const eps = [ ['admin/meta', ep___admin_meta], ['admin/abuse-user-reports', ep___admin_abuseUserReports], @@ -489,6 +493,8 @@ const eps = [ ['hashtags/trend', ep___hashtags_trend], ['hashtags/users', ep___hashtags_users], ['i', ep___i], + ['i/known-as', ep___i_known_as], + ['i/move', ep___i_move], ['i/2fa/done', ep___i_2fa_done], ['i/2fa/key-done', ep___i_2fa_keyDone], ['i/2fa/password-less', ep___i_2fa_passwordLess], diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index 7426daeec..b65f5d078 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -88,10 +88,10 @@ export default define(meta, paramDef, async (ps, me) => { }); /*** - * URIからUserかNoteを解決する + * Resolve User or Note from URI */ async function fetchAny(uri: string, me: CacheableLocalUser | null | undefined): Promise | null> { - // ブロックしてたら中断 + // Wait if blocked. const fetchedMeta = await fetchMeta(); if (fetchedMeta.blockedHosts.includes(extractDbHost(uri))) return null; @@ -103,12 +103,12 @@ async function fetchAny(uri: string, me: CacheableLocalUser | null | undefined): ])); if (local != null) return local; - // リモートから一旦オブジェクトフェッチ + // fetching Object once from remote const resolver = new Resolver(); const object = await resolver.resolve(uri) as any; - // /@user のような正規id以外で取得できるURIが指定されていた場合、ここで初めて正規URIが確定する - // これはDBに存在する可能性があるため再度DB検索 + // /@user If a URI other than the id is specified, + // the URI is determined here if (uri !== object.id) { local = await mergePack(me, ...await Promise.all([ dbResolver.getUserFromApId(object.id), diff --git a/packages/backend/src/server/api/endpoints/federation/instances.ts b/packages/backend/src/server/api/endpoints/federation/instances.ts index 84740bc92..41750f13e 100644 --- a/packages/backend/src/server/api/endpoints/federation/instances.ts +++ b/packages/backend/src/server/api/endpoints/federation/instances.ts @@ -6,7 +6,7 @@ import { fetchMeta } from '@/misc/fetch-meta.js'; export const meta = { tags: ['federation'], - requireCredential: true, + requireCredential: false, requireCredentialPrivateMode: true, res: { diff --git a/packages/backend/src/server/api/endpoints/i/known-as.ts b/packages/backend/src/server/api/endpoints/i/known-as.ts new file mode 100644 index 000000000..924d74de8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/known-as.ts @@ -0,0 +1,85 @@ +import { In } from 'typeorm'; +import { User } from '@/models/entities/user.js'; +import { Users, DriveFiles, Notes, Channels, Blockings } from '@/models/index.js'; +import { resolveUser } from '@/remote/resolve-user.js'; +import { ApiError } from '../../error.js'; +import acceptAllFollowRequests from '@/services/following/requests/accept-all.js'; +import { publishToFollowers } from '@/services/i/update.js'; +import { apiLogger } from '../../logger.js'; +import { publishMainStream, publishUserEvent } from '@/services/stream.js'; +import define from '../../define.js'; +import { DAY } from '@/const.js'; + +export const meta = { + tags: ['users'], + + secure: true, + requireCredential: true, + + limit: { + duration: DAY, + max: 30, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5', + }, + notRemote: { + message: 'User not remote.', + code: 'NOT_REMOTE', + id: '4362f8dc-731f-4ad8-a694-be2a88922a24', + }, + } +} as const; + +export const paramDef = { + type: 'object', + properties: { + alsoKnownAs: { type: 'string' }, + }, + required: ['alsoKnownAs'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + + if(!ps.alsoKnownAs) throw new ApiError(meta.errors.noSuchUser); + + let unfiltered: string = ps.alsoKnownAs; + + if(unfiltered.startsWith('@')) unfiltered = unfiltered.substring(1); + if(!unfiltered.includes('@')) throw new ApiError(meta.errors.notRemote); + + let userAddress: string[] = unfiltered.split("@"); + + const knownAs: User = await resolveUser(userAddress[0], userAddress[1]).catch(e => { + apiLogger.warn(`failed to resolve remote user: ${e}`); + throw new ApiError(meta.errors.noSuchUser); + }); + + const updates = {} as Partial; + + if(!knownAs.uri) knownAs.uri = ""; + updates.alsoKnownAs = [knownAs.uri]; + + await Users.update(user.id, updates); + + const iObj = await Users.pack(user.id, user, { + detail: true, + includeSecrets: true, + }); + + // Publish meUpdated event + publishMainStream(user.id, 'meUpdated', iObj); + + if (user.isLocked === false) { + acceptAllFollowRequests(user); + } + + publishToFollowers(user.id); + + return iObj; +}); diff --git a/packages/backend/src/server/api/endpoints/i/move.ts b/packages/backend/src/server/api/endpoints/i/move.ts new file mode 100644 index 000000000..562d35007 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/move.ts @@ -0,0 +1,97 @@ +import type { User } from '@/models/entities/user.js'; +import { resolveUser } from '@/remote/resolve-user.js'; +import { DAY } from '@/const.js'; +import DeliverManager from '@/remote/activitypub/deliver-manager.js'; +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import type { IActivity } from '@/remote/activitypub/type.js'; +import define from '../../define.js'; +import { ApiError } from '../../error.js'; +import { apiLogger } from '../../logger.js'; + +export const meta = { + tags: ['users'], + + secure: true, + requireCredential: true, + + limit: { + duration: DAY, + max: 1, + }, + + errors: { + noSuchMoveTarget: { + message: 'No such move target.', + code: 'NO_SUCH_MOVE_TARGET', + id: 'b5c90186-4ab0-49c8-9bba-a1f76c202ba4', + }, + remoteAccountForbids: { + message: 'Remote account doesn\'t have proper known As.', + code: 'REMOTE_ACCOUNT_FORBIDS', + id: 'b5c90186-4ab0-49c8-9bba-a1f766282ba4', + }, + notRemote: { + message: 'User not remote.', + code: 'NOT_REMOTE', + id: '4362f8dc-731f-4ad8-a694-be2a88922a24', + }, + adminForbidden: { + message: 'Adminds cant migrate.', + code: 'NOT_ADMIN_FORBIDDEN', + id: '4362e8dc-731f-4ad8-a694-be2a88922a24', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + moveToAccount: { type: 'string' }, + }, + required: ['moveToAccount'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + if (!ps.moveToAccount) throw new ApiError(meta.errors.noSuchMoveTarget); + if(user.isAdmin) throw new ApiError(meta.errors.adminForbidden); + + let unfiltered: string = ps.moveToAccount; + + if (unfiltered.startsWith('@')) unfiltered = unfiltered.substring(1); + if (!unfiltered.includes('@')) throw new ApiError(meta.errors.notRemote); + const userAddress: string[] = unfiltered.split('@'); + + const moveTo: User = await resolveUser(userAddress[0], userAddress[1]).catch(e => { + apiLogger.warn(`failed to resolve remote user: ${e}`); + throw new ApiError(meta.errors.noSuchMoveTarget); + }); + + let allowed = false; + + moveTo.alsoKnownAs?.forEach(element => { + if (user.uri?.includes(element)) allowed = true; + }); + + if (!allowed || !moveTo.uri || !user.uri) throw new ApiError(meta.errors.remoteAccountForbids); + + (async (): Promise => { + const moveAct = await moveActivity(moveTo.uri!, user.uri!); + const dm = new DeliverManager(user, moveAct); + dm.addFollowersRecipe(); + dm.execute(); + })(); + return true; +}); + +async function moveActivity(to: string, from: string): Promise { + const activity = { + id: 'foo', + actor: from, + type: 'Move', + object: from, + target: to, + } as any; + + return renderActivity(activity); +} diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 82540f96b..68c04ff22 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -78,6 +78,12 @@ export const meta = { code: 'YOU_HAVE_BEEN_BLOCKED', id: 'b390d7e1-8a5e-46ed-b625-06271cafd3d3', }, + + accountLocked: { + message: 'You migrated. Your account is now locked.', + code: 'ACCOUNT_LOCKED', + id: 'd390d7e1-8a5e-46ed-b625-06271cafd3d3', + }, }, } as const; @@ -163,6 +169,7 @@ export const paramDef = { // eslint-disable-next-line import/no-default-export export default define(meta, paramDef, async (ps, user) => { + if(user.movedToUri) throw new ApiError(meta.errors.accountLocked); let visibleUsers: User[] = []; if (ps.visibleUserIds) { visibleUsers = await Users.findBy({ @@ -250,7 +257,7 @@ export default define(meta, paramDef, async (ps, user) => { } } - // 投稿を作成 + // Create a post const note = await create(user, { createdAt: new Date(), files: files, diff --git a/packages/backend/src/server/index.ts b/packages/backend/src/server/index.ts index f31de2b7f..ae236ce34 100644 --- a/packages/backend/src/server/index.ts +++ b/packages/backend/src/server/index.ts @@ -19,7 +19,7 @@ import { genIdenticon } from '@/misc/gen-identicon.js'; 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 { envOption } from '@/env.js'; import activityPub from './activitypub.js'; import nodeinfo from './nodeinfo.js'; import wellKnown from './well-known.js'; @@ -164,5 +164,6 @@ export default () => new Promise(resolve => { } }); + // @ts-ignore server.listen(config.port, resolve); }); diff --git a/packages/backend/src/server/nodeinfo.ts b/packages/backend/src/server/nodeinfo.ts index b4216d9d9..0bb9e05c5 100644 --- a/packages/backend/src/server/nodeinfo.ts +++ b/packages/backend/src/server/nodeinfo.ts @@ -11,11 +11,11 @@ const router = new Router(); const nodeinfo2_1path = '/nodeinfo/2.1'; const nodeinfo2_0path = '/nodeinfo/2.0'; -export const links = [/* (awaiting release) { - rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1', +export const links = [{ + rel: 'https://nodeinfo.diaspora.software/ns/schema/2.1', href: config.url + nodeinfo2_1path -}, */{ - rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0', +}, { + rel: 'https://nodeinfo.diaspora.software/ns/schema/2.0', href: config.url + nodeinfo2_0path, }]; @@ -96,6 +96,7 @@ router.get(nodeinfo2_1path, async ctx => { router.get(nodeinfo2_0path, async ctx => { const base = await cache.fetch(null, () => nodeinfo2()); + // @ts-ignore delete base.software.repository; ctx.body = { version: '2.0', ...base }; diff --git a/packages/backend/src/services/note/create.ts b/packages/backend/src/services/note/create.ts index 8ba2963d5..fa8ba6bf1 100644 --- a/packages/backend/src/services/note/create.ts +++ b/packages/backend/src/services/note/create.ts @@ -127,8 +127,8 @@ type Option = { }; export default async (user: { id: User['id']; username: User['username']; host: User['host']; isSilenced: User['isSilenced']; createdAt: User['createdAt']; }, data: Option, silent = false) => new Promise(async (res, rej) => { - // チャンネル外にリプライしたら対象のスコープに合わせる - // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) + // If you reply outside the channel, match the scope of the target. + // TODO (I think it's a process that could be done on the client side, but it's server side for now.) if (data.reply && data.channel && data.reply.channelId !== data.channel.id) { if (data.reply.channelId) { data.channel = await Channels.findOneBy({ id: data.reply.channelId }); @@ -137,8 +137,8 @@ export default async (user: { id: User['id']; username: User['username']; host: } } - // チャンネル内にリプライしたら対象のスコープに合わせる - // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) + // When you reply in a channel, match the scope of the target + // TODO (I think it's a process that could be done on the client side, but it's server side for now.) if (data.reply && (data.channel == null) && data.reply.channelId) { data.channel = await Channels.findOneBy({ id: data.reply.channelId }); } @@ -150,37 +150,37 @@ export default async (user: { id: User['id']; username: User['username']; host: if (data.channel != null) data.visibleUsers = []; if (data.channel != null) data.localOnly = true; - // サイレンス + // enforce silent clients on server if (user.isSilenced && data.visibility === 'public' && data.channel == null) { data.visibility = 'home'; } - // Renote対象が「ホームまたは全体」以外の公開範囲ならreject + // Reject if the target of the renote is a public range other than "Home or Entire". if (data.renote && data.renote.visibility !== 'public' && data.renote.visibility !== 'home' && data.renote.userId !== user.id) { return rej('Renote target is not public or home'); } - // Renote対象がpublicではないならhomeにする + // If the target of the renote is not public, make it home. if (data.renote && data.renote.visibility !== 'public' && data.visibility === 'public') { data.visibility = 'home'; } - // Renote対象がfollowersならfollowersにする + // If the target of Renote is followers, make it followers. if (data.renote && data.renote.visibility === 'followers') { data.visibility = 'followers'; } - // 返信対象がpublicではないならhomeにする + // If the reply target is not public, make it home. if (data.reply && data.reply.visibility !== 'public' && data.visibility === 'public') { data.visibility = 'home'; } - // ローカルのみをRenoteしたらローカルのみにする + // Renote local only if you Renote local only. if (data.renote && data.renote.localOnly && data.channel == null) { data.localOnly = true; } - // ローカルのみにリプライしたらローカルのみにする + // If you reply to local only, make it local only. if (data.reply && data.reply.localOnly && data.channel == null) { data.localOnly = true; } diff --git a/packages/client/src/components/MkInfo.vue b/packages/client/src/components/MkInfo.vue index 98dfef967..0790e58e3 100644 --- a/packages/client/src/components/MkInfo.vue +++ b/packages/client/src/components/MkInfo.vue @@ -9,7 +9,7 @@ diff --git a/packages/client/src/components/MkMoved.vue b/packages/client/src/components/MkMoved.vue new file mode 100644 index 000000000..7789af001 --- /dev/null +++ b/packages/client/src/components/MkMoved.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/packages/client/src/components/MkRemoteCaution.vue b/packages/client/src/components/MkRemoteCaution.vue index eeb097dec..4c141b7d4 100644 --- a/packages/client/src/components/MkRemoteCaution.vue +++ b/packages/client/src/components/MkRemoteCaution.vue @@ -12,7 +12,6 @@ defineProps<{