diff --git a/src/plugins/api/posts.ts b/src/plugins/api/posts.ts index 671e7c0..91c3b86 100644 --- a/src/plugins/api/posts.ts +++ b/src/plugins/api/posts.ts @@ -6,11 +6,15 @@ import { DefaultHeaders, DefaultBody, RouteShorthandOptions, + FastifyReply, + Logger, } from 'fastify' import { Server, IncomingMessage, ServerResponse } from 'http' +import { createHmac } from 'crypto' +import { CosmosClient } from '@azure/cosmos' -import { SHORT_TEXT_LENGTH, SUBSCRIBER_MAX_SIZE } from '../../constants' +import { SHORT_TEXT_LENGTH, SUBSCRIBER_MAX_SIZE, INSTALLATION_PARTITION_KEY, APP_PARTITION_KEY } from '../../constants' import { userSchema, postSchema, errorSchema } from '../../schemas' import { unauthorizedError, serverError, badRequestError, badRequestFormError, notFoundError } from '../../lib/errors' import { trimContent, createPostId } from '../../lib/utils' @@ -31,65 +35,158 @@ import { UserItemType, UserPrivacyType, GroupItemType, + Installation, + App, + Group, } from '../../types/collections' import { PluginOptions } from '../../types' -function doPostRoute(server: FastifyInstance) { - interface Body { - text?: string - cover?: string - visible: boolean - status?: Status - attachments: PostAttachment[] - parent: string - } +interface PostBody { + text?: string + cover?: string + visible: boolean + status?: Status + attachments: PostAttachment[] + parent: string +} - const options: RouteShorthandOptions = { - schema: { - body: { +const postBodySchema = { + type: 'object', + required: ['visible'], + properties: { + text: { type: 'string' }, + cover: { + type: 'string', + maxLength: SHORT_TEXT_LENGTH, + }, + visible: { type: 'boolean' }, + status: { + type: 'object', + required: ['date'], + properties: { + imageUrl: { type: 'string' }, + text: { + type: 'string', + maxLength: SHORT_TEXT_LENGTH, + }, + date: { type: 'number' }, + }, + }, + attachments: { + type: 'array', + items: { type: 'object', - required: ['visible'], + required: ['imageUrl'], properties: { - text: { type: 'string' }, - cover: { + imageUrl: { type: 'string' }, + caption: { type: 'string', maxLength: SHORT_TEXT_LENGTH, }, - visible: { type: 'boolean' }, - status: { - type: 'object', - required: ['date'], - properties: { - imageUrl: { type: 'string' }, - text: { - type: 'string', - maxLength: SHORT_TEXT_LENGTH, - }, - date: { type: 'number' }, - }, - }, - attachments: { - type: 'array', - items: { - type: 'object', - required: ['imageUrl'], - properties: { - imageUrl: { type: 'string' }, - caption: { - type: 'string', - maxLength: SHORT_TEXT_LENGTH, - }, - cover: { - type: 'string', - maxLength: SHORT_TEXT_LENGTH, - }, - }, - }, + cover: { + type: 'string', + maxLength: SHORT_TEXT_LENGTH, }, - parent: { type: 'string' }, }, }, + }, + parent: { type: 'string' }, + }, +} + +async function createPost(client: CosmosClient, userId: string, body: PostBody, reply: FastifyReply, logger: Logger) { + let newPostRelationship: PostRelationship | undefined + + const postContainer = containerFor(client, 'Posts') + const ancestryContainer = containerFor(client, 'Ancestry') + const userContainer = containerFor(client, 'Users') + + const viewer = await getItem({ container: userContainer, id: userId }) + + if (!viewer) return serverError(reply) + if (viewer.pending) return badRequestError(reply, 'User requires approval') + if (!viewer.group) return badRequestError(reply, 'User must belong to a group') + + const postId = createPostId() + + if (body.parent) { + const parent = await getItem({ container: postContainer, id: body.parent }) + if (!parent) return badRequestFormError(reply, 'parent', 'Invalid parent') + + const parentRelationship = await getItem({ + container: ancestryContainer, + id: body.parent, + partitionKey: parent.root, + }) + + const parents = parentRelationship ? parentRelationship.parents : [] + + newPostRelationship = { + id: postId, + pk: parent.root, + parents: [ + ...parents, + parent.id, + ] + } + } + + const post: Post = { + id: postId, + pk: postId, + t: PostItemType.Post, + userId, + root: newPostRelationship ? newPostRelationship.pk : postId, + parents: newPostRelationship ? newPostRelationship.parents : [], + text: trimContent(body.text, 1000), + cover: trimContent(body.cover), + visible: body.visible, + attachments: [], + awards: 0, + latestAwards: [], + created: Date.now(), + } + + const userPost: UserPost = { + postId, + pk: userId, + t: UserItemType.Post, + created: Date.now(), + } + + await postContainer.items.create(post) + await userContainer.items.create(userPost) + + if (newPostRelationship) await ancestryContainer.items.create(newPostRelationship) + + const query = createQuerySpec(`SELECT u.id FROM Users u WHERE u.pk = @pk AND u.t = @type`, { pk: userId, type: UserItemType.Subscription }) + const subscribers = await queryItems({ + container: userContainer, + query, + logger, + }) + + if (subscribers.length < SUBSCRIBER_MAX_SIZE) { + for (const subscriber of subscribers) { + await userContainer.items.create({ + postId, + pk: subscriber.id!, + t: UserItemType.Timeline, + created: Date.now(), + }) + } + } + + return { + id: postId, + } +} + +function createPostRoute(server: FastifyInstance) { + const options: RouteShorthandOptions = { + schema: { + body: postBodySchema, response: { 200: { type: 'object', @@ -102,95 +199,75 @@ function doPostRoute(server: FastifyInstance('/api/post', options, async (request, reply) => { + server.post('/api/post', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply) - let newPostRelationship: PostRelationship | undefined - - const postContainer = containerFor(server.database.client, 'Posts') - const ancestryContainer = containerFor(server.database.client, 'Ancestry') - const userContainer = containerFor(server.database.client, 'Users') - - const viewer = await getItem({ container: userContainer, id: request.viewer.id }) - - if (!viewer) return serverError(reply) - if (viewer.pending) return badRequestError(reply, 'User requires approval') - if (!viewer.group) return badRequestError(reply, 'User must belong to a group') - - const postId = createPostId() - - if (request.body.parent) { - const parent = await getItem({ container: postContainer, id: request.body.parent }) - if (!parent) return badRequestFormError(reply, 'parent', 'Invalid parent') + return await createPost(server.database.client, request.viewer.id, request.body, reply, request.log) + }) +} - const parentRelationship = await getItem({ - container: ancestryContainer, - id: request.body.parent, - partitionKey: parent.root, - }) +function createAppPostRoute(server: FastifyInstance) { + interface Headers { + installation: string + timestamp: string + signature: string + } - const parents = parentRelationship ? parentRelationship.parents : [] + const options: RouteShorthandOptions = { + schema: { + headers: { + type: 'object', + properties: { + installation: { type: 'string' }, + timestamp: { type: 'string' }, + signature: { type: 'string' }, + }, + }, + body: postBodySchema, + response: { + 200: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + }, + 400: errorSchema, + }, + }, + } - newPostRelationship = { - id: postId, - pk: parent.root, - parents: [ - ...parents, - parent.id, - ] - } - } + server.post('/api/app/post', options, async (request, reply) => { + if (!server.database) return serverError(reply) - const post: Post = { - id: postId, - pk: postId, - t: PostItemType.Post, - userId: request.viewer.id, - root: newPostRelationship ? newPostRelationship.pk : postId, - parents: newPostRelationship ? newPostRelationship.parents : [], - text: trimContent(request.body.text, 1000), - cover: trimContent(request.body.cover), - visible: request.body.visible, - attachments: [], - awards: 0, - latestAwards: [], - created: Date.now(), - } + const { installation: installationId, timestamp, signature } = request.headers - const userPost: UserPost = { - postId, - pk: request.viewer.id, - t: UserItemType.Post, - created: Date.now(), - } + if (!installationId) return badRequestError(reply, '"installation" header required') + if (!timestamp) return badRequestError(reply, '"timestamp" header required') + if (!signature) return badRequestError(reply, '"signature" header required') - await postContainer.items.create(post) - await userContainer.items.create(userPost) + const container = containerFor(server.database.client, 'Apps') + const installation = await getItem({ + container, + id: installationId, + partitionKey: INSTALLATION_PARTITION_KEY, + }) - if (newPostRelationship) await ancestryContainer.items.create(newPostRelationship) + if (!installation) return badRequestError(reply, 'Installation not found') - const query = createQuerySpec(`SELECT u.id FROM Users u WHERE u.pk = @pk AND u.t = @type`, { pk: request.viewer.id, type: UserItemType.Subscription }) - const subscribers = await queryItems({ - container: userContainer, - query, - logger: request.log + const app = await getItem({ + container, + id: installation.appId, + partitionKey: APP_PARTITION_KEY, }) - if (subscribers.length < SUBSCRIBER_MAX_SIZE) { - for (const subscriber of subscribers) { - await userContainer.items.create({ - postId, - pk: subscriber.id!, - t: UserItemType.Timeline, - created: Date.now(), - }) - } - } + if (!app) return serverError(reply) - return { - id: postId, + if (createHmac('sha256', app.privateKey).update(installationId + timestamp).digest('hex') !== signature) { + return badRequestError(reply, 'Invalid signature') } + + return await createPost(server.database.client, installation.userId, request.body, reply, request.log) }) } @@ -276,14 +353,19 @@ function postsByUserRoute(server: FastifyInstance({ container: containerFor(server.database.client, 'Posts'), - query: createQuerySpec('SELECT * FROM Posts p WHERE ARRAY_CONTAINS(@posts, p.id)', { + query: createQuerySpec('SELECT * FROM Posts p WHERE ARRAY_CONTAINS(@posts, p.id) ORDER BY p.created DESC', { posts: userPosts.map(p => p.postId!), }), logger: request.log }) + const group = await getItem({ container: containerFor(server.database.client, 'Groups'), id: user.group.id }) + return { - user, + user: { + ...user, + group, + }, posts, } }) @@ -415,7 +497,7 @@ function postRoute(server: FastifyInstance = async server => { - doPostRoute(server) + createPostRoute(server) postsByUserRoute(server) postRoute(server) }