You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
645 lines
21 KiB
645 lines
21 KiB
// posts.ts
|
|
// Copyright (C) 2020 Dwayne Harris
|
|
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
import {
|
|
FastifyInstance,
|
|
Plugin,
|
|
DefaultQuery,
|
|
DefaultParams,
|
|
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, INSTALLATION_PARTITION_KEY, APP_PARTITION_KEY } from '../../constants'
|
|
import { userSchema, postSchema, errorSchema } from '../../schemas'
|
|
import { unauthorizedError, serverError, badRequestError, badRequestFormError, notFoundError, forbiddenError } from '../../lib/errors'
|
|
import { trimContent, createPostId } from '../../lib/utils'
|
|
import { getUsers, getApprovedSubscriptions, getUserBlocks, getUser, userIsValid, updateItem } from '../../lib/collections'
|
|
import { containerFor, createQuerySpec, queryItems, getItem, normalize } from '../../lib/database'
|
|
|
|
import {
|
|
Post,
|
|
PostAttachment,
|
|
User,
|
|
UserPost,
|
|
UserSubscription,
|
|
UserTimelinePost,
|
|
GroupBlock,
|
|
PostRelationship,
|
|
Status,
|
|
UserItemType,
|
|
UserPrivacyType,
|
|
GroupItemType,
|
|
Installation,
|
|
App,
|
|
} from '../../types/collections'
|
|
|
|
import { PluginOptions } from '../../types'
|
|
|
|
interface PostBody {
|
|
text?: string
|
|
cover?: string
|
|
installation: string
|
|
visible: boolean
|
|
status?: Status
|
|
attachments: PostAttachment[]
|
|
data: object
|
|
parent: string
|
|
}
|
|
|
|
const postBodySchema = {
|
|
type: 'object',
|
|
required: ['installation', 'visible'],
|
|
properties: {
|
|
installation: { type: 'string' },
|
|
visible: { type: 'boolean' },
|
|
text: { type: 'string' },
|
|
cover: {
|
|
type: 'string',
|
|
maxLength: SHORT_TEXT_LENGTH,
|
|
},
|
|
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: ['url'],
|
|
properties: {
|
|
url: { type: 'string' },
|
|
text: {
|
|
type: 'string',
|
|
maxLength: SHORT_TEXT_LENGTH,
|
|
},
|
|
cover: {
|
|
type: 'string',
|
|
maxLength: SHORT_TEXT_LENGTH,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
data: {
|
|
type: 'object',
|
|
},
|
|
parent: { type: 'string' },
|
|
},
|
|
}
|
|
|
|
async function createPost(client: CosmosClient, userId: string, appId: 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 (!userIsValid(viewer)) return forbiddenError(reply)
|
|
|
|
const postId = createPostId()
|
|
|
|
if (body.parent) {
|
|
const parentItem = postContainer.item(body.parent, body.parent)
|
|
const { resource: parent } = await parentItem.read<Post>()
|
|
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,
|
|
]
|
|
}
|
|
|
|
if (body.visible) {
|
|
await parentItem.replace<Post>({
|
|
...parent,
|
|
replies: parent.replies + 1,
|
|
})
|
|
}
|
|
}
|
|
|
|
const post: Post = {
|
|
id: postId,
|
|
pk: postId,
|
|
userId,
|
|
appId,
|
|
root: newPostRelationship ? newPostRelationship.pk : postId,
|
|
parents: newPostRelationship ? newPostRelationship.parents : [],
|
|
text: trimContent(body.text, 1000),
|
|
cover: trimContent(body.cover),
|
|
visible: body.visible,
|
|
attachments: body.attachments,
|
|
data: body.data,
|
|
replies: 0,
|
|
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 subscribers = await queryItems<UserSubscription>({
|
|
container: userContainer,
|
|
query: createQuerySpec(`SELECT * FROM Users u WHERE u.pk = @pk AND u.t = @type AND u.pending = false`, {
|
|
pk: userId,
|
|
type: UserItemType.Subscription,
|
|
}),
|
|
logger,
|
|
})
|
|
|
|
for (const uid of [userId, ...subscribers.map(s => s.userId)]) {
|
|
await userContainer.items.create<UserTimelinePost>({
|
|
id: postId,
|
|
pk: uid,
|
|
t: UserItemType.Timeline,
|
|
created: Date.now(),
|
|
})
|
|
}
|
|
|
|
const viewerItem = userContainer.item(viewer.id, viewer.id)
|
|
await viewerItem.replace<User>({
|
|
...viewer,
|
|
posts: viewer.posts + 1,
|
|
})
|
|
|
|
return {
|
|
id: postId,
|
|
}
|
|
}
|
|
|
|
function createPostRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
|
|
const options: RouteShorthandOptions = {
|
|
schema: {
|
|
description: 'Create a Post.',
|
|
tags: ['post'],
|
|
body: postBodySchema,
|
|
response: {
|
|
201: {
|
|
description: 'Post created.',
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string' },
|
|
},
|
|
},
|
|
400: errorSchema,
|
|
},
|
|
},
|
|
}
|
|
|
|
server.post<DefaultQuery, DefaultParams, DefaultHeaders, PostBody>('/v1/post', options, async (request, reply) => {
|
|
if (!server.database) return serverError(reply)
|
|
if (!request.viewer) return unauthorizedError(reply)
|
|
|
|
const installationId = request.body.installation
|
|
|
|
const container = containerFor(server.database.client, 'Apps')
|
|
const installation = await getItem<Installation>({
|
|
container,
|
|
id: installationId,
|
|
partitionKey: INSTALLATION_PARTITION_KEY,
|
|
})
|
|
|
|
if (!installation) return badRequestError(reply, 'Installation not found')
|
|
if (installation.userId !== request.viewer.id) return badRequestError(reply)
|
|
|
|
const app = await getItem<App>({
|
|
container,
|
|
id: installation.appId,
|
|
partitionKey: APP_PARTITION_KEY,
|
|
})
|
|
|
|
if (!app) return serverError(reply)
|
|
|
|
reply.code(201)
|
|
return await createPost(server.database.client, request.viewer.id, app.id, request.body, reply, request.log)
|
|
})
|
|
}
|
|
|
|
function createAppPostRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
|
|
interface Headers {
|
|
timestamp: string
|
|
signature: string
|
|
}
|
|
|
|
const options: RouteShorthandOptions = {
|
|
schema: {
|
|
description: 'Create a Post (App).',
|
|
tags: ['post'],
|
|
headers: {
|
|
type: 'object',
|
|
properties: {
|
|
timestamp: { type: 'string' },
|
|
signature: { type: 'string' },
|
|
},
|
|
},
|
|
body: postBodySchema,
|
|
response: {
|
|
201: {
|
|
description: 'Post created.',
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string' },
|
|
},
|
|
},
|
|
400: errorSchema,
|
|
},
|
|
},
|
|
}
|
|
|
|
server.post<DefaultQuery, DefaultParams, Headers, PostBody>('/v1/app/post', options, async (request, reply) => {
|
|
if (!server.database) return serverError(reply)
|
|
|
|
const { timestamp, signature } = request.headers
|
|
|
|
if (!timestamp) return badRequestError(reply, '"timestamp" header required')
|
|
if (!signature) return badRequestError(reply, '"signature" header required')
|
|
|
|
const installationId = request.body.installation
|
|
|
|
const container = containerFor(server.database.client, 'Apps')
|
|
const installation = await getItem<Installation>({
|
|
container,
|
|
id: installationId,
|
|
partitionKey: INSTALLATION_PARTITION_KEY,
|
|
})
|
|
|
|
if (!installation) return badRequestError(reply, 'Installation not found')
|
|
|
|
const app = await getItem<App>({
|
|
container,
|
|
id: installation.appId,
|
|
partitionKey: APP_PARTITION_KEY,
|
|
})
|
|
|
|
if (!app) return serverError(reply)
|
|
|
|
if (createHmac('sha256', app.privateKey).update(installationId + timestamp).digest('hex') !== signature) {
|
|
return badRequestError(reply, 'Invalid signature')
|
|
}
|
|
|
|
reply.code(201)
|
|
return await createPost(server.database.client, installation.userId, app.id, request.body, reply, request.log)
|
|
})
|
|
}
|
|
|
|
function postsByUserRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
|
|
interface Params {
|
|
id: string
|
|
}
|
|
|
|
const options: RouteShorthandOptions = {
|
|
schema: {
|
|
description: 'Get a list of User Posts.',
|
|
tags: ['post'],
|
|
params: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string' },
|
|
},
|
|
},
|
|
response: {
|
|
200: {
|
|
description: 'Successful response.',
|
|
type: 'object',
|
|
properties: {
|
|
user: userSchema,
|
|
posts: {
|
|
type: 'array',
|
|
items: postSchema,
|
|
},
|
|
},
|
|
},
|
|
400: errorSchema,
|
|
},
|
|
},
|
|
}
|
|
|
|
server.get<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/v1/user/:id/posts', options, async (request, reply) => {
|
|
if (!server.database) return serverError(reply)
|
|
|
|
const id = normalize(request.params.id)
|
|
const userContainer = containerFor(server.database.client, 'Users')
|
|
const user = await getUser(server.database.client, id)
|
|
|
|
if (!user) return notFoundError(reply)
|
|
if (!user.groupId) return notFoundError(reply)
|
|
|
|
switch (user.privacy) {
|
|
case UserPrivacyType.Private:
|
|
return unauthorizedError(reply)
|
|
case UserPrivacyType.Subscribers: {
|
|
if (!request.viewer) return unauthorizedError(reply)
|
|
|
|
const subscriptions = await getApprovedSubscriptions(server.database.client, user.id, request.viewer.id, request.log)
|
|
if (subscriptions.length === 0) return unauthorizedError(reply)
|
|
|
|
break
|
|
}
|
|
case UserPrivacyType.Group: {
|
|
if (!request.viewer) return unauthorizedError(reply)
|
|
|
|
const viewer = await getItem<User>({ container: userContainer, id: request.viewer.id })
|
|
if (!viewer) return serverError(reply)
|
|
if (!userIsValid(viewer)) return forbiddenError(reply)
|
|
|
|
const subscriptions = await getApprovedSubscriptions(server.database.client, user.id, request.viewer.id, request.log)
|
|
if (viewer.groupId !== user.groupId && subscriptions.length === 0) return unauthorizedError(reply)
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
if (request.viewer) {
|
|
const viewer = await getItem<User>({ container: userContainer, id: request.viewer.id })
|
|
if (!viewer) return serverError(reply)
|
|
if (!userIsValid(viewer)) return forbiddenError(reply)
|
|
|
|
const blocks = await getUserBlocks(server.database.client, user.id, [viewer.id, viewer.groupId!], request.log)
|
|
if (blocks.length > 0) return unauthorizedError(reply)
|
|
}
|
|
|
|
const userPostsQuery = createQuerySpec(`SELECT p.id, p.postId FROM Users p WHERE p.pk = @user AND p.t = @type ORDER BY p.created DESC`, { user: id, type: UserItemType.Post })
|
|
const userPosts = await queryItems<UserPost>({
|
|
container: userContainer,
|
|
query: userPostsQuery,
|
|
logger: request.log,
|
|
})
|
|
|
|
const posts = await queryItems<Post>({
|
|
container: containerFor(server.database.client, 'Posts'),
|
|
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,
|
|
})
|
|
|
|
return {
|
|
user,
|
|
posts,
|
|
}
|
|
})
|
|
}
|
|
|
|
function timelineRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
|
|
interface Query {
|
|
continuation?: string
|
|
}
|
|
|
|
const options: RouteShorthandOptions = {
|
|
schema: {
|
|
description: 'Get the authenticated User Timeline.',
|
|
tags: ['post'],
|
|
querystring: {
|
|
type: 'object',
|
|
properties: {
|
|
continuation: { type: 'string' },
|
|
},
|
|
},
|
|
response: {
|
|
200: {
|
|
description: 'Successful response.',
|
|
type: 'object',
|
|
properties: {
|
|
posts: {
|
|
type: 'array',
|
|
items: postSchema,
|
|
},
|
|
continuation: { type: 'string' },
|
|
},
|
|
},
|
|
400: errorSchema,
|
|
},
|
|
},
|
|
}
|
|
|
|
server.get<Query, DefaultParams, DefaultHeaders, DefaultBody>('/v1/timeline', options, async (request, reply) => {
|
|
if (!server.database) return serverError(reply)
|
|
if (!request.viewer) return unauthorizedError(reply)
|
|
|
|
const userContainer = containerFor(server.database.client, 'Users')
|
|
const postContainer = containerFor(server.database.client, 'Posts')
|
|
|
|
const { resources: timelinePosts, requestCharge, continuation } = await userContainer.items.query<UserTimelinePost>(
|
|
createQuerySpec('SELECT * FROM Users u WHERE u.pk = @pk AND u.t = @type ORDER BY u.created', {
|
|
pk: request.viewer.id,
|
|
type: UserItemType.Timeline,
|
|
}),
|
|
{
|
|
maxItemCount: 40,
|
|
continuation: request.query.continuation,
|
|
}
|
|
).fetchAll()
|
|
|
|
request.log.trace('Query: %d', requestCharge)
|
|
|
|
const posts = await queryItems<Post>({
|
|
container: postContainer,
|
|
query: createQuerySpec('SELECT * FROM Posts p WHERE ARRAY_CONTAINS(@posts, p.id) ORDER BY p.created DESC', {
|
|
posts: timelinePosts.map(p => p.id),
|
|
}),
|
|
logger: request.log,
|
|
})
|
|
|
|
const users = await getUsers(server.database.client, posts.map(p => p.userId))
|
|
|
|
return {
|
|
posts: posts.map(post => ({
|
|
...post,
|
|
user: users.find(u => u.id === post.userId),
|
|
userId: undefined,
|
|
})),
|
|
continuation,
|
|
}
|
|
})
|
|
}
|
|
|
|
function postRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
|
|
interface Params {
|
|
id: string
|
|
}
|
|
|
|
const options: RouteShorthandOptions = {
|
|
schema: {
|
|
description: 'Get a Post.',
|
|
tags: ['post'],
|
|
params: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string' },
|
|
},
|
|
},
|
|
response: {
|
|
200: {
|
|
description: 'Successful response.',
|
|
type: 'object',
|
|
properties: {
|
|
post: postSchema,
|
|
parents: {
|
|
type: 'array',
|
|
items: postSchema,
|
|
},
|
|
children: {
|
|
type: 'array',
|
|
items: postSchema,
|
|
},
|
|
users: {
|
|
type: 'array',
|
|
items: userSchema,
|
|
}
|
|
},
|
|
},
|
|
400: errorSchema,
|
|
},
|
|
},
|
|
}
|
|
|
|
server.get<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/v1/post/:id', options, async (request, reply) => {
|
|
if (!server.database) return serverError(reply)
|
|
|
|
const postContainer = containerFor(server.database.client, 'Posts')
|
|
const post = await getItem<Post>({ container: postContainer, id: request.params.id })
|
|
if (!post) return notFoundError(reply)
|
|
|
|
const query = createQuerySpec('SELECT * FROM Ancestry a WHERE a.pk = @pk AND ARRAY_CONTAINS(a.parents, @id)', {
|
|
pk: post.root,
|
|
id: post.id,
|
|
})
|
|
|
|
const descendantRelationships = await queryItems<PostRelationship>({
|
|
container: containerFor(server.database.client, 'Ancestry'),
|
|
query,
|
|
logger: request.log,
|
|
})
|
|
|
|
const descendants = await queryItems<Post>({
|
|
container: postContainer,
|
|
query: createQuerySpec('SELECT * FROM Posts p WHERE ARRAY_CONTAINS(@descendants, p.id)', {
|
|
descendants: descendantRelationships.map(r => r.id),
|
|
}),
|
|
logger: request.log,
|
|
})
|
|
|
|
const ancestors = await queryItems<Post>({
|
|
container: postContainer,
|
|
query: createQuerySpec('SELECT * FROM Posts p WHERE ARRAY_CONTAINS(@parents, p.id)', {
|
|
parents: post.parents,
|
|
}),
|
|
logger: request.log,
|
|
})
|
|
|
|
const getUserId = (post: Post) => post.userId
|
|
|
|
const userIds = [
|
|
...descendants.map(getUserId),
|
|
...ancestors.map(getUserId),
|
|
getUserId(post),
|
|
]
|
|
|
|
const users = await getUsers(server.database.client, userIds, request.log)
|
|
|
|
if (request.viewer) {
|
|
const viewer = await getItem<User>({
|
|
container: containerFor(server.database.client, 'Users'),
|
|
id: request.viewer.id,
|
|
})
|
|
|
|
if (!viewer) return serverError(reply)
|
|
if (!userIsValid(viewer)) return forbiddenError(reply)
|
|
|
|
const blockQuery = createQuerySpec(`
|
|
SELECT g.userId FROM Groups g WHERE
|
|
g.pk = @viewerGroup AND
|
|
g.t = @type AND
|
|
(g.blockedId = @viewer OR g.blockedId = @viewerGroup) AND
|
|
ARRAY_CONTAINS(@ids, g.userId)
|
|
`, {
|
|
viewer: viewer.id,
|
|
viewerGroup: viewer.groupId!,
|
|
ids: userIds,
|
|
type: GroupItemType.Block,
|
|
})
|
|
|
|
const blocks = await queryItems<GroupBlock>({
|
|
container: containerFor(server.database.client, 'Groups'),
|
|
query: blockQuery,
|
|
logger: request.log,
|
|
})
|
|
|
|
const blockedUserIds = blocks.map(b => b.userId)
|
|
if (blockedUserIds.includes(post.userId)) return unauthorizedError(reply)
|
|
|
|
return {
|
|
post,
|
|
children: descendants.filter(p => !blockedUserIds.includes(p.userId)),
|
|
parents: ancestors.filter(p => !blockedUserIds.includes(p.userId)),
|
|
users: users.filter(u => !blockedUserIds.includes(u.id)),
|
|
}
|
|
}
|
|
|
|
return {
|
|
post,
|
|
children: descendants,
|
|
parents: ancestors,
|
|
users,
|
|
}
|
|
})
|
|
}
|
|
|
|
const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = async server => {
|
|
createPostRoute(server)
|
|
createAppPostRoute(server)
|
|
postsByUserRoute(server)
|
|
timelineRoute(server)
|
|
postRoute(server)
|
|
}
|
|
|
|
export default plugin
|