|
|
@ -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<Server, IncomingMessage, ServerResponse>) { |
|
|
|
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<ServerResponse>, 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<User>({ 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<Post>({ container: postContainer, id: body.parent }) |
|
|
|
if (!parent) return badRequestFormError(reply, 'parent', 'Invalid parent') |
|
|
|
|
|
|
|
const parentRelationship = await getItem<PostRelationship>({ |
|
|
|
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>(post) |
|
|
|
await userContainer.items.create<UserPost>(userPost) |
|
|
|
|
|
|
|
if (newPostRelationship) await ancestryContainer.items.create<PostRelationship>(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<UserSubscription>({ |
|
|
|
container: userContainer, |
|
|
|
query, |
|
|
|
logger, |
|
|
|
}) |
|
|
|
|
|
|
|
if (subscribers.length < SUBSCRIBER_MAX_SIZE) { |
|
|
|
for (const subscriber of subscribers) { |
|
|
|
await userContainer.items.create<UserTimelinePost>({ |
|
|
|
postId, |
|
|
|
pk: subscriber.id!, |
|
|
|
t: UserItemType.Timeline, |
|
|
|
created: Date.now(), |
|
|
|
}) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
return { |
|
|
|
id: postId, |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
function createPostRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) { |
|
|
|
const options: RouteShorthandOptions = { |
|
|
|
schema: { |
|
|
|
body: postBodySchema, |
|
|
|
response: { |
|
|
|
200: { |
|
|
|
type: 'object', |
|
|
@ -102,95 +199,75 @@ function doPostRoute(server: FastifyInstance<Server, IncomingMessage, ServerResp |
|
|
|
}, |
|
|
|
} |
|
|
|
|
|
|
|
server.post<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/api/post', options, async (request, reply) => { |
|
|
|
server.post<DefaultQuery, DefaultParams, DefaultHeaders, PostBody>('/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<User>({ 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<Post>({ 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<PostRelationship>({ |
|
|
|
container: ancestryContainer, |
|
|
|
id: request.body.parent, |
|
|
|
partitionKey: parent.root, |
|
|
|
}) |
|
|
|
function createAppPostRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) { |
|
|
|
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<DefaultQuery, DefaultParams, Headers, PostBody>('/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>(post) |
|
|
|
await userContainer.items.create<UserPost>(userPost) |
|
|
|
const container = containerFor(server.database.client, 'Apps') |
|
|
|
const installation = await getItem<Installation>({ |
|
|
|
container, |
|
|
|
id: installationId, |
|
|
|
partitionKey: INSTALLATION_PARTITION_KEY, |
|
|
|
}) |
|
|
|
|
|
|
|
if (newPostRelationship) await ancestryContainer.items.create<PostRelationship>(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<UserSubscription>({ |
|
|
|
container: userContainer, |
|
|
|
query, |
|
|
|
logger: request.log |
|
|
|
const app = await getItem<App>({ |
|
|
|
container, |
|
|
|
id: installation.appId, |
|
|
|
partitionKey: APP_PARTITION_KEY, |
|
|
|
}) |
|
|
|
|
|
|
|
if (subscribers.length < SUBSCRIBER_MAX_SIZE) { |
|
|
|
for (const subscriber of subscribers) { |
|
|
|
await userContainer.items.create<UserTimelinePost>({ |
|
|
|
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<Server, IncomingMessage, Serve |
|
|
|
|
|
|
|
const posts = await queryItems<Post>({ |
|
|
|
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<Group>({ container: containerFor(server.database.client, 'Groups'), id: user.group.id }) |
|
|
|
|
|
|
|
return { |
|
|
|
user, |
|
|
|
user: { |
|
|
|
...user, |
|
|
|
group, |
|
|
|
}, |
|
|
|
posts, |
|
|
|
} |
|
|
|
}) |
|
|
@ -415,7 +497,7 @@ function postRoute(server: FastifyInstance<Server, IncomingMessage, ServerRespon |
|
|
|
} |
|
|
|
|
|
|
|
const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = async server => { |
|
|
|
doPostRoute(server) |
|
|
|
createPostRoute(server) |
|
|
|
postsByUserRoute(server) |
|
|
|
postRoute(server) |
|
|
|
} |
|
|
|