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

635 lines
21 KiB

// users.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,
RouteShorthandOptions,
DefaultHeaders,
DefaultBody,
} from 'fastify'
import { Server, IncomingMessage, ServerResponse } from 'http'
import { unauthorizedError, serverError, notFoundError, badRequestError, badRequestFormError, forbiddenError } from '../../lib/errors'
import { getUserBlocks, getUser, getUserIdFromPhone, getUserIdFromEmail, userIsValid } 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)
if (!userIsValid(viewer)) return forbiddenError(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 (!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 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, 'Cannot subscribe to self')
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 (!userIsValid(viewer)) return forbiddenError(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)
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