diff options
| author | tamaina <tamaina@hotmail.co.jp> | 2018-04-11 20:27:09 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2018-04-11 20:27:09 +0900 |
| commit | d43fe853c3605696e2e57e240845d0fc9c284f61 (patch) | |
| tree | 838914e262c0fca5737588a7bba64e2b9f3d8e5f /src/remote/activitypub | |
| parent | Update README.md (diff) | |
| parent | wip #1443 (diff) | |
| download | misskey-d43fe853c3605696e2e57e240845d0fc9c284f61.tar.gz misskey-d43fe853c3605696e2e57e240845d0fc9c284f61.tar.bz2 misskey-d43fe853c3605696e2e57e240845d0fc9c284f61.zip | |
Merge pull request #1 from syuilo/master
追従
Diffstat (limited to 'src/remote/activitypub')
33 files changed, 1023 insertions, 0 deletions
diff --git a/src/remote/activitypub/kernel/announce/index.ts b/src/remote/activitypub/kernel/announce/index.ts new file mode 100644 index 0000000000..a2cf2d5762 --- /dev/null +++ b/src/remote/activitypub/kernel/announce/index.ts @@ -0,0 +1,35 @@ +import * as debug from 'debug'; + +import Resolver from '../../resolver'; +import { IRemoteUser } from '../../../../models/user'; +import announceNote from './note'; +import { IAnnounce } from '../../type'; + +const log = debug('misskey:activitypub'); + +export default async (actor: IRemoteUser, activity: IAnnounce): Promise<void> => { + const uri = activity.id || activity; + + log(`Announce: ${uri}`); + + const resolver = new Resolver(); + + let object; + + try { + object = await resolver.resolve(activity.object); + } catch (e) { + log(`Resolution failed: ${e}`); + throw e; + } + + switch (object.type) { + case 'Note': + announceNote(resolver, actor, activity, object); + break; + + default: + console.warn(`Unknown announce type: ${object.type}`); + break; + } +}; diff --git a/src/remote/activitypub/kernel/announce/note.ts b/src/remote/activitypub/kernel/announce/note.ts new file mode 100644 index 0000000000..68fb23c97f --- /dev/null +++ b/src/remote/activitypub/kernel/announce/note.ts @@ -0,0 +1,45 @@ +import * as debug from 'debug'; + +import Resolver from '../../resolver'; +import post from '../../../../services/note/create'; +import { IRemoteUser } from '../../../../models/user'; +import { IAnnounce, INote } from '../../type'; +import { fetchNote, resolveNote } from '../../objects/note'; + +const log = debug('misskey:activitypub'); + +/** + * アナウンスアクティビティを捌きます + */ +export default async function(resolver: Resolver, actor: IRemoteUser, activity: IAnnounce, note: INote): Promise<void> { + const uri = activity.id || activity; + + if (typeof uri !== 'string') { + throw new Error('invalid announce'); + } + + // 既に同じURIを持つものが登録されていないかチェック + const exist = await fetchNote(uri); + if (exist) { + return; + } + + const renote = await resolveNote(note); + + log(`Creating the (Re)Note: ${uri}`); + + //#region Visibility + let visibility = 'public'; + if (!activity.to.includes('https://www.w3.org/ns/activitystreams#Public')) visibility = 'unlisted'; + if (activity.cc.length == 0) visibility = 'private'; + // TODO + if (visibility != 'public') throw new Error('unspported visibility'); + //#endergion + + await post(actor, { + createdAt: new Date(activity.published), + renote, + visibility, + uri + }); +} diff --git a/src/remote/activitypub/kernel/create/image.ts b/src/remote/activitypub/kernel/create/image.ts new file mode 100644 index 0000000000..ea36545f0c --- /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<void> { + await createImage(image.url, actor); +} diff --git a/src/remote/activitypub/kernel/create/index.ts b/src/remote/activitypub/kernel/create/index.ts new file mode 100644 index 0000000000..e11bcac811 --- /dev/null +++ b/src/remote/activitypub/kernel/create/index.ts @@ -0,0 +1,40 @@ +import * as debug from 'debug'; + +import Resolver from '../../resolver'; +import { IRemoteUser } from '../../../../models/user'; +import createNote from './note'; +import createImage from './image'; +import { ICreate } from '../../type'; + +const log = debug('misskey:activitypub'); + +export default async (actor: IRemoteUser, activity: ICreate): Promise<void> => { + const uri = activity.id || activity; + + log(`Create: ${uri}`); + + const resolver = new Resolver(); + + let object; + + try { + object = await resolver.resolve(activity.object); + } catch (e) { + log(`Resolution failed: ${e}`); + throw e; + } + + switch (object.type) { + case 'Image': + createImage(actor, object); + break; + + case 'Note': + createNote(resolver, actor, object); + break; + + default: + console.warn(`Unknown type: ${object.type}`); + break; + } +}; diff --git a/src/remote/activitypub/kernel/create/note.ts b/src/remote/activitypub/kernel/create/note.ts new file mode 100644 index 0000000000..530cf6483f --- /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<void> { + const exist = await fetchNote(note); + if (exist == null) { + await createNote(note); + } +} diff --git a/src/remote/activitypub/kernel/delete/index.ts b/src/remote/activitypub/kernel/delete/index.ts new file mode 100644 index 0000000000..10b47dc4ca --- /dev/null +++ b/src/remote/activitypub/kernel/delete/index.ts @@ -0,0 +1,36 @@ +import Resolver from '../../resolver'; +import deleteNote from './note'; +import Note from '../../../../models/note'; +import { IRemoteUser } from '../../../../models/user'; + +/** + * 削除アクティビティを捌きます + */ +export default async (actor: IRemoteUser, activity): Promise<void> => { + if ('actor' in activity && actor.uri !== activity.actor) { + throw new Error('invalid actor'); + } + + const resolver = new Resolver(); + + const object = await resolver.resolve(activity.object); + + const uri = (object as any).id; + + switch (object.type) { + case 'Note': + deleteNote(actor, uri); + break; + + case 'Tombstone': + const note = await Note.findOne({ uri }); + if (note != null) { + deleteNote(actor, uri); + } + break; + + default: + console.warn(`Unknown type: ${object.type}`); + break; + } +}; diff --git a/src/remote/activitypub/kernel/delete/note.ts b/src/remote/activitypub/kernel/delete/note.ts new file mode 100644 index 0000000000..64c342d39b --- /dev/null +++ b/src/remote/activitypub/kernel/delete/note.ts @@ -0,0 +1,30 @@ +import * as debug from 'debug'; + +import Note from '../../../../models/note'; +import { IRemoteUser } from '../../../../models/user'; + +const log = debug('misskey:activitypub'); + +export default async function(actor: IRemoteUser, uri: string): Promise<void> { + log(`Deleting the Note: ${uri}`); + + const note = await Note.findOne({ uri }); + + if (note == null) { + throw new Error('note not found'); + } + + if (!note.userId.equals(actor._id)) { + throw new Error('投稿を削除しようとしているユーザーは投稿の作成者ではありません'); + } + + Note.update({ _id: note._id }, { + $set: { + deletedAt: new Date(), + text: null, + textHtml: null, + mediaIds: [], + poll: null + } + }); +} diff --git a/src/remote/activitypub/kernel/follow.ts b/src/remote/activitypub/kernel/follow.ts new file mode 100644 index 0000000000..6a8b5a1bec --- /dev/null +++ b/src/remote/activitypub/kernel/follow.ts @@ -0,0 +1,24 @@ +import User, { IRemoteUser } from '../../../models/user'; +import config from '../../../config'; +import follow from '../../../services/following/create'; +import { IFollow } from '../type'; + +export default async (actor: IRemoteUser, activity: IFollow): Promise<void> => { + const id = typeof activity.object == 'string' ? activity.object : activity.object.id; + + if (!id.startsWith(config.url + '/')) { + return null; + } + + const followee = await User.findOne({ _id: id.split('/').pop() }); + + if (followee === null) { + throw new Error('followee not found'); + } + + if (followee.host != null) { + throw new Error('フォローしようとしているユーザーはローカルユーザーではありません'); + } + + await follow(actor, followee, activity); +}; diff --git a/src/remote/activitypub/kernel/index.ts b/src/remote/activitypub/kernel/index.ts new file mode 100644 index 0000000000..15ea9494ae --- /dev/null +++ b/src/remote/activitypub/kernel/index.ts @@ -0,0 +1,51 @@ +import { Object } from '../type'; +import { IRemoteUser } from '../../../models/user'; +import create from './create'; +import performDeleteActivity from './delete'; +import follow from './follow'; +import undo from './undo'; +import like from './like'; +import announce from './announce'; + +const self = async (actor: IRemoteUser, activity: Object): Promise<void> => { + switch (activity.type) { + case 'Create': + await create(actor, activity); + break; + + case 'Delete': + await performDeleteActivity(actor, activity); + break; + + case 'Follow': + await follow(actor, activity); + break; + + case 'Accept': + // noop + break; + + case 'Announce': + await announce(actor, activity); + break; + + case 'Like': + await like(actor, activity); + break; + + case 'Undo': + await undo(actor, activity); + break; + + case 'Collection': + case 'OrderedCollection': + // TODO + break; + + default: + console.warn(`unknown activity type: ${(activity as any).type}`); + return null; + } +}; + +export default self; diff --git a/src/remote/activitypub/kernel/like.ts b/src/remote/activitypub/kernel/like.ts new file mode 100644 index 0000000000..4941608588 --- /dev/null +++ b/src/remote/activitypub/kernel/like.ts @@ -0,0 +1,20 @@ +import Note from '../../../models/note'; +import { IRemoteUser } from '../../../models/user'; +import { ILike } from '../type'; +import create from '../../../services/note/reaction/create'; + +export default async (actor: IRemoteUser, activity: ILike) => { + const id = typeof activity.object == 'string' ? activity.object : activity.object.id; + + // Transform: + // https://misskey.ex/notes/xxxx to + // xxxx + const noteId = id.split('/').pop(); + + const note = await Note.findOne({ _id: noteId }); + if (note === null) { + throw new Error(); + } + + await create(actor, note, 'pudding'); +}; diff --git a/src/remote/activitypub/kernel/undo/follow.ts b/src/remote/activitypub/kernel/undo/follow.ts new file mode 100644 index 0000000000..a85cb0305d --- /dev/null +++ b/src/remote/activitypub/kernel/undo/follow.ts @@ -0,0 +1,24 @@ +import User, { IRemoteUser } from '../../../../models/user'; +import config from '../../../../config'; +import unfollow from '../../../../services/following/delete'; +import { IFollow } from '../../type'; + +export default async (actor: IRemoteUser, activity: IFollow): Promise<void> => { + const id = typeof activity.object == 'string' ? activity.object : activity.object.id; + + if (!id.startsWith(config.url + '/')) { + return null; + } + + const followee = await User.findOne({ _id: id.split('/').pop() }); + + if (followee === null) { + throw new Error('followee not found'); + } + + if (followee.host != null) { + throw new Error('フォロー解除しようとしているユーザーはローカルユーザーではありません'); + } + + await unfollow(actor, followee, activity); +}; diff --git a/src/remote/activitypub/kernel/undo/index.ts b/src/remote/activitypub/kernel/undo/index.ts new file mode 100644 index 0000000000..71f547aeb9 --- /dev/null +++ b/src/remote/activitypub/kernel/undo/index.ts @@ -0,0 +1,37 @@ +import * as debug from 'debug'; + +import { IRemoteUser } from '../../../../models/user'; +import { IUndo } from '../../type'; +import unfollow from './follow'; +import Resolver from '../../resolver'; + +const log = debug('misskey:activitypub'); + +export default async (actor: IRemoteUser, activity: IUndo): Promise<void> => { + if ('actor' in activity && actor.uri !== activity.actor) { + throw new Error('invalid actor'); + } + + const uri = activity.id || activity; + + log(`Undo: ${uri}`); + + const resolver = new Resolver(); + + let object; + + try { + object = await resolver.resolve(activity.object); + } catch (e) { + log(`Resolution failed: ${e}`); + throw e; + } + + switch (object.type) { + case 'Follow': + unfollow(actor, object); + break; + } + + return null; +}; diff --git a/src/remote/activitypub/objects/image.ts b/src/remote/activitypub/objects/image.ts new file mode 100644 index 0000000000..d7bc5aff2f --- /dev/null +++ b/src/remote/activitypub/objects/image.ts @@ -0,0 +1,36 @@ +import * as debug from 'debug'; + +import uploadFromUrl from '../../../services/drive/upload-from-url'; +import { IRemoteUser } from '../../../models/user'; +import { IDriveFile } from '../../../models/drive-file'; +import Resolver from '../resolver'; + +const log = debug('misskey:activitypub'); + +/** + * Imageを作成します。 + */ +export async function createImage(actor: IRemoteUser, value): Promise<IDriveFile> { + const image = await new Resolver().resolve(value); + + if (image.url == null) { + throw new Error('invalid image: url not privided'); + } + + 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<IDriveFile> { + // 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 0000000000..3edcb8c63f --- /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<INote> { + 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<INote> { + 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<INote> { + 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 0000000000..f7ec064cdb --- /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, 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<IUser> { + 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<IUser> { + 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) + ) { + log(`invalid person: ${JSON.stringify(object, null, 2)}`); + 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) + ))).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<IUser> { + 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 0000000000..2e4f53adf5 --- /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<void> => { + await kernel(actor, activity); +}; diff --git a/src/remote/activitypub/renderer/accept.ts b/src/remote/activitypub/renderer/accept.ts new file mode 100644 index 0000000000..00c76883a9 --- /dev/null +++ b/src/remote/activitypub/renderer/accept.ts @@ -0,0 +1,4 @@ +export default object => ({ + type: 'Accept', + object +}); diff --git a/src/remote/activitypub/renderer/announce.ts b/src/remote/activitypub/renderer/announce.ts new file mode 100644 index 0000000000..8e4b3d26a6 --- /dev/null +++ b/src/remote/activitypub/renderer/announce.ts @@ -0,0 +1,4 @@ +export default object => ({ + type: 'Announce', + object +}); 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/create.ts b/src/remote/activitypub/renderer/create.ts new file mode 100644 index 0000000000..de411e1951 --- /dev/null +++ b/src/remote/activitypub/renderer/create.ts @@ -0,0 +1,4 @@ +export default object => ({ + type: 'Create', + object +}); diff --git a/src/remote/activitypub/renderer/document.ts b/src/remote/activitypub/renderer/document.ts new file mode 100644 index 0000000000..91a9f7df38 --- /dev/null +++ b/src/remote/activitypub/renderer/document.ts @@ -0,0 +1,7 @@ +import config from '../../../config'; + +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..bf8eeff06b --- /dev/null +++ b/src/remote/activitypub/renderer/follow.ts @@ -0,0 +1,8 @@ +import config from '../../../config'; +import { IRemoteUser, ILocalUser } from '../../../models/user'; + +export default (follower: ILocalUser, followee: IRemoteUser) => ({ + type: 'Follow', + actor: `${config.url}/users/${follower._id}`, + object: followee.uri +}); diff --git a/src/remote/activitypub/renderer/hashtag.ts b/src/remote/activitypub/renderer/hashtag.ts new file mode 100644 index 0000000000..cf0b07b48a --- /dev/null +++ b/src/remote/activitypub/renderer/hashtag.ts @@ -0,0 +1,7 @@ +import config from '../../../config'; + +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..d671a57e7c --- /dev/null +++ b/src/remote/activitypub/renderer/image.ts @@ -0,0 +1,6 @@ +import config from '../../../config'; + +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..0d5e52557c --- /dev/null +++ b/src/remote/activitypub/renderer/key.ts @@ -0,0 +1,10 @@ +import config from '../../../config'; +import { extractPublic } from '../../../crypto_key'; +import { ILocalUser } from '../../../models/user'; + +export default (user: ILocalUser) => ({ + id: `${config.url}/users/${user._id}/publickey`, + type: 'Key', + owner: `${config.url}/users/${user._id}`, + publicKeyPem: extractPublic(user.keypair) +}); diff --git a/src/remote/activitypub/renderer/like.ts b/src/remote/activitypub/renderer/like.ts new file mode 100644 index 0000000000..061a10ba84 --- /dev/null +++ b/src/remote/activitypub/renderer/like.ts @@ -0,0 +1,8 @@ +import config from '../../../config'; +import { ILocalUser } from '../../../models/user'; + +export default (user: ILocalUser, note) => ({ + type: 'Like', + actor: `${config.url}/users/${user._id}`, + object: note.uri ? note.uri : `${config.url}/notes/${note._id}` +}); diff --git a/src/remote/activitypub/renderer/note.ts b/src/remote/activitypub/renderer/note.ts new file mode 100644 index 0000000000..c364b13249 --- /dev/null +++ b/src/remote/activitypub/renderer/note.ts @@ -0,0 +1,59 @@ +import renderDocument from './document'; +import renderHashtag from './hashtag'; +import config from '../../../config'; +import DriveFile from '../../../models/drive-file'; +import Note, { INote } from '../../../models/note'; +import User from '../../../models/user'; + +export default async function renderNote(note: INote, dive = true) { + const promisedFiles = note.mediaIds + ? DriveFile.find({ _id: { $in: note.mediaIds } }) + : Promise.resolve([]); + + let inReplyTo; + + if (note.replyId) { + const inReplyToNote = await Note.findOne({ + _id: note.replyId, + }); + + if (inReplyToNote !== null) { + const inReplyToUser = await User.findOne({ + _id: inReplyToNote.userId, + }); + + if (inReplyToUser !== null) { + if (inReplyToNote.uri) { + inReplyTo = inReplyToNote.uri; + } else { + if (dive) { + inReplyTo = await renderNote(inReplyToNote, false); + } else { + inReplyTo = `${config.url}/notes/${inReplyToNote._id}`; + } + } + } + } + } else { + inReplyTo = null; + } + + const user = await User.findOne({ + _id: note.userId + }); + + const attributedTo = `${config.url}/users/${user._id}`; + + return { + id: `${config.url}/notes/${note._id}`, + type: 'Note', + attributedTo, + content: note.textHtml, + published: note.createdAt.toISOString(), + to: 'https://www.w3.org/ns/activitystreams#Public', + cc: `${attributedTo}/followers`, + inReplyTo, + attachment: (await promisedFiles).map(renderDocument), + tag: (note.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..f1c8056a75 --- /dev/null +++ b/src/remote/activitypub/renderer/person.ts @@ -0,0 +1,21 @@ +import renderImage from './image'; +import renderKey from './key'; +import config from '../../../config'; + +export default user => { + const id = `${config.url}/users/${user._id}`; + + return { + type: 'Person', + id, + inbox: `${id}/inbox`, + outbox: `${id}/outbox`, + url: `${config.url}/@${user.username}`, + 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/renderer/undo.ts b/src/remote/activitypub/renderer/undo.ts new file mode 100644 index 0000000000..f38e224b60 --- /dev/null +++ b/src/remote/activitypub/renderer/undo.ts @@ -0,0 +1,4 @@ +export default object => ({ + type: 'Undo', + object +}); diff --git a/src/remote/activitypub/request.ts b/src/remote/activitypub/request.ts new file mode 100644 index 0000000000..85f43eb91d --- /dev/null +++ b/src/remote/activitypub/request.ts @@ -0,0 +1,44 @@ +import { request } from 'https'; +import { sign } from 'http-signature'; +import { URL } from 'url'; +import * as debug from 'debug'; + +import config from '../../config'; +import { ILocalUser } from '../../models/user'; + +const log = debug('misskey:activitypub:deliver'); + +export default (user: ILocalUser, url: string, object) => new Promise((resolve, reject) => { + log(`--> ${url}`); + + const { protocol, hostname, port, pathname, search } = new URL(url); + + const req = request({ + protocol, + hostname, + port, + method: 'POST', + path: pathname + search, + }, res => { + res.on('end', () => { + log(`${url} --> ${res.statusCode}`); + + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve(); + } else { + reject(res); + } + }); + + res.on('data', () => {}); + res.on('error', reject); + }); + + sign(req, { + authorizationHeaderName: 'Signature', + key: user.keypair, + keyId: `acct:${user.username}@${config.host}` + }); + + req.end(JSON.stringify(object)); +}); diff --git a/src/remote/activitypub/resolver.ts b/src/remote/activitypub/resolver.ts new file mode 100644 index 0000000000..f405ff10c3 --- /dev/null +++ b/src/remote/activitypub/resolver.ts @@ -0,0 +1,70 @@ +import * as request from 'request-promise-native'; +import * as debug from 'debug'; +import { IObject } from './type'; +//import config from '../../config'; + +const log = debug('misskey:activitypub:resolver'); + +export default class Resolver { + private history: Set<string>; + + constructor() { + this.history = new Set(); + } + + public async resolveCollection(value) { + const collection = typeof value === 'string' + ? await this.resolve(value) + : value; + + switch (collection.type) { + case 'Collection': + collection.objects = collection.object.items; + break; + + case 'OrderedCollection': + collection.objects = collection.object.orderedItems; + break; + + default: + throw new Error(`unknown collection type: ${collection.type}`); + } + + return collection; + } + + public async resolve(value): Promise<IObject> { + if (value == null) { + throw new Error('resolvee is null (or undefined)'); + } + + if (typeof value !== 'string') { + return value; + } + + if (this.history.has(value)) { + throw new Error('cannot resolve already resolved one'); + } + + this.history.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' + )) { + log(`invalid response: ${value}`); + throw new Error('invalid response'); + } + + return object; + } +} diff --git a/src/remote/activitypub/type.ts b/src/remote/activitypub/type.ts new file mode 100644 index 0000000000..08e5493dd4 --- /dev/null +++ b/src/remote/activitypub/type.ts @@ -0,0 +1,100 @@ +export type obj = { [x: string]: any }; + +export interface IObject { + '@context': string | obj | obj[]; + type: string; + id?: string; + summary?: string; + published?: string; + cc?: string[]; + to?: string[]; + attributedTo: string; + attachment?: any[]; + inReplyTo?: any; + content: string; + icon?: any; + image?: any; + url?: string; +} + +export interface IActivity extends IObject { + //type: 'Activity'; + actor: IObject | string; + object: IObject | string; + target?: IObject | string; +} + +export interface ICollection extends IObject { + type: 'Collection'; + totalItems: number; + items: IObject | string | IObject[] | string[]; +} + +export interface IOrderedCollection extends IObject { + type: 'OrderedCollection'; + totalItems: number; + orderedItems: IObject | string | IObject[] | string[]; +} + +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'; + +export const isOrderedCollection = (object: IObject): object is IOrderedCollection => + object.type === 'OrderedCollection'; + +export const isCollectionOrOrderedCollection = (object: IObject): object is ICollection | IOrderedCollection => + isCollection(object) || isOrderedCollection(object); + +export interface ICreate extends IActivity { + type: 'Create'; +} + +export interface IDelete extends IActivity { + type: 'Delete'; +} + +export interface IUndo extends IActivity { + type: 'Undo'; +} + +export interface IFollow extends IActivity { + type: 'Follow'; +} + +export interface IAccept extends IActivity { + type: 'Accept'; +} + +export interface ILike extends IActivity { + type: 'Like'; +} + +export interface IAnnounce extends IActivity { + type: 'Announce'; +} + +export type Object = + ICollection | + IOrderedCollection | + ICreate | + IDelete | + IUndo | + IFollow | + IAccept | + ILike | + IAnnounce; |