summaryrefslogtreecommitdiff
path: root/src/common
diff options
context:
space:
mode:
authorAkihiko Odaki <nekomanma@pixiv.co.jp>2018-03-31 19:55:00 +0900
committerAkihiko Odaki <nekomanma@pixiv.co.jp>2018-03-31 20:33:14 +0900
commit68a9aac9573969311dd00a44536c3ee4c05b883d (patch)
tree7d6c502c1e2c61eb3327f678f766f23bda10c1e7 /src/common
parentStore texts as HTML (diff)
downloadsharkey-68a9aac9573969311dd00a44536c3ee4c05b883d.tar.gz
sharkey-68a9aac9573969311dd00a44536c3ee4c05b883d.tar.bz2
sharkey-68a9aac9573969311dd00a44536c3ee4c05b883d.zip
Implement remote status retrieval
Diffstat (limited to 'src/common')
-rw-r--r--src/common/drive/add-file.ts307
-rw-r--r--src/common/drive/upload_from_url.ts46
-rw-r--r--src/common/event.ts80
-rw-r--r--src/common/push-sw.ts52
-rw-r--r--src/common/remote/activitypub/act/create.ts9
-rw-r--r--src/common/remote/activitypub/act/index.ts22
-rw-r--r--src/common/remote/activitypub/create.ts86
-rw-r--r--src/common/remote/activitypub/resolve-person.ts104
-rw-r--r--src/common/remote/activitypub/resolver.ts97
-rw-r--r--src/common/remote/activitypub/type.ts3
-rw-r--r--src/common/remote/resolve-user.ts26
-rw-r--r--src/common/remote/webfinger.ts25
12 files changed, 857 insertions, 0 deletions
diff --git a/src/common/drive/add-file.ts b/src/common/drive/add-file.ts
new file mode 100644
index 0000000000..52a7713dd9
--- /dev/null
+++ b/src/common/drive/add-file.ts
@@ -0,0 +1,307 @@
+import { Buffer } from 'buffer';
+import * as fs from 'fs';
+import * as tmp from 'tmp';
+import * as stream from 'stream';
+
+import * as mongodb from 'mongodb';
+import * as crypto from 'crypto';
+import * as _gm from 'gm';
+import * as debug from 'debug';
+import fileType = require('file-type');
+import prominence = require('prominence');
+
+import DriveFile, { getGridFSBucket } from '../../models/drive-file';
+import DriveFolder from '../../models/drive-folder';
+import { pack } from '../../models/drive-file';
+import event, { publishDriveStream } from '../event';
+import getAcct from '../user/get-acct';
+import config from '../../conf';
+
+const gm = _gm.subClass({
+ imageMagick: true
+});
+
+const log = debug('misskey:drive:add-file');
+
+const tmpFile = (): Promise<string> => new Promise((resolve, reject) => {
+ tmp.file((e, path) => {
+ if (e) return reject(e);
+ resolve(path);
+ });
+});
+
+const addToGridFS = (name: string, readable: stream.Readable, type: string, metadata: any): Promise<any> =>
+ getGridFSBucket()
+ .then(bucket => new Promise((resolve, reject) => {
+ const writeStream = bucket.openUploadStream(name, { contentType: type, metadata });
+ writeStream.once('finish', (doc) => { resolve(doc); });
+ writeStream.on('error', reject);
+ readable.pipe(writeStream);
+ }));
+
+const addFile = async (
+ user: any,
+ path: string,
+ name: string = null,
+ comment: string = null,
+ folderId: mongodb.ObjectID = null,
+ force: boolean = false
+) => {
+ log(`registering ${name} (user: ${getAcct(user)}, path: ${path})`);
+
+ // Calculate hash, get content type and get file size
+ const [hash, [mime, ext], size] = await Promise.all([
+ // hash
+ ((): Promise<string> => new Promise((res, rej) => {
+ const readable = fs.createReadStream(path);
+ const hash = crypto.createHash('md5');
+ const chunks = [];
+ readable
+ .on('error', rej)
+ .pipe(hash)
+ .on('error', rej)
+ .on('data', (chunk) => chunks.push(chunk))
+ .on('end', () => {
+ const buffer = Buffer.concat(chunks);
+ res(buffer.toString('hex'));
+ });
+ }))(),
+ // mime
+ ((): Promise<[string, string | null]> => new Promise((res, rej) => {
+ const readable = fs.createReadStream(path);
+ readable
+ .on('error', rej)
+ .once('data', (buffer: Buffer) => {
+ readable.destroy();
+ const type = fileType(buffer);
+ if (type) {
+ return res([type.mime, type.ext]);
+ } else {
+ // 種類が同定できなかったら application/octet-stream にする
+ return res(['application/octet-stream', null]);
+ }
+ });
+ }))(),
+ // size
+ ((): Promise<number> => new Promise((res, rej) => {
+ fs.stat(path, (err, stats) => {
+ if (err) return rej(err);
+ res(stats.size);
+ });
+ }))()
+ ]);
+
+ log(`hash: ${hash}, mime: ${mime}, ext: ${ext}, size: ${size}`);
+
+ // detect name
+ const detectedName: string = name || (ext ? `untitled.${ext}` : 'untitled');
+
+ if (!force) {
+ // Check if there is a file with the same hash
+ const much = await DriveFile.findOne({
+ md5: hash,
+ 'metadata.userId': user._id
+ });
+
+ if (much !== null) {
+ log('file with same hash is found');
+ return much;
+ } else {
+ log('file with same hash is not found');
+ }
+ }
+
+ const [wh, averageColor, folder] = await Promise.all([
+ // Width and height (when image)
+ (async () => {
+ // 画像かどうか
+ if (!/^image\/.*$/.test(mime)) {
+ return null;
+ }
+
+ const imageType = mime.split('/')[1];
+
+ // 画像でもPNGかJPEGかGIFでないならスキップ
+ if (imageType != 'png' && imageType != 'jpeg' && imageType != 'gif') {
+ return null;
+ }
+
+ log('calculate image width and height...');
+
+ // Calculate width and height
+ const g = gm(fs.createReadStream(path), name);
+ const size = await prominence(g).size();
+
+ log(`image width and height is calculated: ${size.width}, ${size.height}`);
+
+ return [size.width, size.height];
+ })(),
+ // average color (when image)
+ (async () => {
+ // 画像かどうか
+ if (!/^image\/.*$/.test(mime)) {
+ return null;
+ }
+
+ const imageType = mime.split('/')[1];
+
+ // 画像でもPNGかJPEGでないならスキップ
+ if (imageType != 'png' && imageType != 'jpeg') {
+ return null;
+ }
+
+ log('calculate average color...');
+
+ const buffer = await prominence(gm(fs.createReadStream(path), name)
+ .setFormat('ppm')
+ .resize(1, 1)) // 1pxのサイズに縮小して平均色を取得するというハック
+ .toBuffer();
+
+ const r = buffer.readUInt8(buffer.length - 3);
+ const g = buffer.readUInt8(buffer.length - 2);
+ const b = buffer.readUInt8(buffer.length - 1);
+
+ log(`average color is calculated: ${r}, ${g}, ${b}`);
+
+ return [r, g, b];
+ })(),
+ // folder
+ (async () => {
+ if (!folderId) {
+ return null;
+ }
+ const driveFolder = await DriveFolder.findOne({
+ _id: folderId,
+ userId: user._id
+ });
+ if (!driveFolder) {
+ throw 'folder-not-found';
+ }
+ return driveFolder;
+ })(),
+ // usage checker
+ (async () => {
+ // Calculate drive usage
+ const usage = await DriveFile
+ .aggregate([{
+ $match: { 'metadata.userId': user._id }
+ }, {
+ $project: {
+ length: true
+ }
+ }, {
+ $group: {
+ _id: null,
+ usage: { $sum: '$length' }
+ }
+ }])
+ .then((aggregates: any[]) => {
+ if (aggregates.length > 0) {
+ return aggregates[0].usage;
+ }
+ return 0;
+ });
+
+ log(`drive usage is ${usage}`);
+
+ // If usage limit exceeded
+ if (usage + size > user.driveCapacity) {
+ throw 'no-free-space';
+ }
+ })()
+ ]);
+
+ const readable = fs.createReadStream(path);
+
+ const properties = {};
+
+ if (wh) {
+ properties['width'] = wh[0];
+ properties['height'] = wh[1];
+ }
+
+ if (averageColor) {
+ properties['avgColor'] = averageColor;
+ }
+
+ return addToGridFS(detectedName, readable, mime, {
+ userId: user._id,
+ folderId: folder !== null ? folder._id : null,
+ comment: comment,
+ properties: properties
+ });
+};
+
+/**
+ * Add file to drive
+ *
+ * @param user User who wish to add file
+ * @param file File path or readableStream
+ * @param comment Comment
+ * @param type File type
+ * @param folderId Folder ID
+ * @param force If set to true, forcibly upload the file even if there is a file with the same hash.
+ * @return Object that represents added file
+ */
+export default (user: any, file: string | stream.Readable, ...args) => new Promise<any>((resolve, reject) => {
+ // Get file path
+ new Promise((res: (v: [string, boolean]) => void, rej) => {
+ if (typeof file === 'string') {
+ res([file, false]);
+ return;
+ }
+ if (typeof file === 'object' && typeof file.read === 'function') {
+ tmpFile()
+ .then(path => {
+ const readable: stream.Readable = file;
+ const writable = fs.createWriteStream(path);
+ readable
+ .on('error', rej)
+ .on('end', () => {
+ res([path, true]);
+ })
+ .pipe(writable)
+ .on('error', rej);
+ })
+ .catch(rej);
+ }
+ rej(new Error('un-compatible file.'));
+ })
+ .then(([path, shouldCleanup]): Promise<any> => new Promise((res, rej) => {
+ addFile(user, path, ...args)
+ .then(file => {
+ res(file);
+ if (shouldCleanup) {
+ fs.unlink(path, (e) => {
+ if (e) log(e.stack);
+ });
+ }
+ })
+ .catch(rej);
+ }))
+ .then(file => {
+ log(`drive file has been created ${file._id}`);
+ resolve(file);
+
+ pack(file).then(serializedFile => {
+ // Publish drive_file_created event
+ event(user._id, 'drive_file_created', serializedFile);
+ publishDriveStream(user._id, 'file_created', serializedFile);
+
+ // Register to search database
+ if (config.elasticsearch.enable) {
+ const es = require('../../db/elasticsearch');
+ es.index({
+ index: 'misskey',
+ type: 'drive_file',
+ id: file._id.toString(),
+ body: {
+ name: file.name,
+ userId: user._id.toString()
+ }
+ });
+ }
+ });
+ })
+ .catch(reject);
+});
diff --git a/src/common/drive/upload_from_url.ts b/src/common/drive/upload_from_url.ts
new file mode 100644
index 0000000000..5dd9695936
--- /dev/null
+++ b/src/common/drive/upload_from_url.ts
@@ -0,0 +1,46 @@
+import * as URL from 'url';
+import { IDriveFile, validateFileName } from '../../models/drive-file';
+import create from './add-file';
+import * as debug from 'debug';
+import * as tmp from 'tmp';
+import * as fs from 'fs';
+import * as request from 'request';
+
+const log = debug('misskey:common:drive:upload_from_url');
+
+export default async (url, user, folderId = null): Promise<IDriveFile> => {
+ let name = URL.parse(url).pathname.split('/').pop();
+ if (!validateFileName(name)) {
+ name = null;
+ }
+
+ // Create temp file
+ const path = await new Promise((res: (string) => void, rej) => {
+ tmp.file((e, path) => {
+ if (e) return rej(e);
+ res(path);
+ });
+ });
+
+ // write content at URL to temp file
+ await new Promise((res, rej) => {
+ const writable = fs.createWriteStream(path);
+ request(url)
+ .on('error', rej)
+ .on('end', () => {
+ writable.close();
+ res(path);
+ })
+ .pipe(writable)
+ .on('error', rej);
+ });
+
+ const driveFile = await create(user, path, name, null, folderId);
+
+ // clean-up
+ fs.unlink(path, (e) => {
+ if (e) log(e.stack);
+ });
+
+ return driveFile;
+};
diff --git a/src/common/event.ts b/src/common/event.ts
new file mode 100644
index 0000000000..53520f11ce
--- /dev/null
+++ b/src/common/event.ts
@@ -0,0 +1,80 @@
+import * as mongo from 'mongodb';
+import * as redis from 'redis';
+import swPush from './push-sw';
+import config from '../conf';
+
+type ID = string | mongo.ObjectID;
+
+class MisskeyEvent {
+ private redisClient: redis.RedisClient;
+
+ constructor() {
+ // Connect to Redis
+ this.redisClient = redis.createClient(
+ config.redis.port, config.redis.host);
+ }
+
+ public publishUserStream(userId: ID, type: string, value?: any): void {
+ this.publish(`user-stream:${userId}`, type, typeof value === 'undefined' ? null : value);
+ }
+
+ public publishSw(userId: ID, type: string, value?: any): void {
+ swPush(userId, type, value);
+ }
+
+ public publishDriveStream(userId: ID, type: string, value?: any): void {
+ this.publish(`drive-stream:${userId}`, type, typeof value === 'undefined' ? null : value);
+ }
+
+ public publishPostStream(postId: ID, type: string, value?: any): void {
+ this.publish(`post-stream:${postId}`, type, typeof value === 'undefined' ? null : value);
+ }
+
+ public publishMessagingStream(userId: ID, otherpartyId: ID, type: string, value?: any): void {
+ this.publish(`messaging-stream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value);
+ }
+
+ public publishMessagingIndexStream(userId: ID, type: string, value?: any): void {
+ this.publish(`messaging-index-stream:${userId}`, type, typeof value === 'undefined' ? null : value);
+ }
+
+ public publishOthelloStream(userId: ID, type: string, value?: any): void {
+ this.publish(`othello-stream:${userId}`, type, typeof value === 'undefined' ? null : value);
+ }
+
+ public publishOthelloGameStream(gameId: ID, type: string, value?: any): void {
+ this.publish(`othello-game-stream:${gameId}`, type, typeof value === 'undefined' ? null : value);
+ }
+
+ public publishChannelStream(channelId: ID, type: string, value?: any): void {
+ this.publish(`channel-stream:${channelId}`, type, typeof value === 'undefined' ? null : value);
+ }
+
+ private publish(channel: string, type: string, value?: any): void {
+ const message = value == null ?
+ { type: type } :
+ { type: type, body: value };
+
+ this.redisClient.publish(`misskey:${channel}`, JSON.stringify(message));
+ }
+}
+
+const ev = new MisskeyEvent();
+
+export default ev.publishUserStream.bind(ev);
+
+export const pushSw = ev.publishSw.bind(ev);
+
+export const publishDriveStream = ev.publishDriveStream.bind(ev);
+
+export const publishPostStream = ev.publishPostStream.bind(ev);
+
+export const publishMessagingStream = ev.publishMessagingStream.bind(ev);
+
+export const publishMessagingIndexStream = ev.publishMessagingIndexStream.bind(ev);
+
+export const publishOthelloStream = ev.publishOthelloStream.bind(ev);
+
+export const publishOthelloGameStream = ev.publishOthelloGameStream.bind(ev);
+
+export const publishChannelStream = ev.publishChannelStream.bind(ev);
diff --git a/src/common/push-sw.ts b/src/common/push-sw.ts
new file mode 100644
index 0000000000..44c328e833
--- /dev/null
+++ b/src/common/push-sw.ts
@@ -0,0 +1,52 @@
+const push = require('web-push');
+import * as mongo from 'mongodb';
+import Subscription from '../models/sw-subscription';
+import config from '../conf';
+
+if (config.sw) {
+ // アプリケーションの連絡先と、サーバーサイドの鍵ペアの情報を登録
+ push.setVapidDetails(
+ config.maintainer.url,
+ config.sw.public_key,
+ config.sw.private_key);
+}
+
+export default async function(userId: mongo.ObjectID | string, type, body?) {
+ if (!config.sw) return;
+
+ if (typeof userId === 'string') {
+ userId = new mongo.ObjectID(userId);
+ }
+
+ // Fetch
+ const subscriptions = await Subscription.find({
+ userId: userId
+ });
+
+ subscriptions.forEach(subscription => {
+ const pushSubscription = {
+ endpoint: subscription.endpoint,
+ keys: {
+ auth: subscription.auth,
+ p256dh: subscription.publickey
+ }
+ };
+
+ push.sendNotification(pushSubscription, JSON.stringify({
+ type, body
+ })).catch(err => {
+ //console.log(err.statusCode);
+ //console.log(err.headers);
+ //console.log(err.body);
+
+ if (err.statusCode == 410) {
+ Subscription.remove({
+ userId: userId,
+ endpoint: subscription.endpoint,
+ auth: subscription.auth,
+ publickey: subscription.publickey
+ });
+ }
+ });
+ });
+}
diff --git a/src/common/remote/activitypub/act/create.ts b/src/common/remote/activitypub/act/create.ts
new file mode 100644
index 0000000000..6c62f7ab9e
--- /dev/null
+++ b/src/common/remote/activitypub/act/create.ts
@@ -0,0 +1,9 @@
+import create from '../create';
+
+export default (resolver, actor, activity) => {
+ if ('actor' in activity && actor.account.uri !== activity.actor) {
+ throw new Error;
+ }
+
+ return create(resolver, actor, activity.object);
+};
diff --git a/src/common/remote/activitypub/act/index.ts b/src/common/remote/activitypub/act/index.ts
new file mode 100644
index 0000000000..0f4084a61e
--- /dev/null
+++ b/src/common/remote/activitypub/act/index.ts
@@ -0,0 +1,22 @@
+import create from './create';
+import createObject from '../create';
+import Resolver from '../resolver';
+
+export default (actor, value) => {
+ return (new Resolver).resolve(value).then(resolved => Promise.all(resolved.map(async asyncResult => {
+ const { resolver, object } = await asyncResult;
+ const created = await (await createObject(resolver, actor, [object]))[0];
+
+ if (created !== null) {
+ return created;
+ }
+
+ switch (object.type) {
+ case 'Create':
+ return create(resolver, actor, object);
+
+ default:
+ return null;
+ }
+ })));
+}
diff --git a/src/common/remote/activitypub/create.ts b/src/common/remote/activitypub/create.ts
new file mode 100644
index 0000000000..4aaaeb3060
--- /dev/null
+++ b/src/common/remote/activitypub/create.ts
@@ -0,0 +1,86 @@
+import { JSDOM } from 'jsdom';
+import config from '../../../conf';
+import Post from '../../../models/post';
+import RemoteUserObject, { IRemoteUserObject } from '../../../models/remote-user-object';
+import uploadFromUrl from '../../drive/upload_from_url';
+const createDOMPurify = require('dompurify');
+
+function createRemoteUserObject($ref, $id, { id }) {
+ const object = { $ref, $id };
+
+ if (!id) {
+ return { object };
+ }
+
+ return RemoteUserObject.insert({ uri: id, object });
+}
+
+async function createImage(actor, object) {
+ if ('attributedTo' in object && actor.account.uri !== object.attributedTo) {
+ throw new Error;
+ }
+
+ const { _id } = await uploadFromUrl(object.url, actor);
+ return createRemoteUserObject('driveFiles.files', _id, object);
+}
+
+async function createNote(resolver, actor, object) {
+ if ('attributedTo' in object && actor.account.uri !== object.attributedTo) {
+ throw new Error;
+ }
+
+ const mediaIds = 'attachment' in object &&
+ (await Promise.all(await create(resolver, actor, object.attachment)))
+ .filter(media => media !== null && media.object.$ref === 'driveFiles.files')
+ .map(({ object }) => object.$id);
+
+ const { window } = new JSDOM(object.content);
+
+ const { _id } = await Post.insert({
+ channelId: undefined,
+ index: undefined,
+ createdAt: new Date(object.published),
+ mediaIds,
+ replyId: undefined,
+ repostId: undefined,
+ poll: undefined,
+ text: window.document.body.textContent,
+ textHtml: object.content && createDOMPurify(window).sanitize(object.content),
+ userId: actor._id,
+ appId: null,
+ viaMobile: false,
+ geo: undefined
+ });
+
+ // Register to search database
+ if (object.content && config.elasticsearch.enable) {
+ const es = require('../../db/elasticsearch');
+
+ es.index({
+ index: 'misskey',
+ type: 'post',
+ id: _id.toString(),
+ body: {
+ text: window.document.body.textContent
+ }
+ });
+ }
+
+ return createRemoteUserObject('posts', _id, object);
+}
+
+export default async function create(parentResolver, actor, value): Promise<Promise<IRemoteUserObject>[]> {
+ const results = await parentResolver.resolveRemoteUserObjects(value);
+
+ return results.map(asyncResult => asyncResult.then(({ resolver, object }) => {
+ switch (object.type) {
+ case 'Image':
+ return createImage(actor, object);
+
+ case 'Note':
+ return createNote(resolver, actor, object);
+ }
+
+ return null;
+ }));
+};
diff --git a/src/common/remote/activitypub/resolve-person.ts b/src/common/remote/activitypub/resolve-person.ts
new file mode 100644
index 0000000000..c7c131b0ea
--- /dev/null
+++ b/src/common/remote/activitypub/resolve-person.ts
@@ -0,0 +1,104 @@
+import { JSDOM } from 'jsdom';
+import { toUnicode } from 'punycode';
+import User, { validateUsername, isValidName, isValidDescription } from '../../../models/user';
+import queue from '../../../queue';
+import webFinger from '../webfinger';
+import create from './create';
+import Resolver from './resolver';
+
+async function isCollection(collection) {
+ return ['Collection', 'OrderedCollection'].includes(collection.type);
+}
+
+export default async (value, usernameLower, hostLower, acctLower) => {
+ if (!validateUsername(usernameLower)) {
+ throw new Error;
+ }
+
+ const { resolver, object } = await (new Resolver).resolveOne(value);
+
+ if (
+ object === null ||
+ object.type !== 'Person' ||
+ typeof object.preferredUsername !== 'string' ||
+ object.preferredUsername.toLowerCase() !== usernameLower ||
+ !isValidName(object.name) ||
+ !isValidDescription(object.summary)
+ ) {
+ throw new Error;
+ }
+
+ const [followers, following, outbox, finger] = await Promise.all([
+ resolver.resolveOne(object.followers).then(
+ resolved => isCollection(resolved.object) ? resolved.object : null,
+ () => null
+ ),
+ resolver.resolveOne(object.following).then(
+ resolved => isCollection(resolved.object) ? resolved.object : null,
+ () => null
+ ),
+ resolver.resolveOne(object.outbox).then(
+ resolved => isCollection(resolved.object) ? resolved.object : null,
+ () => null
+ ),
+ webFinger(object.id, acctLower),
+ ]);
+
+ const summaryDOM = JSDOM.fragment(object.summary);
+
+ // Create user
+ const user = await User.insert({
+ avatarId: null,
+ bannerId: null,
+ createdAt: Date.parse(object.published),
+ description: summaryDOM.textContent,
+ followersCount: followers.totalItem,
+ followingCount: following.totalItem,
+ name: object.name,
+ postsCount: outbox.totalItem,
+ driveCapacity: 1024 * 1024 * 8, // 8MiB
+ username: object.preferredUsername,
+ usernameLower,
+ host: toUnicode(finger.subject.replace(/^.*?@/, '')),
+ hostLower,
+ account: {
+ uri: object.id,
+ },
+ });
+
+ queue.create('http', {
+ type: 'performActivityPub',
+ actor: user._id,
+ outbox
+ }).save();
+
+ const [avatarId, bannerId] = await Promise.all([
+ object.icon,
+ object.image
+ ].map(async value => {
+ if (value === undefined) {
+ return null;
+ }
+
+ try {
+ const created = await create(resolver, user, value);
+
+ await Promise.all(created.map(asyncCreated => asyncCreated.then(created => {
+ if (created !== null && created.object.$ref === 'driveFiles.files') {
+ throw created.object.$id;
+ }
+ }, () => {})));
+
+ return null;
+ } catch (id) {
+ return id;
+ }
+ }));
+
+ User.update({ _id: user._id }, { $set: { avatarId, bannerId } });
+
+ user.avatarId = avatarId;
+ user.bannerId = bannerId;
+
+ return user;
+};
diff --git a/src/common/remote/activitypub/resolver.ts b/src/common/remote/activitypub/resolver.ts
new file mode 100644
index 0000000000..50ac1b0b19
--- /dev/null
+++ b/src/common/remote/activitypub/resolver.ts
@@ -0,0 +1,97 @@
+import RemoteUserObject from '../../../models/remote-user-object';
+import { IObject } from './type';
+const request = require('request-promise-native');
+
+type IResult = {
+ resolver: Resolver;
+ object: IObject;
+};
+
+async function resolveUnrequestedOne(this: Resolver, value) {
+ if (typeof value !== 'string') {
+ return { resolver: this, object: value };
+ }
+
+ const resolver = new Resolver(this.requesting);
+
+ resolver.requesting.add(value);
+
+ const object = await request({
+ url: value,
+ headers: {
+ Accept: 'application/activity+json, application/ld+json'
+ },
+ json: true
+ });
+
+ if (object === null || (
+ Array.isArray(object['@context']) ?
+ !object['@context'].includes('https://www.w3.org/ns/activitystreams') :
+ object['@context'] !== 'https://www.w3.org/ns/activitystreams'
+ )) {
+ throw new Error;
+ }
+
+ return { resolver, object };
+}
+
+async function resolveCollection(this: Resolver, value) {
+ if (Array.isArray(value)) {
+ return value;
+ }
+
+ const resolved = typeof value === 'string' ?
+ await resolveUnrequestedOne.call(this, value) :
+ value;
+
+ switch (resolved.type) {
+ case 'Collection':
+ return resolved.items;
+
+ case 'OrderedCollection':
+ return resolved.orderedItems;
+
+ default:
+ return [resolved];
+ }
+}
+
+export default class Resolver {
+ requesting: Set<string>;
+
+ constructor(iterable?: Iterable<string>) {
+ this.requesting = new Set(iterable);
+ }
+
+ async resolve(value): Promise<Promise<IResult>[]> {
+ const collection = await resolveCollection.call(this, value);
+
+ return collection
+ .filter(element => !this.requesting.has(element))
+ .map(resolveUnrequestedOne.bind(this));
+ }
+
+ resolveOne(value) {
+ if (this.requesting.has(value)) {
+ throw new Error;
+ }
+
+ return resolveUnrequestedOne.call(this, value);
+ }
+
+ async resolveRemoteUserObjects(value) {
+ const collection = await resolveCollection.call(this, value);
+
+ return collection.filter(element => !this.requesting.has(element)).map(element => {
+ if (typeof element === 'string') {
+ const object = RemoteUserObject.findOne({ uri: element });
+
+ if (object !== null) {
+ return object;
+ }
+ }
+
+ return resolveUnrequestedOne.call(this, element);
+ });
+ }
+}
diff --git a/src/common/remote/activitypub/type.ts b/src/common/remote/activitypub/type.ts
new file mode 100644
index 0000000000..5c4750e140
--- /dev/null
+++ b/src/common/remote/activitypub/type.ts
@@ -0,0 +1,3 @@
+export type IObject = {
+ type: string;
+}
diff --git a/src/common/remote/resolve-user.ts b/src/common/remote/resolve-user.ts
new file mode 100644
index 0000000000..13d155830e
--- /dev/null
+++ b/src/common/remote/resolve-user.ts
@@ -0,0 +1,26 @@
+import { toUnicode, toASCII } from 'punycode';
+import User from '../../models/user';
+import resolvePerson from './activitypub/resolve-person';
+import webFinger from './webfinger';
+
+export default async (username, host, option) => {
+ const usernameLower = username.toLowerCase();
+ const hostLowerAscii = toASCII(host).toLowerCase();
+ const hostLower = toUnicode(hostLowerAscii);
+
+ let user = await User.findOne({ usernameLower, hostLower }, option);
+
+ if (user === null) {
+ const acctLower = `${usernameLower}@${hostLowerAscii}`;
+
+ const finger = await webFinger(acctLower, acctLower);
+ const self = finger.links.find(link => link.rel && link.rel.toLowerCase() === 'self');
+ if (!self) {
+ throw new Error;
+ }
+
+ user = await resolvePerson(self.href, usernameLower, hostLower, acctLower);
+ }
+
+ return user;
+};
diff --git a/src/common/remote/webfinger.ts b/src/common/remote/webfinger.ts
new file mode 100644
index 0000000000..23f0aaa55f
--- /dev/null
+++ b/src/common/remote/webfinger.ts
@@ -0,0 +1,25 @@
+const WebFinger = require('webfinger.js');
+
+const webFinger = new WebFinger({});
+
+type ILink = {
+ href: string;
+ rel: string;
+}
+
+type IWebFinger = {
+ links: Array<ILink>;
+ subject: string;
+}
+
+export default (query, verifier): Promise<IWebFinger> => new Promise((res, rej) => webFinger.lookup(query, (error, result) => {
+ if (error) {
+ return rej(error);
+ }
+
+ if (result.object.subject.toLowerCase().replace(/^acct:/, '') !== verifier) {
+ return rej('WebFinger verfification failed');
+ }
+
+ res(result.object);
+}));