[ABANDONED] API server for Flexor social network.
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

// 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