[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.
 

457 lines
15 KiB

import {
FastifyInstance,
Plugin,
DefaultQuery,
DefaultParams,
DefaultHeaders,
DefaultBody,
RouteShorthandOptions,
} from 'fastify'
import { Server, IncomingMessage, ServerResponse } from 'http'
import { SHORT_TEXT_LENGTH, SUBSCRIBER_MAX_SIZE } from '../../constants'
import { userSchema, postSchema, errorSchema } from '../../schemas'
import { unauthorizedError, serverError, badRequestError, badRequestFormError, notFoundError } from '../../lib/errors'
import { trimContent, createPostId } from '../../lib/util'
import { getUsers, getApprovedSubscriptions, getUserBlocks } from '../../lib/collections'
import { containerFor, createQuerySpec, queryItems, getItem, normalize } from '../../lib/database'
import {
Post,
PostAttachment,
User,
UserPost,
UserSubscription,
UserTimelinePost,
GroupBlock,
PostRelationship,
Status,
PostItemType,
UserItemType,
UserPrivacyType,
GroupItemType,
} 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
}
const options: RouteShorthandOptions = {
schema: {
body: {
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: ['imageUrl'],
properties: {
imageUrl: { type: 'string' },
caption: {
type: 'string',
maxLength: SHORT_TEXT_LENGTH,
},
cover: {
type: 'string',
maxLength: SHORT_TEXT_LENGTH,
},
},
},
},
parent: { type: 'string' },
},
},
response: {
200: {
type: 'object',
properties: {
id: { type: 'string' },
},
},
400: errorSchema,
},
},
}
server.post<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/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,
logger: request.log
})
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,
logger: request.log
})
if (!parent) return badRequestFormError(reply, 'parent', 'Invalid parent')
const parentRelationship = await getItem<PostRelationship>({
container: ancestryContainer,
id: request.body.parent,
partitionKey: parent.root,
logger: request.log
})
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: 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 userPost: UserPost = {
postId,
pk: request.viewer.id,
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: request.viewer.id, type: UserItemType.Subscription })
const subscribers = await queryItems<UserSubscription>({
container: userContainer,
query,
logger: request.log
})
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 postsByUserRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Params {
id: string
}
const options: RouteShorthandOptions = {
schema: {
params: {
type: 'object',
properties: {
id: { type: 'string' },
},
},
response: {
200: {
type: 'object',
properties: {
user: userSchema,
posts: {
type: 'array',
items: postSchema,
},
},
},
400: errorSchema,
},
},
}
server.get<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/api/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 getItem<User>({
container: userContainer,
id,
logger: request.log
})
if (!user) return notFoundError(reply)
if (!user.group) 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,
logger: request.log
})
if (!viewer) return serverError(reply)
if (!viewer.group) return unauthorizedError(reply)
const subscriptions = await getApprovedSubscriptions(server.database.client, user.id, request.viewer.id, request.log)
if (viewer.group.id !== user.group.id && subscriptions.length === 0) return unauthorizedError(reply)
break
}
}
if (request.viewer) {
const viewer = await getItem<User>({
container: userContainer,
id: request.viewer.id,
logger: request.log
})
if (!viewer) return serverError(reply)
if (!viewer.group) return unauthorizedError(reply)
const blocks = await getUserBlocks(server.database.client, user.id, [viewer.id, viewer.group.id], request.log)
if (blocks.length > 0) return unauthorizedError(reply)
}
const userPostsQuery = createQuerySpec(`SELECT p.id FROM Users p WHERE p.pk = @user AND p.t = @type`, { 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)', {
posts: userPosts.map(p => p.id!),
}),
logger: request.log
})
return {
user,
posts,
}
})
}
function postRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Params {
id: string
}
const options: RouteShorthandOptions = {
schema: {
params: {
type: 'object',
properties: {
id: { type: 'string' },
},
},
response: {
200: {
type: 'object',
properties: {
post: postSchema,
descendants: {
type: 'array',
items: postSchema,
},
ancestors: {
type: 'array',
items: postSchema,
},
users: {
type: 'array',
items: userSchema,
}
},
},
400: errorSchema,
},
},
}
server.get<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/api/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,
logger: request.log
})
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,
logger: request.log
})
if (!viewer) return serverError(reply)
if (!viewer.group) return unauthorizedError(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)
ARRAY_CONTAINS(@ids, g.userId)
`, {
viewer: viewer.id,
viewerGroup: viewer.group.id,
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,
descendants: descendants.filter(p => !blockedUserIds.includes(p.userId)),
ancestors: ancestors.filter(p => !blockedUserIds.includes(p.userId)),
users: users.filter(u => !blockedUserIds.includes(u.id)),
}
}
return {
post,
descendants,
ancestors,
users,
}
})
}
const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = async server => {
doPostRoute(server)
postsByUserRoute(server)
postRoute(server)
}
export default plugin