Implement ActivityPub Followers/Following/Outbox
This commit is contained in:
		
							parent
							
								
									58d0ed1a2e
								
							
						
					
					
						commit
						0986301788
					
				
					 7 changed files with 321 additions and 70 deletions
				
			
		
							
								
								
									
										16
									
								
								src/remote/activitypub/renderer/follow-user.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src/remote/activitypub/renderer/follow-user.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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<any> {
 | 
			
		||||
 | 
			
		||||
	const user = await User.findOne({
 | 
			
		||||
		_id: id
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	return isLocalUser(user) ? `${config.url}/users/${user._id}` : user.uri;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										23
									
								
								src/remote/activitypub/renderer/ordered-collection-page.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/remote/activitypub/renderer/ordered-collection-page.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										80
									
								
								src/server/activitypub/followers.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								src/server/activitypub/followers.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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);
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										80
									
								
								src/server/activitypub/following.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								src/server/activitypub/following.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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);
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										96
									
								
								src/server/activitypub/outbox.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								src/server/activitypub/outbox.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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);
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue