|
|
import { FastifyInstance, Plugin, DefaultQuery, DefaultParams, RouteShorthandOptions, DefaultHeaders, DefaultBody, } from 'fastify'
import { Server, IncomingMessage, ServerResponse } from 'http'
import { unauthorizedError, serverError, notFoundError, badRequestError, badRequestFormError } from '../../lib/errors' import { getUserBlocks, getUser, getUserIdFromPhone, getUserIdFromEmail } from '../../lib/collections' import { containerFor, createQuerySpec, queryItems, getItem, normalize } from '../../lib/database' import { deleteMedia, attachMedia } from '../../lib/media'
import { MAX_NAME_LENGTH, USER_LISTING_PARTITION_KEY } from '../../constants' import { userSchema, selfSchema, errorSchema, userSettingsSchema } from '../../schemas'
import { User, UserSubscription, UserBlock, GroupBlock, UserPrivacyType, UserItemType, GroupItemType, BlockType, UserInverseSubscription, UserListing, UserSettings, } from '../../types/collections'
import { PluginOptions } from '../../types'
function availabilityRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) { interface Body { name: string }
const options: RouteShorthandOptions = { schema: { description: 'Check User ID availability.', tags: ['user'], body: { type: 'object', required: ['name'], properties: { name: { type: 'string', maxLength: MAX_NAME_LENGTH, }, }, }, response: { 200: { description: 'Successful response.', type: 'object', properties: { id: { type: 'string' }, available: { type: 'boolean' }, }, }, 400: errorSchema, }, }, }
server.post<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/v1/user/available', options, async (request, reply) => { if (!server.database) return serverError(reply)
const id = normalize(request.body.name)
const user = await getItem<User>({ container: containerFor(server.database.client, 'Users'), id, })
return { id, available: !user, } }) }
function updateRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) { interface Body { name?: string email?: string phone?: string about?: string requiresApproval?: boolean privacy?: UserPrivacyType imageUrl?: string coverImageUrl?: string theme?: string settings?: UserSettings }
const options: RouteShorthandOptions = { schema: { description: 'Update the authenticated User.', tags: ['user'], body: { type: 'object', properties: { name: { type: 'string', maxLength: MAX_NAME_LENGTH, }, email: { type: 'string', format: 'email', }, about: { type: 'string' }, requiresApproval: { type: 'boolean' }, privacy: { type: 'string', enum: ['public', 'group', 'subscribers', 'private'], }, imageUrl: { type: 'string' }, coverImageUrl: { type: 'string' }, theme: { type: 'string' }, settings: userSettingsSchema, }, }, response: { 200: selfSchema, 400: errorSchema, }, }, }
server.put<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/v1/self', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply)
const viewerItem = containerFor(server.database.client, 'Users').item(request.viewer.id, request.viewer.id) const { resource: viewer } = await viewerItem.read<User>()
if (!viewer) return serverError(reply)
const { name, email, phone, about, requiresApproval, privacy, imageUrl, coverImageUrl, theme, settings, } = request.body
if (name) viewer.name = name.trim() if (about) viewer.about = about.trim()
if (email) { const emailT = email.trim()
if (viewer.email !== emailT) { const id = await getUserIdFromEmail(server.database.client, emailT) if (id) return badRequestFormError(reply, 'email', 'Email address already used')
viewer.email = emailT viewer.emailVerified = false } }
if (phone) { const phoneT = phone.trim()
if (viewer.phone !== phoneT) { const id = await getUserIdFromPhone(server.database.client, phoneT) if (id) return badRequestFormError(reply, 'phone', 'Phone number already used')
viewer.phone = phoneT viewer.phoneVerified = false } }
if (requiresApproval !== undefined) viewer.requiresApproval = requiresApproval if (privacy) viewer.privacy = privacy
const mediaContainer = containerFor(server.database.client, 'Media')
if (viewer.imageUrl && !imageUrl) await deleteMedia(viewer.imageUrl) if (viewer.coverImageUrl && !coverImageUrl) await deleteMedia(viewer.coverImageUrl)
if (!viewer.imageUrl && imageUrl) await attachMedia(mediaContainer, imageUrl) if (!viewer.coverImageUrl && coverImageUrl) await attachMedia(mediaContainer, coverImageUrl)
if (imageUrl) viewer.imageUrl = imageUrl if (coverImageUrl) viewer.coverImageUrl = coverImageUrl if (theme) viewer.theme = theme if (settings) viewer.settings = settings
await viewerItem.replace<User>(viewer)
const listingItem = containerFor(server.database.client, 'Directory').item(request.viewer.id, USER_LISTING_PARTITION_KEY) const { resource: listing } = await listingItem.read<UserListing>()
if (listing) { if (email) listing.email = email.trim() if (phone) listing.phone = phone.trim()
await listingItem.replace<UserListing>(listing) }
return viewer }) }
function getRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) { interface Params { id: string }
interface Subscription { from: string to: string pending: boolean }
const options: RouteShorthandOptions = { schema: { description: 'Get a User.', tags: ['user'], params: { type: 'object', properties: { id: { type: 'string' }, }, }, response: { 200: userSchema, 400: errorSchema, }, }, }
server.get<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/v1/user/:id', options, async (request, reply) => { if (!server.database) return serverError(reply)
const userContainer = containerFor(server.database.client, 'Users') const user = await getUser(server.database.client, request.params.id) if (!user) return notFoundError(reply)
const subscriptions: Subscription[] = []
if (request.viewer && request.viewer.id !== user.id) { const viewer = await getItem<User>({ container: userContainer, id: request.viewer.id }) if (!viewer) return serverError(reply) if (!viewer.groupId) return unauthorizedError(reply)
const blocks = await getUserBlocks(server.database.client, user.id, [viewer.id, viewer.groupId], request.log) if (blocks.length > 0) return unauthorizedError(reply)
const subscription = (await queryItems<UserSubscription>({ container: userContainer, query: createQuerySpec('SELECT * FROM Users u WHERE u.pk = @pk AND u.userId = @userId AND u.t = @type', { userId: viewer.id, pk: user.id, type: UserItemType.Subscription, }), logger: request.log, }))[0]
if (subscription) { subscriptions.push({ from: subscription.userId, to: subscription.pk, pending: subscription.pending, }) }
const inverseSubscription = (await queryItems<UserInverseSubscription>({ container: userContainer, query: createQuerySpec('SELECT * FROM Users u WHERE u.pk = @pk AND u.userId = @userId AND u.t = @type', { userId: user.id, pk: viewer.id, type: UserItemType.InverseSubscription, }), logger: request.log, }))[0]
if (inverseSubscription) { subscriptions.push({ from: inverseSubscription.pk, to: inverseSubscription.userId, pending: inverseSubscription.pending, }) } }
return { ...user, subscriptions, } }) }
function subscribeRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) { interface Params { id: string }
const options: RouteShorthandOptions = { schema: { description: 'Subscribe to a User.', tags: ['user'], params: { type: 'object', properties: { id: { type: 'string' }, }, }, response: { 204: { description: 'Subscribed.', type: 'object', }, 400: errorSchema, }, }, }
server.post<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/v1/user/:id/subscribe', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply)
if (request.viewer.id === request.params.id) return badRequestError(reply)
const userContainer = containerFor(server.database.client, 'Users') const user = await getItem<User>({ container: userContainer, id: request.params.id }) const viewer = await getItem<User>({ container: userContainer, id: request.viewer.id })
if (!user) return notFoundError(reply) if (!viewer) return serverError(reply) if (!viewer.groupId) return unauthorizedError(reply)
const subscriptionQuery = createQuerySpec(`SELECT u.id FROM Users u WHERE u.id = @user AND u.pk = @viewer AND u.t = @type`, { user: user.id, viewer: viewer.id, type: UserItemType.Subscription, })
const subscriptions = await queryItems<UserSubscription>({ container: userContainer, query: subscriptionQuery, logger: request.log }) if (subscriptions.length > 0) return badRequestError(reply, 'Already subscribed') let pending = false
switch (user.privacy) { case UserPrivacyType.Private: return unauthorizedError(reply) case UserPrivacyType.Group: if (user.groupId !== viewer.groupId) return unauthorizedError(reply) case UserPrivacyType.Subscribers: pending = true break }
const blockQuery = createQuerySpec(`
SELECT g.id FROM Groups g WHERE g.pk = @viewerGroup AND g.t = @type AND g.userId = @user AND (g.blockedId = @viewer OR g.blockedId = @viewerGroup) `, {
user: user.id, viewerGroup: viewer.groupId, type: GroupItemType.Block, })
const blocks = await queryItems<GroupBlock>({ container: containerFor(server.database.client, 'Groups'), query: blockQuery, logger: request.log })
if (blocks.length > 0) return badRequestError(reply, 'Invalid operation')
await userContainer.items.create<UserSubscription>({ userId: request.viewer.id, pk: user.id, t: UserItemType.Subscription, pending, created: Date.now(), })
await userContainer.items.create<UserInverseSubscription>({ userId: user.id, pk: request.viewer.id, t: UserItemType.InverseSubscription, pending, created: Date.now(), })
reply.code(204) }) }
function unsubscribeRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) { interface Params { id: string }
const options: RouteShorthandOptions = { schema: { description: 'Unsubscribe from a User.', tags: ['user'], params: { type: 'object', properties: { id: { type: 'string' }, }, }, response: { 204: { description: 'Unsubscribed.', type: 'object', }, 400: errorSchema, }, }, }
server.post<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/v1/user/:id/unsubscribe', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply)
const userContainer = containerFor(server.database.client, 'Users') const user = await getItem<User>({ container: userContainer, id: request.params.id }) const viewer = await getItem<User>({ container: userContainer, id: request.viewer.id })
if (!user) return notFoundError(reply) if (!viewer) return serverError(reply)
const subscriptionQuery = createQuerySpec(`SELECT u.id, u.pk FROM Users u WHERE u.userId = @user AND u.pk = @viewer AND u.t = @type`, { user: user.id, viewer: viewer.id, type: UserItemType.Subscription, })
const subscriptions = await queryItems<UserSubscription>({ container: userContainer, query: subscriptionQuery, logger: request.log }) for (const subscription of subscriptions) { await userContainer.item(subscription.id!, subscription.pk).delete() }
const inverseSubscriptionQuery = createQuerySpec(`SELECT u.id FROM Users u WHERE u.userId = @viewer AND u.pk = @user AND u.t = @type`, { user: user.id, viewer: viewer.id, type: UserItemType.InverseSubscription, })
const inverseSubscriptions = await queryItems<UserInverseSubscription>({ container: userContainer, query: inverseSubscriptionQuery, logger: request.log }) for (const inverseSubscription of inverseSubscriptions) { await userContainer.item(inverseSubscription.id!, inverseSubscription.pk).delete() }
reply.code(204) }) }
function blockRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) { interface Params { id: string }
interface Body { description?: string }
const options: RouteShorthandOptions = { schema: { description: 'Block a User.', tags: ['user'], params: { type: 'object', properties: { id: { type: 'string' }, }, }, body: { type: 'object', properties: { description: { type: 'string' }, }, }, response: { 204: { description: 'User blocked.', type: 'object', }, 400: errorSchema, }, }, }
server.post<DefaultQuery, Params, DefaultHeaders, Body>('/v1/user/:id/block', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply)
const userContainer = containerFor(server.database.client, 'Users') const user = await getItem<User>({ container: userContainer, id: request.params.id }) if (!user) return notFoundError(reply) if (!user.groupId) return badRequestError(reply)
await userContainer.items.create<UserBlock>({ blockedId: user.id, pk: request.viewer.id, t: UserItemType.Block, blockType: BlockType.User, description: request.body.description, created: Date.now(), })
await containerFor(server.database.client, 'Groups').items.create<GroupBlock>({ pk: user.groupId, t: GroupItemType.Block, blockedId: user.id, userId: request.viewer.id, created: Date.now(), })
reply.code(204) }) }
function unblockRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) { interface Params { id: string }
const options: RouteShorthandOptions = { schema: { description: 'Unblock a User.', tags: ['user'], params: { type: 'object', properties: { id: { type: 'string' }, }, }, response: { 204: { description: 'User unblocked.', type: 'object', }, 400: errorSchema, }, }, }
server.post<DefaultQuery, Params, DefaultHeaders, Body>('/v1/user/:id/unblock', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply)
const userContainer = containerFor(server.database.client, 'Users') const groupContainer = containerFor(server.database.client, 'Groups')
const user = await getItem<User>({ container: userContainer, id: request.params.id }) if (!user) return notFoundError(reply) if (!user.groupId) return badRequestError(reply, 'Invalid operation')
const userBlockQuery = createQuerySpec(`SELECT u.id FROM Users u WHERE u.pk = @pk AND u.blockedId = @blocked AND u.t = @type`, { pk: request.viewer.id, blocked: user.id, type: UserItemType.Block, })
const userBlocks = await queryItems<UserBlock>({ container: userContainer, query: userBlockQuery, logger: request.log })
for (const userBlock of userBlocks) { await userContainer.item(userBlock.id!, request.viewer.id).delete() }
const groupBlockQuery = createQuerySpec( `SELECT g.id FROM Groups g WHERE g.pk = @pk AND u.blockedId = @blocked AND u.userId = @viewer AND u.t = @type`, { pk: user.groupId, blocked: user.id, viewer: request.viewer.id, type: GroupItemType.Block, } )
const groupBlocks = await queryItems<GroupBlock>({ container: groupContainer, query: groupBlockQuery, logger: request.log }) for (const groupBlock of groupBlocks) { await groupContainer.item(groupBlock.id!, user.groupId).delete() }
reply.code(204) }) }
const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = async server => { availabilityRoute(server) updateRoute(server) getRoute(server) subscribeRoute(server) unsubscribeRoute(server) blockRoute(server) unblockRoute(server) }
export default plugin
|