Implement announce

And bug fixes
This commit is contained in:
syuilo 2018-04-08 06:55:26 +09:00
parent 5e5c46545c
commit d482ca7f9f
17 changed files with 164 additions and 300 deletions

View File

@ -112,7 +112,7 @@ export const pack = async (
_note = deepcopy(note);
}
if (!_note) throw 'invalid note arg.';
if (!_note) throw `invalid note arg ${note}`;
const id = _note._id;

View File

@ -51,9 +51,6 @@ export interface INotification {
/**
* Pack a notification for API response
*
* @param {any} notification
* @return {Promise<any>}
*/
export const pack = (notification: any) => new Promise<any>(async (resolve, reject) => {
let _notification: any;

View File

@ -0,0 +1,39 @@
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> => {
if ('actor' in activity && actor.uri !== activity.actor) {
throw new Error('invalid actor');
}
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;
}
};

View File

@ -0,0 +1,52 @@
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 { IAnnounce, INote } from '../../type';
import createNote from '../create/note';
import resolvePerson from '../../resolve-person';
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 Note.findOne({ 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() });
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
});
}

View File

@ -5,6 +5,7 @@ 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) {
@ -24,6 +25,10 @@ const self = async (actor: IRemoteUser, activity: Object): Promise<void> => {
// noop
break;
case 'Announce':
await announce(actor, activity);
break;
case 'Like':
await like(actor, activity);
break;

View File

@ -7,7 +7,7 @@ export default async (actor: IRemoteUser, activity: ILike) => {
const id = typeof activity.object == 'string' ? activity.object : activity.object.id;
// Transform:
// https://misskey.ex/@syuilo/xxxx to
// https://misskey.ex/notes/xxxx to
// xxxx
const noteId = id.split('/').pop();

View File

@ -0,0 +1,4 @@
export default object => ({
type: 'Announce',
object
});

View File

@ -1,6 +1,7 @@
import config from '../../../config';
import { ILocalUser } from '../../../models/user';
export default (user, note) => {
export default (user: ILocalUser, note) => {
return {
type: 'Like',
actor: `${config.url}/@${user.username}`,

View File

@ -3,9 +3,9 @@ import renderHashtag from './hashtag';
import config from '../../../config';
import DriveFile from '../../../models/drive-file';
import Note, { INote } from '../../../models/note';
import User, { IUser } from '../../../models/user';
import User from '../../../models/user';
export default async (user: IUser, note: INote) => {
export default async (note: INote) => {
const promisedFiles = note.mediaIds
? DriveFile.find({ _id: { $in: note.mediaIds } })
: Promise.resolve([]);
@ -30,6 +30,10 @@ export default async (user: IUser, note: INote) => {
inReplyTo = null;
}
const user = await User.findOne({
_id: note.userId
});
const attributedTo = `${config.url}/@${user.username}`;
return {

View File

@ -2,18 +2,18 @@ import { JSDOM } from 'jsdom';
import { toUnicode } from 'punycode';
import parseAcct from '../../acct/parse';
import config from '../../config';
import User, { validateUsername, isValidName, isValidDescription } from '../../models/user';
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 } from './type';
import { isCollectionOrOrderedCollection, IObject } from './type';
export default async (value, verifier?: string) => {
const id = value.id || value;
export default async (value: string | IObject, verifier?: string): Promise<IUser> => {
const id = typeof value == 'string' ? value : value.id;
const localPrefix = config.url + '/@';
if (id.startsWith(localPrefix)) {
return User.findOne(parseAcct(id.slice(localPrefix)));
return await User.findOne(parseAcct(id.substr(localPrefix.length)));
}
const resolver = new Resolver();

View File

@ -1,6 +1,7 @@
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');
@ -47,6 +48,11 @@ export default class Resolver {
this.history.add(value);
//#region resolve local objects
// TODO
//if (value.startsWith(`${config.url}/@`)) {
//#endregion
const object = await request({
url: value,
headers: {
@ -60,6 +66,7 @@ export default class Resolver {
!object['@context'].includes('https://www.w3.org/ns/activitystreams') :
object['@context'] !== 'https://www.w3.org/ns/activitystreams'
)) {
log(`invalid response: ${JSON.stringify(object, null, 2)}`);
throw new Error('invalid response');
}

View File

@ -5,6 +5,10 @@ export interface IObject {
type: string;
id?: string;
summary?: string;
published?: string;
cc?: string[];
to?: string[];
attributedTo: string;
}
export interface IActivity extends IObject {
@ -26,6 +30,10 @@ export interface IOrderedCollection extends IObject {
orderedItems: IObject | string | IObject[] | string[];
}
export interface INote extends IObject {
type: 'Note';
}
export const isCollection = (object: IObject): object is ICollection =>
object.type === 'Collection';
@ -59,6 +67,10 @@ export interface ILike extends IActivity {
type: 'Like';
}
export interface IAnnounce extends IActivity {
type: 'Announce';
}
export type Object =
ICollection |
IOrderedCollection |
@ -67,4 +79,5 @@ export type Object =
IUndo |
IFollow |
IAccept |
ILike;
ILike |
IAnnounce;

View File

@ -1,40 +1,25 @@
import * as express from 'express';
import context from '../../remote/activitypub/renderer/context';
import render from '../../remote/activitypub/renderer/note';
import parseAcct from '../../acct/parse';
import Note from '../../models/note';
import User from '../../models/user';
const app = express.Router();
app.get('/@:user/:note', async (req, res, next) => {
app.get('/notes/:note', async (req, res, next) => {
const accepted = req.accepts(['html', 'application/activity+json', 'application/ld+json']);
if (!(['application/activity+json', 'application/ld+json'] as any[]).includes(accepted)) {
return next();
}
const { username, host } = parseAcct(req.params.user);
if (host !== null) {
return res.sendStatus(422);
}
const user = await User.findOne({
usernameLower: username.toLowerCase(),
host: null
});
if (user === null) {
return res.sendStatus(404);
}
const note = await Note.findOne({
_id: req.params.note,
userId: user._id
_id: req.params.note
});
if (note === null) {
return res.sendStatus(404);
}
const rendered = await render(user, note);
const rendered = await render(note);
rendered['@context'] = context;
res.json(rendered);

View File

@ -16,7 +16,7 @@ app.get('/@:user/outbox', withUser(username => {
sort: { _id: -1 }
});
const renderedNotes = await Promise.all(notes.map(note => renderNote(user, note)));
const renderedNotes = await Promise.all(notes.map(note => renderNote(note)));
const rendered = renderOrderedCollection(`${config.url}/@${user.username}/inbox`, user.notesCount, renderedNotes);
rendered['@context'] = context;

View File

@ -1,251 +0,0 @@
/**
* Module dependencies
*/
import $ from 'cafy';
import deepEqual = require('deep-equal');
import Note, { INote, isValidText, isValidCw, pack } from '../../../../models/note';
import { ILocalUser } from '../../../../models/user';
import Channel, { IChannel } from '../../../../models/channel';
import DriveFile from '../../../../models/drive-file';
import create from '../../../../services/note/create';
import { IApp } from '../../../../models/app';
/**
* Create a note
*
* @param {any} params
* @param {any} user
* @param {any} app
* @return {Promise<any>}
*/
module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res, rej) => {
// Get 'visibility' parameter
const [visibility = 'public', visibilityErr] = $(params.visibility).optional.string().or(['public', 'unlisted', 'private', 'direct']).$;
if (visibilityErr) return rej('invalid visibility');
// Get 'text' parameter
const [text, textErr] = $(params.text).optional.string().pipe(isValidText).$;
if (textErr) return rej('invalid text');
// Get 'cw' parameter
const [cw, cwErr] = $(params.cw).optional.string().pipe(isValidCw).$;
if (cwErr) return rej('invalid cw');
// Get 'viaMobile' parameter
const [viaMobile = false, viaMobileErr] = $(params.viaMobile).optional.boolean().$;
if (viaMobileErr) return rej('invalid viaMobile');
// Get 'tags' parameter
const [tags = [], tagsErr] = $(params.tags).optional.array('string').unique().eachQ(t => t.range(1, 32)).$;
if (tagsErr) return rej('invalid tags');
// Get 'geo' parameter
const [geo, geoErr] = $(params.geo).optional.nullable.strict.object()
.have('coordinates', $().array().length(2)
.item(0, $().number().range(-180, 180))
.item(1, $().number().range(-90, 90)))
.have('altitude', $().nullable.number())
.have('accuracy', $().nullable.number())
.have('altitudeAccuracy', $().nullable.number())
.have('heading', $().nullable.number().range(0, 360))
.have('speed', $().nullable.number())
.$;
if (geoErr) return rej('invalid geo');
// Get 'mediaIds' parameter
const [mediaIds, mediaIdsErr] = $(params.mediaIds).optional.array('id').unique().range(1, 4).$;
if (mediaIdsErr) return rej('invalid mediaIds');
let files = [];
if (mediaIds !== undefined) {
// Fetch files
// forEach だと途中でエラーなどがあっても return できないので
// 敢えて for を使っています。
for (const mediaId of mediaIds) {
// Fetch file
// SELECT _id
const entity = await DriveFile.findOne({
_id: mediaId,
'metadata.userId': user._id
});
if (entity === null) {
return rej('file not found');
} else {
files.push(entity);
}
}
} else {
files = null;
}
// Get 'renoteId' parameter
const [renoteId, renoteIdErr] = $(params.renoteId).optional.id().$;
if (renoteIdErr) return rej('invalid renoteId');
let renote: INote = null;
let isQuote = false;
if (renoteId !== undefined) {
// Fetch renote to note
renote = await Note.findOne({
_id: renoteId
});
if (renote == null) {
return rej('renoteee is not found');
} else if (renote.renoteId && !renote.text && !renote.mediaIds) {
return rej('cannot renote to renote');
}
// Fetch recently note
const latestNote = await Note.findOne({
userId: user._id
}, {
sort: {
_id: -1
}
});
isQuote = text != null || files != null;
// 直近と同じRenote対象かつ引用じゃなかったらエラー
if (latestNote &&
latestNote.renoteId &&
latestNote.renoteId.equals(renote._id) &&
!isQuote) {
return rej('cannot renote same note that already reposted in your latest note');
}
// 直近がRenote対象かつ引用じゃなかったらエラー
if (latestNote &&
latestNote._id.equals(renote._id) &&
!isQuote) {
return rej('cannot renote your latest note');
}
}
// Get 'replyId' parameter
const [replyId, replyIdErr] = $(params.replyId).optional.id().$;
if (replyIdErr) return rej('invalid replyId');
let reply: INote = null;
if (replyId !== undefined) {
// Fetch reply
reply = await Note.findOne({
_id: replyId
});
if (reply === null) {
return rej('in reply to note is not found');
}
// 返信対象が引用でないRenoteだったらエラー
if (reply.renoteId && !reply.text && !reply.mediaIds) {
return rej('cannot reply to renote');
}
}
// Get 'channelId' parameter
const [channelId, channelIdErr] = $(params.channelId).optional.id().$;
if (channelIdErr) return rej('invalid channelId');
let channel: IChannel = null;
if (channelId !== undefined) {
// Fetch channel
channel = await Channel.findOne({
_id: channelId
});
if (channel === null) {
return rej('channel not found');
}
// 返信対象の投稿がこのチャンネルじゃなかったらダメ
if (reply && !channelId.equals(reply.channelId)) {
return rej('チャンネル内部からチャンネル外部の投稿に返信することはできません');
}
// Renote対象の投稿がこのチャンネルじゃなかったらダメ
if (renote && !channelId.equals(renote.channelId)) {
return rej('チャンネル内部からチャンネル外部の投稿をRenoteすることはできません');
}
// 引用ではないRenoteはダメ
if (renote && !isQuote) {
return rej('チャンネル内部では引用ではないRenoteをすることはできません');
}
} else {
// 返信対象の投稿がチャンネルへの投稿だったらダメ
if (reply && reply.channelId != null) {
return rej('チャンネル外部からチャンネル内部の投稿に返信することはできません');
}
// Renote対象の投稿がチャンネルへの投稿だったらダメ
if (renote && renote.channelId != null) {
return rej('チャンネル外部からチャンネル内部の投稿をRenoteすることはできません');
}
}
// Get 'poll' parameter
const [poll, pollErr] = $(params.poll).optional.strict.object()
.have('choices', $().array('string')
.unique()
.range(2, 10)
.each(c => c.length > 0 && c.length < 50))
.$;
if (pollErr) return rej('invalid poll');
if (poll) {
(poll as any).choices = (poll as any).choices.map((choice, i) => ({
id: i, // IDを付与
text: choice.trim(),
votes: 0
}));
}
// テキストが無いかつ添付ファイルが無いかつRenoteも無いかつ投票も無かったらエラー
if (text === undefined && files === null && renote === null && poll === undefined) {
return rej('text, mediaIds, renoteId or poll is required');
}
// 直近の投稿と重複してたらエラー
// TODO: 直近の投稿が一日前くらいなら重複とは見なさない
if (user.latestNote) {
if (deepEqual({
text: user.latestNote.text,
reply: user.latestNote.replyId ? user.latestNote.replyId.toString() : null,
renote: user.latestNote.renoteId ? user.latestNote.renoteId.toString() : null,
mediaIds: (user.latestNote.mediaIds || []).map(id => id.toString())
}, {
text: text,
reply: reply ? reply._id.toString() : null,
renote: renote ? renote._id.toString() : null,
mediaIds: (files || []).map(file => file._id.toString())
})) {
return rej('duplicate');
}
}
// 投稿を作成
const note = await create(user, {
createdAt: new Date(),
media: files,
poll: poll,
text: text,
reply,
renote,
cw: cw,
tags: tags,
app: app,
viaMobile: viaMobile,
visibility,
geo
});
const noteObj = await pack(note, user);
// Reponse
res({
createdNote: noteObj
});
});

View File

@ -5,6 +5,7 @@ import Following from '../../models/following';
import { deliver } from '../../queue';
import renderNote from '../../remote/activitypub/renderer/note';
import renderCreate from '../../remote/activitypub/renderer/create';
import renderAnnounce from '../../remote/activitypub/renderer/announce';
import context from '../../remote/activitypub/renderer/context';
import { IDriveFile } from '../../models/drive-file';
import notify from '../../publishers/notify';
@ -34,6 +35,7 @@ export default async (user: IUser, data: {
}, silent = false) => new Promise<INote>(async (res, rej) => {
if (data.createdAt == null) data.createdAt = new Date();
if (data.visibility == null) data.visibility = 'public';
if (data.viaMobile == null) data.viaMobile = false;
const tags = data.tags || [];
@ -77,9 +79,7 @@ export default async (user: IUser, data: {
_user: {
host: user.host,
hostLower: user.hostLower,
account: isLocalUser(user) ? {} : {
inbox: user.inbox
}
inbox: isRemoteUser(user) ? user.inbox : undefined
}
};
@ -128,15 +128,25 @@ export default async (user: IUser, data: {
});
if (!silent) {
const content = renderCreate(await renderNote(user, note));
content['@context'] = context;
const render = async () => {
const content = data.renote && data.text == null
? renderAnnounce(data.renote.uri ? data.renote.uri : await renderNote(data.renote))
: renderCreate(await renderNote(note));
content['@context'] = context;
return content;
};
// 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送
if (data.reply && isLocalUser(user) && isRemoteUser(data.reply._user)) {
deliver(user, content, data.reply._user.inbox).save();
deliver(user, await render(), data.reply._user.inbox).save();
}
Promise.all(followers.map(follower => {
// 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送
if (data.renote && isLocalUser(user) && isRemoteUser(data.renote._user)) {
deliver(user, await render(), data.renote._user.inbox).save();
}
Promise.all(followers.map(async follower => {
follower = follower.user[0];
if (isLocalUser(follower)) {
@ -145,7 +155,7 @@ export default async (user: IUser, data: {
} else {
// フォロワーがリモートユーザーかつ投稿者がローカルユーザーなら投稿を配信
if (isLocalUser(user)) {
deliver(user, content, follower.inbox).save();
deliver(user, await render(), follower.inbox).save();
}
}
}));
@ -255,15 +265,13 @@ export default async (user: IUser, data: {
// Notify
const type = data.text ? 'quote' : 'renote';
notify(data.renote.userId, user._id, type, {
note_id: note._id
noteId: note._id
});
// Fetch watchers
NoteWatching.find({
noteId: data.renote._id,
userId: { $ne: user._id },
// 削除されたドキュメントは除く
deletedAt: { $exists: false }
userId: { $ne: user._id }
}, {
fields: {
userId: true

View File

@ -83,11 +83,11 @@ export default async (user: IUser, note: INote, reaction: string) => new Promise
}
//#region 配信
const content = renderLike(user, note);
content['@context'] = context;
// リアクターがローカルユーザーかつリアクション対象がリモートユーザーの投稿なら配送
if (isLocalUser(user) && isRemoteUser(note._user)) {
const content = renderLike(user, note);
content['@context'] = context;
deliver(user, content, note._user.inbox).save();
}
//#endregion