import { URL } from 'node:url'; import promiseLimit from 'promise-limit'; import { Not, IsNull } from 'typeorm'; import config from '@/config/index.js'; import { registerOrFetchInstanceDoc } from '@/services/register-or-fetch-instance-doc.js'; import { Note } from '@/models/entities/note.js'; import { updateUsertags } from '@/services/update-hashtag.js'; import { Users, Instances, Followings, UserProfiles, UserPublickeys } from '@/models/index.js'; import { User, IRemoteUser, User } from '@/models/entities/user.js'; import { Emoji } from '@/models/entities/emoji.js'; import { UserNotePining } from '@/models/entities/user-note-pining.js'; import { genId } from '@/misc/gen-id.js'; import { instanceChart, usersChart } from '@/services/chart/index.js'; import { UserPublickey } from '@/models/entities/user-publickey.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; import { extractPunyHost } from '@/misc/convert-host.js'; import { UserProfile } from '@/models/entities/user-profile.js'; import { toArray } from '@/prelude/array.js'; import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { truncate } from '@/misc/truncate.js'; import { StatusError } from '@/misc/fetch.js'; import { uriPersonCache } from '@/services/user-cache.js'; import { publishInternalEvent } from '@/services/stream.js'; import { db } from '@/db/postgre.js'; import { fromHtml } from '@/mfm/from-html.js'; import { Resolver } from '@/remote/activitypub/resolver.js'; import { apLogger } from '../logger.js'; import { isCollectionOrOrderedCollection, isCollection, IActor, getApId, getOneApHrefNullable, IObject, isPropertyValue, getApType, isActor } from '../type.js'; import { extractApHashtags, extractEmojis } from './tag.js'; import { resolveNote } from './note.js'; import { resolveImage } from './image.js'; const nameLength = 128; const summaryLength = 2048; /** * Validate and convert to actor object * @param x Fetched object * @param uri Fetch target URI */ async function validateActor(x: IObject, resolver: Resolver): Promise { if (x == null) { throw new Error('invalid Actor: object is null'); } if (!isActor(x)) { throw new Error(`invalid Actor type '${x.type}'`); } const uri = getApId(x); if (uri == null) { // Only transient objects or anonymous objects may not have an id or an id that is explicitly null. // We consider all actors as not transient and not anonymous so require ids for them. throw new Error('invalid Actor: wrong id'); } // This check is security critical. // Without this check, an entry could be inserted into UserPublickey for a local user. if (extractPunyHost(uri) === extractPunyHost(config.url)) { throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user'); } if (x.movedTo !== undefined) { if (!(typeof x.movedTo === 'string' && x.movedTo.length > 0)) { throw new Error('invalid Actor: wrong movedTo'); } if (x.movedTo === uri) { throw new Error('invalid Actor: moved to self'); } // This may throw an exception if we cannot resolve the move target. // If we are processing an incoming activity, this is desired behaviour // because that will cause the activity to be retried. await resolvePerson(x.movedTo, resolver) .then(moveTarget => { x.movedTo = moveTarget.id }); } // check that inbox is a valid and absolute URL // in NodeJS, the first parameter must be an absolute URL or the base URL is required try { new URL(x.inbox) } catch (err) { throw new Error('invalid Actor: wrong inbox', { cause: err }); } // unify different sharedInbox places x.sharedInbox = x.sharedInbox ?? x.endpoints?.sharedInbox ?? null; if (x.sharedInbox != null) { // check that sharedInbox is a valid and absolute URL try { new URL(x.sharedInbox); } catch (err) { delete x.sharedInbox; } } if (!(typeof x.preferredUsername === 'string' && x.preferredUsername.length > 0 && x.preferredUsername.length <= 128 && /^\w([\w-.]*\w)?$/.test(x.preferredUsername))) { throw new Error('invalid Actor: wrong username'); } // 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. if (x.name) { if (!(typeof x.name === 'string' && x.name.length > 0)) { throw new Error('invalid Actor: wrong name'); } x.name = truncate(x.name, nameLength); } if (x.summary) { if (!(typeof x.summary === 'string' && x.summary.length > 0)) { throw new Error('invalid Actor: wrong summary'); } x.summary = truncate(x.summary, summaryLength); } if (x.publicKey) { if (typeof x.publicKey.id !== 'string') { throw new Error('invalid Actor: publicKey.id is not a string'); } // This is a security critical check to not insert or change an entry of // UserPublickey to point to a local key id. if (extractPunyHost(uri) !== extractPunyHost(x.publicKey.id)) { throw new Error('invalid Actor: publicKey.id has different host'); } } return x; } /** * Fetches a person. * * If the target Person is registered in FoundKey, it is returned. */ export async function fetchPerson(uri: string): Promise { if (typeof uri !== 'string') throw new Error('uri is not string'); const cached = uriPersonCache.get(uri); if (cached) return cached; // If the URI points to this server, fetch from database. if (uri.startsWith(config.url + '/')) { const id = uri.split('/').pop(); const u = await Users.findOneBy({ id }); if (u) uriPersonCache.set(uri, u); return u; } //#region このサーバーに既に登録されていたらそれを返す const exist = await Users.findOneBy({ uri }); if (exist) { uriPersonCache.set(uri, exist); return exist; } //#endregion return null; } /** * Personを作成します。 */ export async function createPerson(value: string | IObject, resolver: Resolver): Promise { const object = await resolver.resolve(value) as any; const person = await validateActor(object, resolver); apLogger.info(`Creating the Person: ${person.id}`); const host = extractPunyHost(object.id); 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 db.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, 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, showTimelineReplies: false, movedToId: person.movedTo, })) as IRemoteUser; await transactionalEntityManager.save(new UserProfile({ userId: user.id, description: person.summary ? fromHtml(truncate(person.summary, summaryLength)) : 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)) { // Fix an error when the input is an alias like /users/@a -> /users/:id const u = await Users.findOneBy({ uri: person.id, }); if (u) { user = u as IRemoteUser; } else { throw new Error('already registered'); } } else { apLogger.error(e instanceof Error ? e : new Error(e as string)); 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, resolver).catch(() => null), )); const avatarId = avatar ? avatar.id : null; const bannerId = banner ? banner.id : null; await Users.update(user!.id, { avatarId, bannerId, }); user!.avatarId = avatarId; user!.bannerId = bannerId; //#endregion //#region カスタム絵文字取得 const emojis = await extractEmojis(person.tag || [], host).catch(e => { apLogger.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, resolver).catch(err => apLogger.error(err)); return user!; } /** * Update Person information. * If the target Person is not registered in FoundKey, it is ignored. * @param value URI of Person or Person itself * @param resolver Resolver * @param hint Hint of Person object (If this value is a valid Person, it is used for updating without Remote resolve.) */ export async function updatePerson(value: IObject | string, resolver: Resolver): Promise { const uri = getApId(value); // do we already know this user? const exist = await Users.findOneBy({ uri, host: Not(IsNull()) }) as IRemoteUser; if (exist == null) { return; } const object = await resolver.resolve(value); const person = await validateActor(object, resolver); apLogger.info(`Updating the Person: ${person.id}`); // Fetch avatar and banner image const [avatar, banner] = await Promise.all([ person.icon, person.image, ].map(img => img == null ? Promise.resolve(null) : resolveImage(exist, img, resolver).catch(() => null), )); // Get custom emoji const emojis = await extractEmojis(person.tag || [], exist.host).catch(e => { apLogger.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, 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, movedToId: person.movedTo, } as Partial; if (avatar) { updates.avatarId = avatar.id; } if (banner) { updates.bannerId = banner.id; } // 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 ? fromHtml(truncate(person.summary, summaryLength)) : null, birthday: bday ? bday[0] : null, location: person['vcard:Address'] || null, }); publishInternalEvent('remoteUserUpdated', { id: exist.id }); updateUsertags(exist, tags); // If the user in question is already a follower, followers will also be updated. await Followings.update({ followerId: exist.id, }, { followerSharedInbox: person.sharedInbox, }); await updateFeatured(exist.id, resolver).catch(err => apLogger.error(err)); } /** * Resolve Person. * * If the target Person is registered in FoundKey, return it; otherwise, fetch it from a remote server and return it. * Fetch the person from the remote server, register it in FoundKey, and return it. */ export async function resolvePerson(uri: string, resolver: Resolver, hint?: IObject): Promise { if (typeof uri !== 'string') throw new Error('uri is not string'); //#region このサーバーに既に登録されていたらそれを返す const exist = await fetchPerson(uri); if (exist) { return exist; } //#endregion // リモートサーバーからフェッチしてきて登録 return await createPerson(hint ?? uri, resolver); } 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)) { fields.push({ name: attachment.name, value: fromHtml(attachment.value), }); } } return { fields, services }; } async function updateFeatured(userId: User['id'], resolver: Resolver) { const user = await Users.findOneByOrFail({ id: userId }); if (!Users.isRemoteUser(user)) return; if (!user.featured) return; apLogger.info(`Updating the featured: ${user.uri}`); // 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(2); const featuredNotes = await Promise.all(items .filter(item => getApType(item) === 'Note') // TODO: Noteでなくてもいいかも .slice(0, 5) .map(item => limit(() => resolveNote(item, resolver)))); await db.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, }); } }); }