From 0986301788a40663ced0aa9d6d8a97b099b622b1 Mon Sep 17 00:00:00 2001 From: mei23 Date: Tue, 14 Aug 2018 20:13:32 +0900 Subject: [PATCH] Implement ActivityPub Followers/Following/Outbox --- .../activitypub/renderer/follow-user.ts | 16 ++++ .../renderer/ordered-collection-page.ts | 23 +++++ .../renderer/ordered-collection.ts | 25 +++-- src/server/activitypub.ts | 71 ++------------ src/server/activitypub/followers.ts | 80 ++++++++++++++++ src/server/activitypub/following.ts | 80 ++++++++++++++++ src/server/activitypub/outbox.ts | 96 +++++++++++++++++++ 7 files changed, 321 insertions(+), 70 deletions(-) create mode 100644 src/remote/activitypub/renderer/follow-user.ts create mode 100644 src/remote/activitypub/renderer/ordered-collection-page.ts create mode 100644 src/server/activitypub/followers.ts create mode 100644 src/server/activitypub/following.ts create mode 100644 src/server/activitypub/outbox.ts diff --git a/src/remote/activitypub/renderer/follow-user.ts b/src/remote/activitypub/renderer/follow-user.ts new file mode 100644 index 000000000..9a488d392 --- /dev/null +++ b/src/remote/activitypub/renderer/follow-user.ts @@ -0,0 +1,16 @@ +import config from '../../../config'; +import * as mongo from 'mongodb'; +import User, { isLocalUser } from '../../../models/user'; + +/** + * Convert (local|remote)(Follower|Followee)ID to URL + * @param id Follower|Followee ID + */ +export default async function renderFollowUser(id: mongo.ObjectID): Promise { + + const user = await User.findOne({ + _id: id + }); + + return isLocalUser(user) ? `${config.url}/users/${user._id}` : user.uri; +} diff --git a/src/remote/activitypub/renderer/ordered-collection-page.ts b/src/remote/activitypub/renderer/ordered-collection-page.ts new file mode 100644 index 000000000..83af07870 --- /dev/null +++ b/src/remote/activitypub/renderer/ordered-collection-page.ts @@ -0,0 +1,23 @@ +/** + * Render OrderedCollectionPage + * @param id URL of self + * @param totalItems Number of total items + * @param orderedItems Items + * @param partOf URL of base + * @param prev URL of prev page (optional) + * @param next URL of next page (optional) + */ +export default function(id: string, totalItems: any, orderedItems: any, partOf: string, prev: string, next: string) { + const page = { + id, + partOf, + type: 'OrderedCollectionPage', + totalItems, + orderedItems + } as any; + + if (prev) page.prev = prev; + if (next) page.next = next; + + return page; +} diff --git a/src/remote/activitypub/renderer/ordered-collection.ts b/src/remote/activitypub/renderer/ordered-collection.ts index 9d543b1e1..3c448cf87 100644 --- a/src/remote/activitypub/renderer/ordered-collection.ts +++ b/src/remote/activitypub/renderer/ordered-collection.ts @@ -1,6 +1,19 @@ -export default (id: string, totalItems: any, orderedItems: any) => ({ - id, - type: 'OrderedCollection', - totalItems, - orderedItems -}); +/** + * Render OrderedCollection + * @param id URL of self + * @param totalItems Total number of items + * @param first URL of first page (optional) + * @param last URL of last page (optional) + */ +export default function(id: string, totalItems: any, first: string, last: string) { + const page: any = { + id, + type: 'OrderedCollection', + totalItems, + }; + + if (first) page.first = first; + if (last) page.last = last; + + return page; +} diff --git a/src/server/activitypub.ts b/src/server/activitypub.ts index 7d6fe0926..c2dec2b99 100644 --- a/src/server/activitypub.ts +++ b/src/server/activitypub.ts @@ -10,8 +10,9 @@ import User, { isLocalUser, ILocalUser, IUser } from '../models/user'; import renderNote from '../remote/activitypub/renderer/note'; import renderKey from '../remote/activitypub/renderer/key'; import renderPerson from '../remote/activitypub/renderer/person'; -import renderOrderedCollection from '../remote/activitypub/renderer/ordered-collection'; -import config from '../config'; +import Outbox from './activitypub/outbox'; +import Followers from './activitypub/followers'; +import Following from './activitypub/following'; // Init router const router = new Router(); @@ -64,72 +65,14 @@ router.get('/notes/:note', async (ctx, next) => { ctx.body = pack(await renderNote(note)); }); -// outbot -router.get('/users/:user/outbox', async ctx => { - const userId = new mongo.ObjectID(ctx.params.user); - - const user = await User.findOne({ - _id: userId, - host: null - }); - - if (user === null) { - ctx.status = 404; - return; - } - - const notes = await Note.find({ userId: user._id }, { - limit: 10, - sort: { _id: -1 } - }); - - const renderedNotes = await Promise.all(notes.map(note => renderNote(note))); - const rendered = renderOrderedCollection(`${config.url}/users/${userId}/inbox`, user.notesCount, renderedNotes); - - ctx.body = pack(rendered); -}); +// outbox +router.get('/users/:user/outbox', Outbox); // followers -router.get('/users/:user/followers', async ctx => { - const userId = new mongo.ObjectID(ctx.params.user); - - const user = await User.findOne({ - _id: userId, - host: null - }); - - if (user === null) { - ctx.status = 404; - return; - } - - // TODO: Implement fetch and render - - const rendered = renderOrderedCollection(`${config.url}/users/${userId}/followers`, 0, []); - - ctx.body = pack(rendered); -}); +router.get('/users/:user/followers', Followers); // following -router.get('/users/:user/following', async ctx => { - const userId = new mongo.ObjectID(ctx.params.user); - - const user = await User.findOne({ - _id: userId, - host: null - }); - - if (user === null) { - ctx.status = 404; - return; - } - - // TODO: Implement fetch and render - - const rendered = renderOrderedCollection(`${config.url}/users/${userId}/following`, 0, []); - - ctx.body = pack(rendered); -}); +router.get('/users/:user/following', Following); // publickey router.get('/users/:user/publickey', async ctx => { diff --git a/src/server/activitypub/followers.ts b/src/server/activitypub/followers.ts new file mode 100644 index 000000000..d51d45b1c --- /dev/null +++ b/src/server/activitypub/followers.ts @@ -0,0 +1,80 @@ +import * as mongo from 'mongodb'; +import * as Koa from 'koa'; +import config from '../../config'; +import $ from 'cafy'; import ID from '../../misc/cafy-id'; +import User from '../../models/user'; +import Following from '../../models/following'; +import pack from '../../remote/activitypub/renderer'; +import renderOrderedCollection from '../../remote/activitypub/renderer/ordered-collection'; +import renderOrderedCollectionPage from '../../remote/activitypub/renderer/ordered-collection-page'; +import renderFollowUser from '../../remote/activitypub/renderer/follow-user'; + +export default async (ctx: Koa.Context) => { + const userId = new mongo.ObjectID(ctx.params.user); + + // Get 'cursor' parameter + const [cursor = null, cursorErr] = $.type(ID).optional.get(ctx.request.query.cursor); + + // Get 'page' parameter + const pageErr = !$.str.optional.or(['true', 'false']).ok(ctx.request.query.page); + const page: boolean = ctx.request.query.page === 'true'; + + // Validate parameters + if (cursorErr || pageErr) { + ctx.status = 400; + return; + } + + // Verify user + const user = await User.findOne({ + _id: userId, + host: null + }); + + if (user === null) { + ctx.status = 404; + return; + } + + const limit = 10; + const partOf = `${config.url}/users/${userId}/followers`; + + if (page) { + // Construct query + const query = { + followeeId: user._id + } as any; + + // カーソルが指定されている場合 + if (cursor) { + query._id = { + $lt: cursor + }; + } + + // Get followers + const followings = await Following + .find(query, { + limit: limit + 1, + sort: { _id: -1 } + }); + + // 「次のページ」があるかどうか + const inStock = followings.length === limit + 1; + if (inStock) followings.pop(); + + const renderedFollowers = await Promise.all(followings.map(following => renderFollowUser(following.followerId))); + const rendered = renderOrderedCollectionPage( + `${partOf}?page=true${cursor ? `&cursor=${cursor}` : ''}`, + user.followersCount, renderedFollowers, partOf, + null, + inStock ? `${partOf}?page=true&cursor=${followings[followings.length - 1]._id}` : null + ); + + ctx.body = pack(rendered); + } else { + // index page + const rendered = renderOrderedCollection(partOf, user.followersCount, `${partOf}?page=true`, null); + ctx.body = pack(rendered); + } +}; diff --git a/src/server/activitypub/following.ts b/src/server/activitypub/following.ts new file mode 100644 index 000000000..7e496f590 --- /dev/null +++ b/src/server/activitypub/following.ts @@ -0,0 +1,80 @@ +import * as mongo from 'mongodb'; +import * as Koa from 'koa'; +import config from '../../config'; +import $ from 'cafy'; import ID from '../../misc/cafy-id'; +import User from '../../models/user'; +import Following from '../../models/following'; +import pack from '../../remote/activitypub/renderer'; +import renderOrderedCollection from '../../remote/activitypub/renderer/ordered-collection'; +import renderOrderedCollectionPage from '../../remote/activitypub/renderer/ordered-collection-page'; +import renderFollowUser from '../../remote/activitypub/renderer/follow-user'; + +export default async (ctx: Koa.Context) => { + const userId = new mongo.ObjectID(ctx.params.user); + + // Get 'cursor' parameter + const [cursor = null, cursorErr] = $.type(ID).optional.get(ctx.request.query.cursor); + + // Get 'page' parameter + const pageErr = !$.str.optional.or(['true', 'false']).ok(ctx.request.query.page); + const page: boolean = ctx.request.query.page === 'true'; + + // Validate parameters + if (cursorErr || pageErr) { + ctx.status = 400; + return; + } + + // Verify user + const user = await User.findOne({ + _id: userId, + host: null + }); + + if (user === null) { + ctx.status = 404; + return; + } + + const limit = 10; + const partOf = `${config.url}/users/${userId}/following`; + + if (page) { + // Construct query + const query = { + followerId: user._id + } as any; + + // カーソルが指定されている場合 + if (cursor) { + query._id = { + $lt: cursor + }; + } + + // Get followings + const followings = await Following + .find(query, { + limit: limit + 1, + sort: { _id: -1 } + }); + + // 「次のページ」があるかどうか + const inStock = followings.length === limit + 1; + if (inStock) followings.pop(); + + const renderedFollowees = await Promise.all(followings.map(following => renderFollowUser(following.followeeId))); + const rendered = renderOrderedCollectionPage( + `${partOf}?page=true${cursor ? `&cursor=${cursor}` : ''}`, + user.followingCount, renderedFollowees, partOf, + null, + inStock ? `${partOf}?page=true&cursor=${followings[followings.length - 1]._id}` : null + ); + + ctx.body = pack(rendered); + } else { + // index page + const rendered = renderOrderedCollection(partOf, user.followingCount, `${partOf}?page=true`, null); + ctx.body = pack(rendered); + } +}; diff --git a/src/server/activitypub/outbox.ts b/src/server/activitypub/outbox.ts new file mode 100644 index 000000000..e441e3dc4 --- /dev/null +++ b/src/server/activitypub/outbox.ts @@ -0,0 +1,96 @@ +import * as mongo from 'mongodb'; +import * as Koa from 'koa'; +import config from '../../config'; +import $ from 'cafy'; import ID from '../../misc/cafy-id'; +import User from '../../models/user'; +import pack from '../../remote/activitypub/renderer'; +import renderOrderedCollection from '../../remote/activitypub/renderer/ordered-collection'; +import renderOrderedCollectionPage from '../../remote/activitypub/renderer/ordered-collection-page'; + +import Note from '../../models/note'; +import renderNote from '../../remote/activitypub/renderer/note'; + +export default async (ctx: Koa.Context) => { + const userId = new mongo.ObjectID(ctx.params.user); + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $.type(ID).optional.get(ctx.request.query.since_id); + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $.type(ID).optional.get(ctx.request.query.until_id); + + // Get 'page' parameter + const pageErr = !$.str.optional.or(['true', 'false']).ok(ctx.request.query.page); + const page: boolean = ctx.request.query.page === 'true'; + + // Validate parameters + if (sinceIdErr || untilIdErr || pageErr || [sinceId, untilId].filter(x => x != null).length > 1) { + ctx.status = 400; + return; + } + + // Verify user + const user = await User.findOne({ + _id: userId, + host: null + }); + + if (user === null) { + ctx.status = 404; + return; + } + + const limit = 20; + const partOf = `${config.url}/users/${userId}/outbox`; + + if (page) { + //#region Construct query + const sort = { + _id: -1 + }; + + const query = { + userId: user._id, + $or: [ { visibility: 'public' }, { visibility: 'home' } ], + text: { $ne: null } // exclude renote, but include quote + } as any; + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + //#endregion + + // Issue query + const notes = await Note + .find(query, { + limit: limit, + sort: sort + }); + + if (sinceId) notes.reverse(); + + const renderedNotes = await Promise.all(notes.map(note => renderNote(note))); + const rendered = renderOrderedCollectionPage( + `${partOf}?page=true${sinceId ? `&since_id=${sinceId}` : ''}${untilId ? `&until_id=${untilId}` : ''}`, + user.notesCount, renderedNotes, partOf, + notes.length > 0 ? `${partOf}?page=true&since_id=${notes[0]._id}` : null, + notes.length > 0 ? `${partOf}?page=true&until_id=${notes[notes.length - 1]._id}` : null + ); + + ctx.body = pack(rendered); + } else { + // index page + const rendered = renderOrderedCollection(partOf, user.notesCount, + `${partOf}?page=true`, + `${partOf}?page=true&since_id=000000000000000000000000` + ); + ctx.body = pack(rendered); + } +};