diff --git a/src/remote/activitypub/renderer/note.ts b/src/remote/activitypub/renderer/note.ts index 92dc50efc79c..dc87a913b80e 100644 --- a/src/remote/activitypub/renderer/note.ts +++ b/src/remote/activitypub/renderer/note.ts @@ -157,6 +157,7 @@ export default async function renderNote(note: INote, dive = true, isTalk = fals inReplyTo, attachment: files.map(renderDocument), sensitive: note.cw != null || files.some(file => file.metadata.isSensitive), + likes: `${config.url}/notes/${note._id}/likes`, tag, ...asPoll, ...asTalk diff --git a/src/server/activitypub.ts b/src/server/activitypub.ts index b10d2f4bb18a..d1ce165305c7 100644 --- a/src/server/activitypub.ts +++ b/src/server/activitypub.ts @@ -12,6 +12,7 @@ import renderKey from '../remote/activitypub/renderer/key'; import renderPerson from '../remote/activitypub/renderer/person'; import renderEmoji from '../remote/activitypub/renderer/emoji'; import Outbox, { packActivity } from './activitypub/outbox'; +import Likes from './activitypub/likes'; import Followers from './activitypub/followers'; import Following from './activitypub/following'; import Featured from './activitypub/featured'; @@ -71,7 +72,7 @@ export function setResponseType(ctx: Router.RouterContext) { router.post('/inbox', json() as any, inbox); router.post('/users/:user/inbox', json() as any, inbox); -const isNoteUserAvailable = async (note: INote) => { +export const isNoteUserAvailable = async (note: INote) => { const user = await User.findOne({ _id: note.userId, isDeleted: { $ne: true }, @@ -148,6 +149,8 @@ router.get('/notes/:note/activity', async ctx => { setResponseType(ctx); }); +router.get('/notes/:note/likes', Likes); + // outbox router.get('/users/:user/outbox', Outbox); diff --git a/src/server/activitypub/likes.ts b/src/server/activitypub/likes.ts new file mode 100644 index 000000000000..d75b4f459173 --- /dev/null +++ b/src/server/activitypub/likes.ts @@ -0,0 +1,101 @@ +import { ObjectID } from 'mongodb'; +import * as Router from '@koa/router'; +import config from '../../config'; +import $ from 'cafy'; +import ID, { transform } from '../../misc/cafy-id'; +import { renderLike } from '../../remote/activitypub/renderer/like'; +import { renderActivity } from '../../remote/activitypub/renderer'; +import renderOrderedCollection from '../../remote/activitypub/renderer/ordered-collection'; +import renderOrderedCollectionPage from '../../remote/activitypub/renderer/ordered-collection-page'; +import { setResponseType, isNoteUserAvailable } from '../activitypub'; + +import Note from '../../models/note'; +import { sum } from '../../prelude/array'; +import * as url from '../../prelude/url'; +import NoteReaction from '../../models/note-reaction'; + +export default async (ctx: Router.RouterContext) => { + if (config.disableFederation) ctx.throw(404); + + if (!ObjectID.isValid(ctx.params.note)) { + ctx.status = 404; + return; + } + + const note = await Note.findOne({ + _id: new ObjectID(ctx.params.note), + deletedAt: { $exists: false }, + '_user.host': null, + visibility: { $in: ['public', 'home'] }, + localOnly: { $ne: true }, + copyOnce: { $ne: true } + }); + + if (note == null || !await isNoteUserAvailable(note)) { + ctx.status = 404; + return; + } + + // Get 'cursor' parameter + const [cursor, cursorErr] = $.optional.type(ID).get(ctx.request.query.cursor); + + // Get 'page' parameter + const pageErr = !$.optional.str.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; + } + + const limit = 10; + const partOf = `${config.url}/notes/${note._id}/likes`; + + if (page) { + const query = { + noteId: note._id + } as any; + + // カーソルが指定されている場合 + if (cursor) { + query._id = { + $lt: transform(cursor) + }; + } + + const reactions = await NoteReaction.find(query, { + limit: limit + 1, + sort: { _id: -1 }, + // TODO: local only + }); + + // 「次のページ」があるかどうか + const inStock = reactions.length === limit + 1; + if (inStock) reactions.pop(); + + const renderedLikes = await Promise.all(reactions.map(reaction => renderLike(reaction, note))); + const rendered = renderOrderedCollectionPage( + `${partOf}?${url.query({ + page: 'true', + cursor + })}`, + sum(Object.values(note.reactionCounts)), + renderedLikes, partOf, + null, + inStock ? `${partOf}?${url.query({ + page: 'true', + cursor: reactions[reactions.length - 1]._id.toHexString() + })}` : null + ); + + ctx.body = renderActivity(rendered); + setResponseType(ctx); + } else { + // index page + const rendered = renderOrderedCollection(partOf, sum(Object.values(note.reactionCounts)), `${partOf}?page=true`, null); + ctx.body = renderActivity(rendered); + ctx.set('Cache-Control', 'public, max-age=30'); + setResponseType(ctx); + } +};