diff --git a/src/queue/processors/http/process-inbox.ts b/src/queue/processors/http/process-inbox.ts index 6608907a7..ce5b7d5a8 100644 --- a/src/queue/processors/http/process-inbox.ts +++ b/src/queue/processors/http/process-inbox.ts @@ -4,8 +4,8 @@ import * as debug from 'debug'; import { verifySignature } from 'http-signature'; import parseAcct from '../../../acct/parse'; import User, { IRemoteUser } from '../../../models/user'; -import act from '../../../remote/activitypub/act'; -import resolvePerson from '../../../remote/activitypub/resolve-person'; +import perform from '../../../remote/activitypub/perform'; +import { resolvePerson } from '../../../remote/activitypub/objects/person'; const log = debug('misskey:queue:inbox'); @@ -58,7 +58,7 @@ export default async (job: kue.Job, done): Promise => { // アクティビティを処理 try { - await act(user, activity); + await perform(user, activity); done(); } catch (e) { done(e); diff --git a/src/remote/activitypub/act/create/image.ts b/src/remote/activitypub/act/create/image.ts deleted file mode 100644 index f1462f4ee..000000000 --- a/src/remote/activitypub/act/create/image.ts +++ /dev/null @@ -1,18 +0,0 @@ -import * as debug from 'debug'; - -import uploadFromUrl from '../../../../services/drive/upload-from-url'; -import { IRemoteUser } from '../../../../models/user'; -import { IDriveFile } from '../../../../models/drive-file'; - -const log = debug('misskey:activitypub'); - -export default async function(actor: IRemoteUser, image): Promise { - if ('attributedTo' in image && actor.uri !== image.attributedTo) { - log(`invalid image: ${JSON.stringify(image, null, 2)}`); - throw new Error('invalid image'); - } - - log(`Creating the Image: ${image.url}`); - - return await uploadFromUrl(image.url, actor); -} diff --git a/src/remote/activitypub/act/create/note.ts b/src/remote/activitypub/act/create/note.ts deleted file mode 100644 index 599bc10aa..000000000 --- a/src/remote/activitypub/act/create/note.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { JSDOM } from 'jsdom'; -import * as debug from 'debug'; - -import Resolver from '../../resolver'; -import Note, { INote } from '../../../../models/note'; -import post from '../../../../services/note/create'; -import { IRemoteUser } from '../../../../models/user'; -import resolvePerson from '../../resolve-person'; -import createImage from './image'; -import config from '../../../../config'; - -const log = debug('misskey:activitypub'); - -/** - * 投稿作成アクティビティを捌きます - */ -export default async function createNote(resolver: Resolver, actor: IRemoteUser, note, silent = false): Promise { - if (typeof note.id !== 'string') { - log(`invalid note: ${JSON.stringify(note, null, 2)}`); - throw new Error('invalid note'); - } - - // 既に同じURIを持つものが登録されていないかチェックし、登録されていたらそれを返す - const exist = await Note.findOne({ uri: note.id }); - if (exist) { - return exist; - } - - log(`Creating the Note: ${note.id}`); - - //#region Visibility - let visibility = 'public'; - if (!note.to.includes('https://www.w3.org/ns/activitystreams#Public')) visibility = 'unlisted'; - if (note.cc.length == 0) visibility = 'private'; - // TODO - if (visibility != 'public') throw new Error('unspported visibility'); - //#endergion - - //#region 添付メディア - let media = []; - if ('attachment' in note && note.attachment != null) { - // TODO: attachmentは必ずしもImageではない - // TODO: attachmentは必ずしも配列ではない - media = await Promise.all(note.attachment.map(x => { - return createImage(actor, x); - })); - } - //#endregion - - //#region リプライ - let reply = null; - if ('inReplyTo' in note && note.inReplyTo != null) { - // リプライ先の投稿がMisskeyに登録されているか調べる - const uri: string = note.inReplyTo.id || note.inReplyTo; - const inReplyToNote = uri.startsWith(config.url + '/') - ? await Note.findOne({ _id: uri.split('/').pop() }) - : await Note.findOne({ uri }); - - if (inReplyToNote) { - reply = inReplyToNote; - } else { - // 無かったらフェッチ - const inReplyTo = await resolver.resolve(note.inReplyTo) as any; - - // リプライ先の投稿の投稿者をフェッチ - const actor = await resolvePerson(inReplyTo.attributedTo) as IRemoteUser; - - // TODO: silentを常にtrueにしてはならない - reply = await createNote(resolver, actor, inReplyTo); - } - } - //#endregion - - const { window } = new JSDOM(note.content); - - return await post(actor, { - createdAt: new Date(note.published), - media, - reply, - renote: undefined, - text: window.document.body.textContent, - viaMobile: false, - geo: undefined, - visibility, - uri: note.id - }); -} diff --git a/src/remote/activitypub/act/announce/index.ts b/src/remote/activitypub/kernel/announce/index.ts similarity index 100% rename from src/remote/activitypub/act/announce/index.ts rename to src/remote/activitypub/kernel/announce/index.ts diff --git a/src/remote/activitypub/act/announce/note.ts b/src/remote/activitypub/kernel/announce/note.ts similarity index 67% rename from src/remote/activitypub/act/announce/note.ts rename to src/remote/activitypub/kernel/announce/note.ts index 24d159f18..68fb23c97 100644 --- a/src/remote/activitypub/act/announce/note.ts +++ b/src/remote/activitypub/kernel/announce/note.ts @@ -1,12 +1,10 @@ import * as debug from 'debug'; import Resolver from '../../resolver'; -import Note from '../../../../models/note'; import post from '../../../../services/note/create'; -import { IRemoteUser, isRemoteUser } from '../../../../models/user'; +import { IRemoteUser } from '../../../../models/user'; import { IAnnounce, INote } from '../../type'; -import createNote from '../create/note'; -import resolvePerson from '../../resolve-person'; +import { fetchNote, resolveNote } from '../../objects/note'; const log = debug('misskey:activitypub'); @@ -21,17 +19,12 @@ export default async function(resolver: Resolver, actor: IRemoteUser, activity: } // 既に同じURIを持つものが登録されていないかチェック - const exist = await Note.findOne({ uri }); + const exist = await fetchNote(uri); if (exist) { return; } - // アナウンス元の投稿の投稿者をフェッチ - const announcee = await resolvePerson(note.attributedTo); - - const renote = isRemoteUser(announcee) - ? await createNote(resolver, announcee, note, true) - : await Note.findOne({ _id: note.id.split('/').pop() }); + const renote = await resolveNote(note); log(`Creating the (Re)Note: ${uri}`); diff --git a/src/remote/activitypub/kernel/create/image.ts b/src/remote/activitypub/kernel/create/image.ts new file mode 100644 index 000000000..ea36545f0 --- /dev/null +++ b/src/remote/activitypub/kernel/create/image.ts @@ -0,0 +1,6 @@ +import { IRemoteUser } from '../../../../models/user'; +import { createImage } from '../../objects/image'; + +export default async function(actor: IRemoteUser, image): Promise { + await createImage(image.url, actor); +} diff --git a/src/remote/activitypub/act/create/index.ts b/src/remote/activitypub/kernel/create/index.ts similarity index 100% rename from src/remote/activitypub/act/create/index.ts rename to src/remote/activitypub/kernel/create/index.ts diff --git a/src/remote/activitypub/kernel/create/note.ts b/src/remote/activitypub/kernel/create/note.ts new file mode 100644 index 000000000..530cf6483 --- /dev/null +++ b/src/remote/activitypub/kernel/create/note.ts @@ -0,0 +1,13 @@ +import Resolver from '../../resolver'; +import { IRemoteUser } from '../../../../models/user'; +import { createNote, fetchNote } from '../../objects/note'; + +/** + * 投稿作成アクティビティを捌きます + */ +export default async function(resolver: Resolver, actor: IRemoteUser, note, silent = false): Promise { + const exist = await fetchNote(note); + if (exist == null) { + await createNote(note); + } +} diff --git a/src/remote/activitypub/act/delete/index.ts b/src/remote/activitypub/kernel/delete/index.ts similarity index 100% rename from src/remote/activitypub/act/delete/index.ts rename to src/remote/activitypub/kernel/delete/index.ts diff --git a/src/remote/activitypub/act/delete/note.ts b/src/remote/activitypub/kernel/delete/note.ts similarity index 100% rename from src/remote/activitypub/act/delete/note.ts rename to src/remote/activitypub/kernel/delete/note.ts diff --git a/src/remote/activitypub/act/follow.ts b/src/remote/activitypub/kernel/follow.ts similarity index 100% rename from src/remote/activitypub/act/follow.ts rename to src/remote/activitypub/kernel/follow.ts diff --git a/src/remote/activitypub/act/index.ts b/src/remote/activitypub/kernel/index.ts similarity index 100% rename from src/remote/activitypub/act/index.ts rename to src/remote/activitypub/kernel/index.ts diff --git a/src/remote/activitypub/act/like.ts b/src/remote/activitypub/kernel/like.ts similarity index 100% rename from src/remote/activitypub/act/like.ts rename to src/remote/activitypub/kernel/like.ts diff --git a/src/remote/activitypub/act/undo/follow.ts b/src/remote/activitypub/kernel/undo/follow.ts similarity index 100% rename from src/remote/activitypub/act/undo/follow.ts rename to src/remote/activitypub/kernel/undo/follow.ts diff --git a/src/remote/activitypub/act/undo/index.ts b/src/remote/activitypub/kernel/undo/index.ts similarity index 100% rename from src/remote/activitypub/act/undo/index.ts rename to src/remote/activitypub/kernel/undo/index.ts diff --git a/src/remote/activitypub/objects/image.ts b/src/remote/activitypub/objects/image.ts new file mode 100644 index 000000000..7f79fc5c0 --- /dev/null +++ b/src/remote/activitypub/objects/image.ts @@ -0,0 +1,29 @@ +import * as debug from 'debug'; + +import uploadFromUrl from '../../../services/drive/upload-from-url'; +import { IRemoteUser } from '../../../models/user'; +import { IDriveFile } from '../../../models/drive-file'; + +const log = debug('misskey:activitypub'); + +/** + * Imageを作成します。 + */ +export async function createImage(actor: IRemoteUser, image): Promise { + log(`Creating the Image: ${image.url}`); + + return await uploadFromUrl(image.url, actor); +} + +/** + * Imageを解決します。 + * + * Misskeyに対象のImageが登録されていればそれを返し、そうでなければ + * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 + */ +export async function resolveImage(actor: IRemoteUser, value: any): Promise { + // TODO + + // リモートサーバーからフェッチしてきて登録 + return await createImage(actor, value); +} diff --git a/src/remote/activitypub/objects/note.ts b/src/remote/activitypub/objects/note.ts new file mode 100644 index 000000000..3edcb8c63 --- /dev/null +++ b/src/remote/activitypub/objects/note.ts @@ -0,0 +1,110 @@ +import { JSDOM } from 'jsdom'; +import * as debug from 'debug'; + +import config from '../../../config'; +import Resolver from '../resolver'; +import Note, { INote } from '../../../models/note'; +import post from '../../../services/note/create'; +import { INote as INoteActivityStreamsObject, IObject } from '../type'; +import { resolvePerson } from './person'; +import { resolveImage } from './image'; +import { IRemoteUser } from '../../../models/user'; + +const log = debug('misskey:activitypub'); + +/** + * Noteをフェッチします。 + * + * Misskeyに対象のNoteが登録されていればそれを返します。 + */ +export async function fetchNote(value: string | IObject, resolver?: Resolver): Promise { + const uri = typeof value == 'string' ? value : value.id; + + // URIがこのサーバーを指しているならデータベースからフェッチ + if (uri.startsWith(config.url + '/')) { + return await Note.findOne({ _id: uri.split('/').pop() }); + } + + //#region このサーバーに既に登録されていたらそれを返す + const exist = await Note.findOne({ uri }); + + if (exist) { + return exist; + } + //#endregion + + return null; +} + +/** + * Noteを作成します。 + */ +export async function createNote(value: any, resolver?: Resolver, silent = false): Promise { + if (resolver == null) resolver = new Resolver(); + + const object = await resolver.resolve(value) as any; + + if (object == null || object.type !== 'Note') { + throw new Error('invalid note'); + } + + const note: INoteActivityStreamsObject = object; + + log(`Creating the Note: ${note.id}`); + + // 投稿者をフェッチ + const actor = await resolvePerson(note.attributedTo) as IRemoteUser; + + //#region Visibility + let visibility = 'public'; + if (!note.to.includes('https://www.w3.org/ns/activitystreams#Public')) visibility = 'unlisted'; + if (note.cc.length == 0) visibility = 'private'; + // TODO + if (visibility != 'public') throw new Error('unspported visibility'); + //#endergion + + // 添付メディア + // TODO: attachmentは必ずしもImageではない + // TODO: attachmentは必ずしも配列ではない + const media = note.attachment + ? await Promise.all(note.attachment.map(x => resolveImage(actor, x))) + : []; + + // リプライ + const reply = note.inReplyTo ? await resolveNote(note.inReplyTo, resolver) : null; + + const { window } = new JSDOM(note.content); + + return await post(actor, { + createdAt: new Date(note.published), + media, + reply, + renote: undefined, + text: window.document.body.textContent, + viaMobile: false, + geo: undefined, + visibility, + uri: note.id + }, silent); +} + +/** + * Noteを解決します。 + * + * Misskeyに対象のNoteが登録されていればそれを返し、そうでなければ + * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 + */ +export async function resolveNote(value: string | IObject, resolver?: Resolver): Promise { + const uri = typeof value == 'string' ? value : value.id; + + //#region このサーバーに既に登録されていたらそれを返す + const exist = await fetchNote(uri); + + if (exist) { + return exist; + } + //#endregion + + // リモートサーバーからフェッチしてきて登録 + return await createNote(value, resolver); +} diff --git a/src/remote/activitypub/objects/person.ts b/src/remote/activitypub/objects/person.ts new file mode 100644 index 000000000..b1e8c9ee0 --- /dev/null +++ b/src/remote/activitypub/objects/person.ts @@ -0,0 +1,142 @@ +import { JSDOM } from 'jsdom'; +import { toUnicode } from 'punycode'; +import * as debug from 'debug'; + +import config from '../../../config'; +import User, { validateUsername, isValidName, isValidDescription, IUser, IRemoteUser } from '../../../models/user'; +import webFinger from '../../webfinger'; +import Resolver from '../resolver'; +import { resolveImage } from './image'; +import { isCollectionOrOrderedCollection, IObject, IPerson } from '../type'; + +const log = debug('misskey:activitypub'); + +/** + * Personをフェッチします。 + * + * Misskeyに対象のPersonが登録されていればそれを返します。 + */ +export async function fetchPerson(value: string | IObject, resolver?: Resolver): Promise { + const uri = typeof value == 'string' ? value : value.id; + + // URIがこのサーバーを指しているならデータベースからフェッチ + if (uri.startsWith(config.url + '/')) { + return await User.findOne({ _id: uri.split('/').pop() }); + } + + //#region このサーバーに既に登録されていたらそれを返す + const exist = await User.findOne({ uri }); + + if (exist) { + return exist; + } + //#endregion + + return null; +} + +/** + * Personを作成します。 + */ +export async function createPerson(value: any, resolver?: Resolver): Promise { + if (resolver == null) resolver = new Resolver(); + + const object = await resolver.resolve(value) as any; + + if ( + object == null || + object.type !== 'Person' || + typeof object.preferredUsername !== 'string' || + !validateUsername(object.preferredUsername) || + !isValidName(object.name == '' ? null : object.name) || + !isValidDescription(object.summary) + ) { + throw new Error('invalid person'); + } + + const person: IPerson = object; + + log(`Creating the Person: ${person.id}`); + + const [followersCount = 0, followingCount = 0, notesCount = 0, finger] = await Promise.all([ + resolver.resolve(person.followers).then( + resolved => isCollectionOrOrderedCollection(resolved) ? resolved.totalItems : undefined, + () => undefined + ), + resolver.resolve(person.following).then( + resolved => isCollectionOrOrderedCollection(resolved) ? resolved.totalItems : undefined, + () => undefined + ), + resolver.resolve(person.outbox).then( + resolved => isCollectionOrOrderedCollection(resolved) ? resolved.totalItems : undefined, + () => undefined + ), + webFinger(person.id) + ]); + + const host = toUnicode(finger.subject.replace(/^.*?@/, '')); + const hostLower = host.replace(/[A-Z]+/, matched => matched.toLowerCase()); + const summaryDOM = JSDOM.fragment(person.summary); + + // Create user + const user = await User.insert({ + avatarId: null, + bannerId: null, + createdAt: Date.parse(person.published) || null, + description: summaryDOM.textContent, + followersCount, + followingCount, + notesCount, + name: person.name, + driveCapacity: 1024 * 1024 * 8, // 8MiB + username: person.preferredUsername, + usernameLower: person.preferredUsername.toLowerCase(), + host, + hostLower, + publicKey: { + id: person.publicKey.id, + publicKeyPem: person.publicKey.publicKeyPem + }, + inbox: person.inbox, + uri: person.id + }) as IRemoteUser; + + //#region アイコンとヘッダー画像をフェッチ + const [avatarId, bannerId] = (await Promise.all([ + person.icon, + person.image + ].map(img => + img == null + ? Promise.resolve(null) + : resolveImage(user, img.url) + ))).map(file => file != null ? file._id : null); + + User.update({ _id: user._id }, { $set: { avatarId, bannerId } }); + + user.avatarId = avatarId; + user.bannerId = bannerId; + //#endregion + + return user; +} + +/** + * Personを解決します。 + * + * Misskeyに対象のPersonが登録されていればそれを返し、そうでなければ + * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 + */ +export async function resolvePerson(value: string | IObject, verifier?: string): Promise { + const uri = typeof value == 'string' ? value : value.id; + + //#region このサーバーに既に登録されていたらそれを返す + const exist = await fetchPerson(uri); + + if (exist) { + return exist; + } + //#endregion + + // リモートサーバーからフェッチしてきて登録 + return await createPerson(value); +} diff --git a/src/remote/activitypub/perform.ts b/src/remote/activitypub/perform.ts new file mode 100644 index 000000000..2e4f53adf --- /dev/null +++ b/src/remote/activitypub/perform.ts @@ -0,0 +1,7 @@ +import { Object } from './type'; +import { IRemoteUser } from '../../models/user'; +import kernel from './kernel'; + +export default async (actor: IRemoteUser, activity: Object): Promise => { + await kernel(actor, activity); +}; diff --git a/src/remote/activitypub/resolve-person.ts b/src/remote/activitypub/resolve-person.ts deleted file mode 100644 index 50e7873cb..000000000 --- a/src/remote/activitypub/resolve-person.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { JSDOM } from 'jsdom'; -import { toUnicode } from 'punycode'; -import config from '../../config'; -import User, { validateUsername, isValidName, isValidDescription, IUser } from '../../models/user'; -import webFinger from '../webfinger'; -import Resolver from './resolver'; -import uploadFromUrl from '../../services/drive/upload-from-url'; -import { isCollectionOrOrderedCollection, IObject } from './type'; - -export default async (value: string | IObject, verifier?: string): Promise => { - const id = typeof value == 'string' ? value : value.id; - - if (id.startsWith(config.url + '/')) { - return await User.findOne({ _id: id.split('/').pop() }); - } else { - const exist = await User.findOne({ - uri: id - }); - - if (exist) { - return exist; - } - } - - const resolver = new Resolver(); - - const object = await resolver.resolve(value) as any; - - if ( - object == null || - object.type !== 'Person' || - typeof object.preferredUsername !== 'string' || - !validateUsername(object.preferredUsername) || - !isValidName(object.name == '' ? null : object.name) || - !isValidDescription(object.summary) - ) { - throw new Error('invalid person'); - } - - const [followersCount = 0, followingCount = 0, notesCount = 0, finger] = await Promise.all([ - resolver.resolve(object.followers).then( - resolved => isCollectionOrOrderedCollection(resolved) ? resolved.totalItems : undefined, - () => undefined - ), - resolver.resolve(object.following).then( - resolved => isCollectionOrOrderedCollection(resolved) ? resolved.totalItems : undefined, - () => undefined - ), - resolver.resolve(object.outbox).then( - resolved => isCollectionOrOrderedCollection(resolved) ? resolved.totalItems : undefined, - () => undefined - ), - webFinger(id, verifier) - ]); - - const host = toUnicode(finger.subject.replace(/^.*?@/, '')); - const hostLower = host.replace(/[A-Z]+/, matched => matched.toLowerCase()); - const summaryDOM = JSDOM.fragment(object.summary); - - // Create user - const user = await User.insert({ - avatarId: null, - bannerId: null, - createdAt: Date.parse(object.published) || null, - description: summaryDOM.textContent, - followersCount, - followingCount, - notesCount, - name: object.name, - driveCapacity: 1024 * 1024 * 8, // 8MiB - username: object.preferredUsername, - usernameLower: object.preferredUsername.toLowerCase(), - host, - hostLower, - publicKey: { - id: object.publicKey.id, - publicKeyPem: object.publicKey.publicKeyPem - }, - inbox: object.inbox, - uri: id - }); - - const [avatarId, bannerId] = (await Promise.all([ - object.icon, - object.image - ].map(img => - img == null - ? Promise.resolve(null) - : uploadFromUrl(img.url, user) - ))).map(file => file != null ? file._id : null); - - User.update({ _id: user._id }, { $set: { avatarId, bannerId } }); - - user.avatarId = avatarId; - user.bannerId = bannerId; - - return user; -}; diff --git a/src/remote/activitypub/type.ts b/src/remote/activitypub/type.ts index 233551764..983eb621f 100644 --- a/src/remote/activitypub/type.ts +++ b/src/remote/activitypub/type.ts @@ -9,6 +9,11 @@ export interface IObject { cc?: string[]; to?: string[]; attributedTo: string; + attachment?: any[]; + inReplyTo?: any; + content: string; + icon?: any; + image?: any; } export interface IActivity extends IObject { @@ -34,6 +39,17 @@ export interface INote extends IObject { type: 'Note'; } +export interface IPerson extends IObject { + type: 'Person'; + name: string; + preferredUsername: string; + inbox: string; + publicKey: any; + followers: any; + following: any; + outbox: any; +} + export const isCollection = (object: IObject): object is ICollection => object.type === 'Collection'; diff --git a/src/remote/resolve-user.ts b/src/remote/resolve-user.ts index 0e7edd8e1..346e134c9 100644 --- a/src/remote/resolve-user.ts +++ b/src/remote/resolve-user.ts @@ -1,8 +1,8 @@ import { toUnicode, toASCII } from 'punycode'; import User from '../models/user'; -import resolvePerson from './activitypub/resolve-person'; import webFinger from './webfinger'; import config from '../config'; +import { createPerson } from './activitypub/objects/person'; export default async (username, host, option) => { const usernameLower = username.toLowerCase(); @@ -18,13 +18,13 @@ export default async (username, host, option) => { if (user === null) { const acctLower = `${usernameLower}@${hostLowerAscii}`; - const finger = await webFinger(acctLower, acctLower); + const finger = await webFinger(acctLower); const self = finger.links.find(link => link.rel && link.rel.toLowerCase() === 'self'); if (!self) { throw new Error('self link not found'); } - user = await resolvePerson(self.href, acctLower); + user = await createPerson(self.href); } return user; diff --git a/src/remote/webfinger.ts b/src/remote/webfinger.ts index bfca8d1c8..4f1ff231c 100644 --- a/src/remote/webfinger.ts +++ b/src/remote/webfinger.ts @@ -3,36 +3,21 @@ const WebFinger = require('webfinger.js'); const webFinger = new WebFinger({ }); type ILink = { - href: string; - rel: string; + href: string; + rel: string; }; type IWebFinger = { - links: ILink[]; - subject: string; + links: ILink[]; + subject: string; }; -export default async function resolve(query, verifier?: string): Promise { - const finger = await new Promise((res, rej) => webFinger.lookup(query, (error, result) => { +export default async function resolve(query): Promise { + return await new Promise((res, rej) => webFinger.lookup(query, (error, result) => { if (error) { return rej(error); } res(result.object); })) as IWebFinger; - const subject = finger.subject.toLowerCase().replace(/^acct:/, ''); - - if (typeof verifier === 'string') { - if (subject !== verifier) { - throw new Error(); - } - - return finger; - } - - if (typeof subject === 'string') { - return resolve(subject, subject); - } - - throw new Error(); }