Implement announce

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

View file

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

View file

@ -51,9 +51,6 @@ export interface INotification {
/** /**
* Pack a notification for API response * Pack a notification for API response
*
* @param {any} notification
* @return {Promise<any>}
*/ */
export const pack = (notification: any) => new Promise<any>(async (resolve, reject) => { export const pack = (notification: any) => new Promise<any>(async (resolve, reject) => {
let _notification: any; 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 follow from './follow';
import undo from './undo'; import undo from './undo';
import like from './like'; import like from './like';
import announce from './announce';
const self = async (actor: IRemoteUser, activity: Object): Promise<void> => { const self = async (actor: IRemoteUser, activity: Object): Promise<void> => {
switch (activity.type) { switch (activity.type) {
@ -24,6 +25,10 @@ const self = async (actor: IRemoteUser, activity: Object): Promise<void> => {
// noop // noop
break; break;
case 'Announce':
await announce(actor, activity);
break;
case 'Like': case 'Like':
await like(actor, activity); await like(actor, activity);
break; 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; const id = typeof activity.object == 'string' ? activity.object : activity.object.id;
// Transform: // Transform:
// https://misskey.ex/@syuilo/xxxx to // https://misskey.ex/notes/xxxx to
// xxxx // xxxx
const noteId = id.split('/').pop(); 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 config from '../../../config';
import { ILocalUser } from '../../../models/user';
export default (user, note) => { export default (user: ILocalUser, note) => {
return { return {
type: 'Like', type: 'Like',
actor: `${config.url}/@${user.username}`, actor: `${config.url}/@${user.username}`,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -16,7 +16,7 @@ app.get('/@:user/outbox', withUser(username => {
sort: { _id: -1 } 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); const rendered = renderOrderedCollection(`${config.url}/@${user.username}/inbox`, user.notesCount, renderedNotes);
rendered['@context'] = context; 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 { deliver } from '../../queue';
import renderNote from '../../remote/activitypub/renderer/note'; import renderNote from '../../remote/activitypub/renderer/note';
import renderCreate from '../../remote/activitypub/renderer/create'; import renderCreate from '../../remote/activitypub/renderer/create';
import renderAnnounce from '../../remote/activitypub/renderer/announce';
import context from '../../remote/activitypub/renderer/context'; import context from '../../remote/activitypub/renderer/context';
import { IDriveFile } from '../../models/drive-file'; import { IDriveFile } from '../../models/drive-file';
import notify from '../../publishers/notify'; import notify from '../../publishers/notify';
@ -34,6 +35,7 @@ export default async (user: IUser, data: {
}, silent = false) => new Promise<INote>(async (res, rej) => { }, silent = false) => new Promise<INote>(async (res, rej) => {
if (data.createdAt == null) data.createdAt = new Date(); if (data.createdAt == null) data.createdAt = new Date();
if (data.visibility == null) data.visibility = 'public'; if (data.visibility == null) data.visibility = 'public';
if (data.viaMobile == null) data.viaMobile = false;
const tags = data.tags || []; const tags = data.tags || [];
@ -77,9 +79,7 @@ export default async (user: IUser, data: {
_user: { _user: {
host: user.host, host: user.host,
hostLower: user.hostLower, hostLower: user.hostLower,
account: isLocalUser(user) ? {} : { inbox: isRemoteUser(user) ? user.inbox : undefined
inbox: user.inbox
}
} }
}; };
@ -128,15 +128,25 @@ export default async (user: IUser, data: {
}); });
if (!silent) { if (!silent) {
const content = renderCreate(await renderNote(user, note)); const render = async () => {
content['@context'] = context; 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)) { 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]; follower = follower.user[0];
if (isLocalUser(follower)) { if (isLocalUser(follower)) {
@ -145,7 +155,7 @@ export default async (user: IUser, data: {
} else { } else {
// フォロワーがリモートユーザーかつ投稿者がローカルユーザーなら投稿を配信 // フォロワーがリモートユーザーかつ投稿者がローカルユーザーなら投稿を配信
if (isLocalUser(user)) { 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 // Notify
const type = data.text ? 'quote' : 'renote'; const type = data.text ? 'quote' : 'renote';
notify(data.renote.userId, user._id, type, { notify(data.renote.userId, user._id, type, {
note_id: note._id noteId: note._id
}); });
// Fetch watchers // Fetch watchers
NoteWatching.find({ NoteWatching.find({
noteId: data.renote._id, noteId: data.renote._id,
userId: { $ne: user._id }, userId: { $ne: user._id }
// 削除されたドキュメントは除く
deletedAt: { $exists: false }
}, { }, {
fields: { fields: {
userId: true userId: true

View file

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