diff options
| author | syuilo <syuilotan@yahoo.co.jp> | 2018-04-02 04:15:27 +0900 |
|---|---|---|
| committer | syuilo <syuilotan@yahoo.co.jp> | 2018-04-02 04:15:27 +0900 |
| commit | cd2542e0fd8578f6e41114ffebbda1f16f7d04ce (patch) | |
| tree | c339b7808fc2a3d72ae30cb86ddb7b9c21852652 /src/remote | |
| parent | Refactor (diff) | |
| download | sharkey-cd2542e0fd8578f6e41114ffebbda1f16f7d04ce.tar.gz sharkey-cd2542e0fd8578f6e41114ffebbda1f16f7d04ce.tar.bz2 sharkey-cd2542e0fd8578f6e41114ffebbda1f16f7d04ce.zip | |
Refactor
Diffstat (limited to 'src/remote')
| -rw-r--r-- | src/remote/activitypub/act/create.ts | 9 | ||||
| -rw-r--r-- | src/remote/activitypub/act/index.ts | 22 | ||||
| -rw-r--r-- | src/remote/activitypub/create.ts | 87 | ||||
| -rw-r--r-- | src/remote/activitypub/renderer/context.ts | 5 | ||||
| -rw-r--r-- | src/remote/activitypub/renderer/document.ts | 7 | ||||
| -rw-r--r-- | src/remote/activitypub/renderer/follow.ts | 8 | ||||
| -rw-r--r-- | src/remote/activitypub/renderer/hashtag.ts | 7 | ||||
| -rw-r--r-- | src/remote/activitypub/renderer/image.ts | 6 | ||||
| -rw-r--r-- | src/remote/activitypub/renderer/key.ts | 10 | ||||
| -rw-r--r-- | src/remote/activitypub/renderer/note.ts | 44 | ||||
| -rw-r--r-- | src/remote/activitypub/renderer/ordered-collection.ts | 6 | ||||
| -rw-r--r-- | src/remote/activitypub/renderer/person.ts | 20 | ||||
| -rw-r--r-- | src/remote/activitypub/resolve-person.ts | 109 | ||||
| -rw-r--r-- | src/remote/activitypub/resolver.ts | 97 | ||||
| -rw-r--r-- | src/remote/activitypub/type.ts | 3 | ||||
| -rw-r--r-- | src/remote/resolve-user.ts | 26 | ||||
| -rw-r--r-- | src/remote/webfinger.ts | 25 |
17 files changed, 491 insertions, 0 deletions
diff --git a/src/remote/activitypub/act/create.ts b/src/remote/activitypub/act/create.ts new file mode 100644 index 0000000000..9eb74800ea --- /dev/null +++ b/src/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/remote/activitypub/act/index.ts b/src/remote/activitypub/act/index.ts new file mode 100644 index 0000000000..a76983638f --- /dev/null +++ b/src/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 promisedResult => { + const { resolver, object } = await promisedResult; + 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/remote/activitypub/create.ts b/src/remote/activitypub/create.ts new file mode 100644 index 0000000000..e610232f51 --- /dev/null +++ b/src/remote/activitypub/create.ts @@ -0,0 +1,87 @@ +import { JSDOM } from 'jsdom'; +import config from '../../conf'; +import Post from '../../models/post'; +import RemoteUserObject, { IRemoteUserObject } from '../../models/remote-user-object'; +import uploadFromUrl from '../../common/drive/upload-from-url'; +import Resolver from './resolver'; +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: Resolver, actor, value): Promise<Array<Promise<IRemoteUserObject>>> { + const results = await parentResolver.resolveRemoteUserObjects(value); + + return results.map(promisedResult => promisedResult.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/remote/activitypub/renderer/context.ts b/src/remote/activitypub/renderer/context.ts new file mode 100644 index 0000000000..b56f727ae7 --- /dev/null +++ b/src/remote/activitypub/renderer/context.ts @@ -0,0 +1,5 @@ +export default [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + { Hashtag: 'as:Hashtag' } +]; diff --git a/src/remote/activitypub/renderer/document.ts b/src/remote/activitypub/renderer/document.ts new file mode 100644 index 0000000000..fdd52c1b6c --- /dev/null +++ b/src/remote/activitypub/renderer/document.ts @@ -0,0 +1,7 @@ +import config from '../../../conf'; + +export default ({ _id, contentType }) => ({ + type: 'Document', + mediaType: contentType, + url: `${config.drive_url}/${_id}` +}); diff --git a/src/remote/activitypub/renderer/follow.ts b/src/remote/activitypub/renderer/follow.ts new file mode 100644 index 0000000000..c99bc375a2 --- /dev/null +++ b/src/remote/activitypub/renderer/follow.ts @@ -0,0 +1,8 @@ +import config from '../../../conf'; +import { IRemoteUser } from '../../../models/user'; + +export default ({ username }, followee: IRemoteUser) => ({ + type: 'Follow', + actor: `${config.url}/@${username}`, + object: followee.account.uri +}); diff --git a/src/remote/activitypub/renderer/hashtag.ts b/src/remote/activitypub/renderer/hashtag.ts new file mode 100644 index 0000000000..c2d261ed21 --- /dev/null +++ b/src/remote/activitypub/renderer/hashtag.ts @@ -0,0 +1,7 @@ +import config from '../../../conf'; + +export default tag => ({ + type: 'Hashtag', + href: `${config.url}/search?q=#${encodeURIComponent(tag)}`, + name: '#' + tag +}); diff --git a/src/remote/activitypub/renderer/image.ts b/src/remote/activitypub/renderer/image.ts new file mode 100644 index 0000000000..3d1c71cb95 --- /dev/null +++ b/src/remote/activitypub/renderer/image.ts @@ -0,0 +1,6 @@ +import config from '../../../conf'; + +export default ({ _id }) => ({ + type: 'Image', + url: `${config.drive_url}/${_id}` +}); diff --git a/src/remote/activitypub/renderer/key.ts b/src/remote/activitypub/renderer/key.ts new file mode 100644 index 0000000000..904a69e081 --- /dev/null +++ b/src/remote/activitypub/renderer/key.ts @@ -0,0 +1,10 @@ +import config from '../../../conf'; +import { extractPublic } from '../../../crypto_key'; +import { ILocalUser } from '../../../models/user'; + +export default (user: ILocalUser) => ({ + id: `${config.url}/@${user.username}/publickey`, + type: 'Key', + owner: `${config.url}/@${user.username}`, + publicKeyPem: extractPublic(user.account.keypair) +}); diff --git a/src/remote/activitypub/renderer/note.ts b/src/remote/activitypub/renderer/note.ts new file mode 100644 index 0000000000..74806f14b4 --- /dev/null +++ b/src/remote/activitypub/renderer/note.ts @@ -0,0 +1,44 @@ +import renderDocument from './document'; +import renderHashtag from './hashtag'; +import config from '../../../conf'; +import DriveFile from '../../../models/drive-file'; +import Post from '../../../models/post'; +import User from '../../../models/user'; + +export default async (user, post) => { + const promisedFiles = DriveFile.find({ _id: { $in: post.mediaIds } }); + let inReplyTo; + + if (post.replyId) { + const inReplyToPost = await Post.findOne({ + _id: post.replyId, + }); + + if (inReplyToPost !== null) { + const inReplyToUser = await User.findOne({ + _id: post.userId, + }); + + if (inReplyToUser !== null) { + inReplyTo = `${config.url}@${inReplyToUser.username}/${inReplyToPost._id}`; + } + } + } else { + inReplyTo = null; + } + + const attributedTo = `${config.url}/@${user.username}`; + + return { + id: `${attributedTo}/${post._id}`, + type: 'Note', + attributedTo, + content: post.textHtml, + published: post.createdAt.toISOString(), + to: 'https://www.w3.org/ns/activitystreams#Public', + cc: `${attributedTo}/followers`, + inReplyTo, + attachment: (await promisedFiles).map(renderDocument), + tag: post.tags.map(renderHashtag) + }; +}; diff --git a/src/remote/activitypub/renderer/ordered-collection.ts b/src/remote/activitypub/renderer/ordered-collection.ts new file mode 100644 index 0000000000..2ca0f77354 --- /dev/null +++ b/src/remote/activitypub/renderer/ordered-collection.ts @@ -0,0 +1,6 @@ +export default (id, totalItems, orderedItems) => ({ + id, + type: 'OrderedCollection', + totalItems, + orderedItems +}); diff --git a/src/remote/activitypub/renderer/person.ts b/src/remote/activitypub/renderer/person.ts new file mode 100644 index 0000000000..c6c7893165 --- /dev/null +++ b/src/remote/activitypub/renderer/person.ts @@ -0,0 +1,20 @@ +import renderImage from './image'; +import renderKey from './key'; +import config from '../../../conf'; + +export default user => { + const id = `${config.url}/@${user.username}`; + + return { + type: 'Person', + id, + inbox: `${id}/inbox`, + outbox: `${id}/outbox`, + preferredUsername: user.username, + name: user.name, + summary: user.description, + icon: user.avatarId && renderImage({ _id: user.avatarId }), + image: user.bannerId && renderImage({ _id: user.bannerId }), + publicKey: renderKey(user) + }; +}; diff --git a/src/remote/activitypub/resolve-person.ts b/src/remote/activitypub/resolve-person.ts new file mode 100644 index 0000000000..d928e7ce19 --- /dev/null +++ b/src/remote/activitypub/resolve-person.ts @@ -0,0 +1,109 @@ +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: { + publicKey: { + id: object.publicKey.id, + publicKeyPem: object.publicKey.publicKeyPem + }, + inbox: object.inbox, + 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/remote/activitypub/resolver.ts b/src/remote/activitypub/resolver.ts new file mode 100644 index 0000000000..ebfe25fe7e --- /dev/null +++ b/src/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; +}; + +export default class Resolver { + private requesting: Set<string>; + + constructor(iterable?: Iterable<string>) { + this.requesting = new Set(iterable); + } + + private async resolveUnrequestedOne(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 }; + } + + private async resolveCollection(value) { + if (Array.isArray(value)) { + return value; + } + + const resolved = typeof value === 'string' ? + await this.resolveUnrequestedOne(value) : + value; + + switch (resolved.type) { + case 'Collection': + return resolved.items; + + case 'OrderedCollection': + return resolved.orderedItems; + + default: + return [resolved]; + } + } + + public async resolve(value): Promise<Array<Promise<IResult>>> { + const collection = await this.resolveCollection(value); + + return collection + .filter(element => !this.requesting.has(element)) + .map(this.resolveUnrequestedOne.bind(this)); + } + + public resolveOne(value) { + if (this.requesting.has(value)) { + throw new Error(); + } + + return this.resolveUnrequestedOne(value); + } + + public async resolveRemoteUserObjects(value) { + const collection = await this.resolveCollection(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 this.resolveUnrequestedOne(element); + }); + } +} diff --git a/src/remote/activitypub/type.ts b/src/remote/activitypub/type.ts new file mode 100644 index 0000000000..94e2c350a2 --- /dev/null +++ b/src/remote/activitypub/type.ts @@ -0,0 +1,3 @@ +export type IObject = { + type: string; +}; diff --git a/src/remote/resolve-user.ts b/src/remote/resolve-user.ts new file mode 100644 index 0000000000..a393092839 --- /dev/null +++ b/src/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/remote/webfinger.ts b/src/remote/webfinger.ts new file mode 100644 index 0000000000..fec5da689c --- /dev/null +++ b/src/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: 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); +})); |