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