diff options
Diffstat (limited to 'src/remote/activitypub/models/person.ts')
| -rw-r--r-- | src/remote/activitypub/models/person.ts | 494 |
1 files changed, 0 insertions, 494 deletions
diff --git a/src/remote/activitypub/models/person.ts b/src/remote/activitypub/models/person.ts deleted file mode 100644 index eb8c00a10b..0000000000 --- a/src/remote/activitypub/models/person.ts +++ /dev/null @@ -1,494 +0,0 @@ -import { URL } from 'url'; -import * as promiseLimit from 'promise-limit'; - -import $, { Context } from 'cafy'; -import config from '@/config/index'; -import Resolver from '../resolver'; -import { resolveImage } from './image'; -import { isCollectionOrOrderedCollection, isCollection, IActor, getApId, getOneApHrefNullable, IObject, isPropertyValue, IApPropertyValue, getApType, isActor } from '../type'; -import { fromHtml } from '../../../mfm/from-html'; -import { htmlToMfm } from '../misc/html-to-mfm'; -import { resolveNote, extractEmojis } from './note'; -import { registerOrFetchInstanceDoc } from '@/services/register-or-fetch-instance-doc'; -import { extractApHashtags } from './tag'; -import { apLogger } from '../logger'; -import { Note } from '@/models/entities/note'; -import { updateUsertags } from '@/services/update-hashtag'; -import { Users, Instances, DriveFiles, Followings, UserProfiles, UserPublickeys } from '@/models/index'; -import { User, IRemoteUser } from '@/models/entities/user'; -import { Emoji } from '@/models/entities/emoji'; -import { UserNotePining } from '@/models/entities/user-note-pining'; -import { genId } from '@/misc/gen-id'; -import { instanceChart, usersChart } from '@/services/chart/index'; -import { UserPublickey } from '@/models/entities/user-publickey'; -import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error'; -import { toPuny } from '@/misc/convert-host'; -import { UserProfile } from '@/models/entities/user-profile'; -import { getConnection } from 'typeorm'; -import { toArray } from '@/prelude/array'; -import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata'; -import { normalizeForSearch } from '@/misc/normalize-for-search'; -import { truncate } from '@/misc/truncate'; -import { StatusError } from '@/misc/fetch'; - -const logger = apLogger; - -const nameLength = 128; -const summaryLength = 2048; - -/** - * Validate and convert to actor object - * @param x Fetched object - * @param uri Fetch target URI - */ -function validateActor(x: IObject, uri: string): IActor { - const expectHost = toPuny(new URL(uri).hostname); - - if (x == null) { - throw new Error('invalid Actor: object is null'); - } - - if (!isActor(x)) { - throw new Error(`invalid Actor type '${x.type}'`); - } - - const validate = (name: string, value: any, validater: Context) => { - const e = validater.test(value); - if (e) throw new Error(`invalid Actor: ${name} ${e.message}`); - }; - - validate('id', x.id, $.str.min(1)); - validate('inbox', x.inbox, $.str.min(1)); - validate('preferredUsername', x.preferredUsername, $.str.min(1).max(128).match(/^\w([\w-.]*\w)?$/)); - - // These fields are only informational, and some AP software allows these - // fields to be very long. If they are too long, we cut them off. This way - // we can at least see these users and their activities. - validate('name', truncate(x.name, nameLength), $.optional.nullable.str); - validate('summary', truncate(x.summary, summaryLength), $.optional.nullable.str); - - const idHost = toPuny(new URL(x.id!).hostname); - if (idHost !== expectHost) { - throw new Error('invalid Actor: id has different host'); - } - - if (x.publicKey) { - if (typeof x.publicKey.id !== 'string') { - throw new Error('invalid Actor: publicKey.id is not a string'); - } - - const publicKeyIdHost = toPuny(new URL(x.publicKey.id).hostname); - if (publicKeyIdHost !== expectHost) { - throw new Error('invalid Actor: publicKey.id has different host'); - } - } - - return x; -} - -/** - * Personをフェッチします。 - * - * Misskeyに対象のPersonが登録されていればそれを返します。 - */ -export async function fetchPerson(uri: string, resolver?: Resolver): Promise<User | null> { - if (typeof uri !== 'string') throw new Error('uri is not string'); - - // URIがこのサーバーを指しているならデータベースからフェッチ - if (uri.startsWith(config.url + '/')) { - const id = uri.split('/').pop(); - return await Users.findOne(id).then(x => x || null); - } - - //#region このサーバーに既に登録されていたらそれを返す - const exist = await Users.findOne({ uri }); - - if (exist) { - return exist; - } - //#endregion - - return null; -} - -/** - * Personを作成します。 - */ -export async function createPerson(uri: string, resolver?: Resolver): Promise<User> { - if (typeof uri !== 'string') throw new Error('uri is not string'); - - if (uri.startsWith(config.url)) { - throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user'); - } - - if (resolver == null) resolver = new Resolver(); - - const object = await resolver.resolve(uri) as any; - - const person = validateActor(object, uri); - - logger.info(`Creating the Person: ${person.id}`); - - const host = toPuny(new URL(object.id).hostname); - - const { fields } = analyzeAttachments(person.attachment || []); - - const tags = extractApHashtags(person.tag).map(tag => normalizeForSearch(tag)).splice(0, 32); - - const isBot = getApType(object) === 'Service'; - - const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); - - // Create user - let user: IRemoteUser; - try { - // Start transaction - await getConnection().transaction(async transactionalEntityManager => { - user = await transactionalEntityManager.save(new User({ - id: genId(), - avatarId: null, - bannerId: null, - createdAt: new Date(), - lastFetchedAt: new Date(), - name: truncate(person.name, nameLength), - isLocked: !!person.manuallyApprovesFollowers, - isExplorable: !!person.discoverable, - username: person.preferredUsername, - usernameLower: person.preferredUsername!.toLowerCase(), - host, - inbox: person.inbox, - sharedInbox: person.sharedInbox || (person.endpoints ? person.endpoints.sharedInbox : undefined), - followersUri: person.followers ? getApId(person.followers) : undefined, - featured: person.featured ? getApId(person.featured) : undefined, - uri: person.id, - tags, - isBot, - isCat: (person as any).isCat === true - })) as IRemoteUser; - - await transactionalEntityManager.save(new UserProfile({ - userId: user.id, - description: person.summary ? htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null, - url: getOneApHrefNullable(person.url), - fields, - birthday: bday ? bday[0] : null, - location: person['vcard:Address'] || null, - userHost: host - })); - - if (person.publicKey) { - await transactionalEntityManager.save(new UserPublickey({ - userId: user.id, - keyId: person.publicKey.id, - keyPem: person.publicKey.publicKeyPem - })); - } - }); - } catch (e) { - // duplicate key error - if (isDuplicateKeyValueError(e)) { - // /users/@a => /users/:id のように入力がaliasなときにエラーになることがあるのを対応 - const u = await Users.findOne({ - uri: person.id - }); - - if (u) { - user = u as IRemoteUser; - } else { - throw new Error('already registered'); - } - } else { - logger.error(e); - throw e; - } - } - - // Register host - registerOrFetchInstanceDoc(host).then(i => { - Instances.increment({ id: i.id }, 'usersCount', 1); - instanceChart.newUser(i.host); - fetchInstanceMetadata(i); - }); - - usersChart.update(user!, true); - - // ハッシュタグ更新 - updateUsertags(user!, tags); - - //#region アバターとヘッダー画像をフェッチ - const [avatar, banner] = await Promise.all([ - person.icon, - person.image - ].map(img => - img == null - ? Promise.resolve(null) - : resolveImage(user!, img).catch(() => null) - )); - - const avatarId = avatar ? avatar.id : null; - const bannerId = banner ? banner.id : null; - const avatarUrl = avatar ? DriveFiles.getPublicUrl(avatar, true) : null; - const bannerUrl = banner ? DriveFiles.getPublicUrl(banner) : null; - const avatarBlurhash = avatar ? avatar.blurhash : null; - const bannerBlurhash = banner ? banner.blurhash : null; - - await Users.update(user!.id, { - avatarId, - bannerId, - avatarUrl, - bannerUrl, - avatarBlurhash, - bannerBlurhash - }); - - user!.avatarId = avatarId; - user!.bannerId = bannerId; - user!.avatarUrl = avatarUrl; - user!.bannerUrl = bannerUrl; - user!.avatarBlurhash = avatarBlurhash; - user!.bannerBlurhash = bannerBlurhash; - //#endregion - - //#region カスタム絵文字取得 - const emojis = await extractEmojis(person.tag || [], host).catch(e => { - logger.info(`extractEmojis: ${e}`); - return [] as Emoji[]; - }); - - const emojiNames = emojis.map(emoji => emoji.name); - - await Users.update(user!.id, { - emojis: emojiNames - }); - //#endregion - - await updateFeatured(user!.id).catch(err => logger.error(err)); - - return user!; -} - -/** - * Personの情報を更新します。 - * Misskeyに対象のPersonが登録されていなければ無視します。 - * @param uri URI of Person - * @param resolver Resolver - * @param hint Hint of Person object (この値が正当なPersonの場合、Remote resolveをせずに更新に利用します) - */ -export async function updatePerson(uri: string, resolver?: Resolver | null, hint?: object): Promise<void> { - if (typeof uri !== 'string') throw new Error('uri is not string'); - - // URIがこのサーバーを指しているならスキップ - if (uri.startsWith(config.url + '/')) { - return; - } - - //#region このサーバーに既に登録されているか - const exist = await Users.findOne({ uri }) as IRemoteUser; - - if (exist == null) { - return; - } - //#endregion - - if (resolver == null) resolver = new Resolver(); - - const object = hint || await resolver.resolve(uri) as any; - - const person = validateActor(object, uri); - - logger.info(`Updating the Person: ${person.id}`); - - // アバターとヘッダー画像をフェッチ - const [avatar, banner] = await Promise.all([ - person.icon, - person.image - ].map(img => - img == null - ? Promise.resolve(null) - : resolveImage(exist, img).catch(() => null) - )); - - // カスタム絵文字取得 - const emojis = await extractEmojis(person.tag || [], exist.host).catch(e => { - logger.info(`extractEmojis: ${e}`); - return [] as Emoji[]; - }); - - const emojiNames = emojis.map(emoji => emoji.name); - - const { fields } = analyzeAttachments(person.attachment || []); - - const tags = extractApHashtags(person.tag).map(tag => normalizeForSearch(tag)).splice(0, 32); - - const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); - - const updates = { - lastFetchedAt: new Date(), - inbox: person.inbox, - sharedInbox: person.sharedInbox || (person.endpoints ? person.endpoints.sharedInbox : undefined), - followersUri: person.followers ? getApId(person.followers) : undefined, - featured: person.featured, - emojis: emojiNames, - name: truncate(person.name, nameLength), - tags, - isBot: getApType(object) === 'Service', - isCat: (person as any).isCat === true, - isLocked: !!person.manuallyApprovesFollowers, - isExplorable: !!person.discoverable, - } as Partial<User>; - - if (avatar) { - updates.avatarId = avatar.id; - updates.avatarUrl = DriveFiles.getPublicUrl(avatar, true); - updates.avatarBlurhash = avatar.blurhash; - } - - if (banner) { - updates.bannerId = banner.id; - updates.bannerUrl = DriveFiles.getPublicUrl(banner); - updates.bannerBlurhash = banner.blurhash; - } - - // Update user - await Users.update(exist.id, updates); - - if (person.publicKey) { - await UserPublickeys.update({ userId: exist.id }, { - keyId: person.publicKey.id, - keyPem: person.publicKey.publicKeyPem - }); - } - - await UserProfiles.update({ userId: exist.id }, { - url: getOneApHrefNullable(person.url), - fields, - description: person.summary ? htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null, - birthday: bday ? bday[0] : null, - location: person['vcard:Address'] || null, - }); - - // ハッシュタグ更新 - updateUsertags(exist, tags); - - // 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする - await Followings.update({ - followerId: exist.id - }, { - followerSharedInbox: person.sharedInbox || (person.endpoints ? person.endpoints.sharedInbox : undefined) - }); - - await updateFeatured(exist.id).catch(err => logger.error(err)); -} - -/** - * Personを解決します。 - * - * Misskeyに対象のPersonが登録されていればそれを返し、そうでなければ - * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 - */ -export async function resolvePerson(uri: string, resolver?: Resolver): Promise<User> { - if (typeof uri !== 'string') throw new Error('uri is not string'); - - //#region このサーバーに既に登録されていたらそれを返す - const exist = await fetchPerson(uri); - - if (exist) { - return exist; - } - //#endregion - - // リモートサーバーからフェッチしてきて登録 - if (resolver == null) resolver = new Resolver(); - return await createPerson(uri, resolver); -} - -const services: { - [x: string]: (id: string, username: string) => any - } = { - 'misskey:authentication:twitter': (userId, screenName) => ({ userId, screenName }), - 'misskey:authentication:github': (id, login) => ({ id, login }), - 'misskey:authentication:discord': (id, name) => $discord(id, name) -}; - -const $discord = (id: string, name: string) => { - if (typeof name !== 'string') - name = 'unknown#0000'; - const [username, discriminator] = name.split('#'); - return { id, username, discriminator }; -}; - -function addService(target: { [x: string]: any }, source: IApPropertyValue) { - const service = services[source.name]; - - if (typeof source.value !== 'string') - source.value = 'unknown'; - - const [id, username] = source.value.split('@'); - - if (service) - target[source.name.split(':')[2]] = service(id, username); -} - -export function analyzeAttachments(attachments: IObject | IObject[] | undefined) { - const fields: { - name: string, - value: string - }[] = []; - const services: { [x: string]: any } = {}; - - if (Array.isArray(attachments)) { - for (const attachment of attachments.filter(isPropertyValue)) { - if (isPropertyValue(attachment.identifier)) { - addService(services, attachment.identifier); - } else { - fields.push({ - name: attachment.name, - value: fromHtml(attachment.value) - }); - } - } - } - - return { fields, services }; -} - -export async function updateFeatured(userId: User['id']) { - const user = await Users.findOneOrFail(userId); - if (!Users.isRemoteUser(user)) return; - if (!user.featured) return; - - logger.info(`Updating the featured: ${user.uri}`); - - const resolver = new Resolver(); - - // Resolve to (Ordered)Collection Object - const collection = await resolver.resolveCollection(user.featured); - if (!isCollectionOrOrderedCollection(collection)) throw new Error(`Object is not Collection or OrderedCollection`); - - // Resolve to Object(may be Note) arrays - const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems; - const items = await Promise.all(toArray(unresolvedItems).map(x => resolver.resolve(x))); - - // Resolve and regist Notes - const limit = promiseLimit<Note | null>(2); - const featuredNotes = await Promise.all(items - .filter(item => getApType(item) === 'Note') // TODO: Noteでなくてもいいかも - .slice(0, 5) - .map(item => limit(() => resolveNote(item, resolver)))); - - await getConnection().transaction(async transactionalEntityManager => { - await transactionalEntityManager.delete(UserNotePining, { userId: user.id }); - - // とりあえずidを別の時間で生成して順番を維持 - let td = 0; - for (const note of featuredNotes.filter(note => note != null)) { - td -= 1000; - transactionalEntityManager.insert(UserNotePining, { - id: genId(new Date(Date.now() + td)), - createdAt: new Date(), - userId: user.id, - noteId: note!.id - }); - } - }); -} |