diff --git a/.vscode/settings.json b/.vscode/settings.json index 00ad71f..55712c1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "typescript.tsdk": "node_modules\\typescript\\lib" + "typescript.tsdk": "node_modules/typescript/lib" } \ No newline at end of file diff --git a/src/constants.ts b/src/constants.ts index d0a09f0..1b0305d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -5,7 +5,8 @@ export const MIN_PASSWORD_LENGTH = 8 export const SHORT_TEXT_LENGTH = 100 export const SUBSCRIBER_MAX_SIZE = 100 -export const GROUP_LISTING_PARTITION_KEY = 'pk' +export const USER_LISTING_PARTITION_KEY = 'upk' +export const GROUP_LISTING_PARTITION_KEY = 'gpk' export const APP_PARTITION_KEY = 'apk' export const INSTALLATION_PARTITION_KEY = 'ipk' export const MEDIA_PARTITION_KEY = 'pk' diff --git a/src/lib/collections.ts b/src/lib/collections.ts index 3ec9dba..1f1fa82 100644 --- a/src/lib/collections.ts +++ b/src/lib/collections.ts @@ -2,10 +2,11 @@ import { CosmosClient } from '@azure/cosmos' import { Logger } from 'fastify' import compact from 'lodash/compact' import uniq from 'lodash/uniq' -import { DatabaseItem } from '../types' import { containerFor, createQuerySpec, queryItems, getItem } from './database' -import { User, UserSubscription, UserBlock, Group, GroupMembership, UserItemType, GroupItemType } from '../types/collections' +import { USER_LISTING_PARTITION_KEY } from '../constants' +import { DatabaseItem } from '../types' +import { User, UserSubscription, UserBlock, Group, GroupMembership, UserItemType, GroupItemType, UserListing } from '../types/collections' export async function getUser(client: CosmosClient, id: string): Promise { const user = await getItem({ @@ -89,7 +90,7 @@ export async function getUserBlocks(client: CosmosClient, from: string, to: stri }) } -export async function getUserMembership(client: CosmosClient, userId: string, logger?: Logger): Promise { +export async function getUserMembership(client: CosmosClient, userId: string, logger?: Logger) { const user = await getItem({ container: containerFor(client, 'Users'), id: userId, @@ -117,3 +118,47 @@ export async function getUserMembership(client: CosmosClient, userId: string, lo if (memberships.length > 0) return memberships[0] } + +export async function getUserIdFromEmail(client: CosmosClient, email: string, logger?: Logger) { + const listings = await queryItems({ + container: containerFor(client, 'Directory'), + query: createQuerySpec('SELECT d.id FROM Directory d WHERE d.pk = @pk AND d.email = @email', { + pk: USER_LISTING_PARTITION_KEY, + email, + }), + logger, + }) + + const listing = listings[0] + if (listing) return listing.id +} + +export async function getUserIdFromPhone(client: CosmosClient, phone: string, logger?: Logger) { + const listings = await queryItems({ + container: containerFor(client, 'Directory'), + query: createQuerySpec('SELECT d.id FROM Directory d WHERE d.pk = @pk AND d.phone = @phone', { + pk: USER_LISTING_PARTITION_KEY, + phone, + }), + logger, + }) + + const listing = listings[0] + if (listing) return listing.id +} + +export function userIsValid(user?: User) { + if (!user) return false + if (user.pending) return false + if (!user.groupId) return false + if (!user.active) return false + + return true +} + +export async function userIdIsValid(client: CosmosClient, id: string) { + return userIsValid(await getItem({ + container: containerFor(client, 'Users'), + id, + })) +} diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 05f3e8f..f090d76 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -27,24 +27,20 @@ export function badRequestFormError(reply: FastifyReply, field: export function unauthorizedError(reply: FastifyReply): HttpError { reply.code(401) + return { message: 'Unauthorized' } +} - return { - message: 'Unauthorized', - } +export function forbiddenError(reply: FastifyReply): HttpError { + reply.code(403) + return { message: 'Forbidden' } } export function notFoundError(reply: FastifyReply): HttpError { reply.code(404) - - return { - message: 'Not Found', - } + return { message: 'Not Found' } } export function serverError(reply: FastifyReply, message: string = 'Server Error'): HttpError { reply.code(500) - - return { - message, - } + return { message } } diff --git a/src/plugins/api/apps.ts b/src/plugins/api/apps.ts index 4cc85f9..570dea3 100644 --- a/src/plugins/api/apps.ts +++ b/src/plugins/api/apps.ts @@ -12,7 +12,7 @@ import { Server, IncomingMessage, ServerResponse } from 'http' import pick from 'lodash/pick' import { appSchema, errorSchema } from '../../schemas' -import { getUsers } from '../../lib/collections' +import { getUsers, userIdIsValid, userIsValid } from '../../lib/collections' import { generateString } from '../../lib/crypto' import { containerFor, getItem, normalize, queryItems, createQuerySpec } from '../../lib/database' import { unauthorizedError, serverError, badRequestError, notFoundError } from '../../lib/errors' @@ -20,7 +20,6 @@ import { attachMedia, deleteMedia } from '../../lib/media' import { createInstallationId } from '../../lib/utils' import { APP_PARTITION_KEY, MAX_NAME_LENGTH, INSTALLATION_PARTITION_KEY } from '../../constants' - import { App, User, Installation, InstallationSettings } from '../../types/collections' import { PluginOptions } from '../../types' @@ -57,7 +56,7 @@ function availabilityRoute(server: FastifyInstance('/api/app/available', options, async (request, reply) => { + server.post('/v1/app/available', options, async (request, reply) => { if (!server.database) return serverError(reply) const id = normalize(request.body.name) @@ -109,7 +108,7 @@ function appsRoute(server: FastifyInstance('/api/apps', options, async (request, reply) => { + server.get('/v1/apps', options, async (request, reply) => { if (!server.database) return serverError(reply) const { sort = 'created', continuation } = request.query @@ -146,7 +145,7 @@ function selfAppsRoute(server: FastifyInstance('/api/self/apps', options, async (request, reply) => { + server.get('/v1/self/apps', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply) @@ -250,9 +249,10 @@ function createRoute(server: FastifyInstance('/api/app', options, async (request, reply) => { + server.post('/v1/app', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply) + if (!(await userIdIsValid(server.database.client, request.viewer.id))) return unauthorizedError(reply) const container = containerFor(server.database.client, 'Apps') const id = normalize(request.body.name) @@ -379,9 +379,10 @@ function updateRoute(server: FastifyInstance('/api/app/:id', options, async (request, reply) => { + server.put('/v1/app/:id', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply) + if (!(await userIdIsValid(server.database.client, request.viewer.id))) return unauthorizedError(reply) const container = containerFor(server.database.client, 'Apps') const mediaContainer = containerFor(server.database.client, 'Media') @@ -480,7 +481,7 @@ function getRoute(server: FastifyInstance('/api/app/:id', options, async (request, reply) => { + server.get('/v1/app/:id', options, async (request, reply) => { if (!server.database) return serverError(reply) const app = await getItem({ @@ -533,7 +534,7 @@ function installRoute(server: FastifyInstance('/api/app/:id/install', options, async (request, reply) => { + server.post('/v1/app/:id/install', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply) @@ -547,6 +548,7 @@ function installRoute(server: FastifyInstance() + if (!userIsValid(viewer)) return unauthorizedError(reply) const installations = await queryItems({ container: appContainer, @@ -619,7 +621,7 @@ function uninstallRoute(server: FastifyInstance('/api/app/:id/uninstall', options, async (request, reply) => { + server.post('/v1/app/:id/uninstall', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply) @@ -633,6 +635,7 @@ function uninstallRoute(server: FastifyInstance() + if (!userIsValid(viewer)) return unauthorizedError(reply) const installations = await queryItems({ container: appContainer, @@ -675,7 +678,7 @@ function installationsRoute(server: FastifyInstance('/api/installations', options, async (request, reply) => { + server.get('/v1/installations', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply) @@ -713,6 +716,7 @@ function installationsRoute(server: FastifyInstance('/api/app/:id/activate', options, async (request, reply) => { + server.post('/v1/app/:id/activate', options, async (request, reply) => { if (!server.database) return serverError(reply) if (request.headers.adminkey !== process.env.ADMIN_KEY) return serverError(reply) @@ -835,7 +839,7 @@ function setPreinstallRoute(server: FastifyInstance('/api/app/:id/preinstall', options, async (request, reply) => { + server.post('/v1/app/:id/preinstall', options, async (request, reply) => { if (!server.database) return serverError(reply) if (request.headers.adminkey !== process.env.ADMIN_KEY) return serverError(reply) @@ -883,7 +887,7 @@ function updateSettingsRoute(server: FastifyInstance('/api/installation/:id/settings', options, async (request, reply) => { + server.put('/v1/installation/:id/settings', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply) diff --git a/src/plugins/api/authentication.ts b/src/plugins/api/authentication.ts index 02227fc..3d50937 100644 --- a/src/plugins/api/authentication.ts +++ b/src/plugins/api/authentication.ts @@ -13,7 +13,7 @@ import { Server, IncomingMessage, ServerResponse } from 'http' import { MIN_ID_LENGTH, MAX_ID_LENGTH, MAX_NAME_LENGTH, MIN_PASSWORD_LENGTH, INSTALLATION_PARTITION_KEY } from '../../constants' import { tokenResponseSchema, selfSchema, errorSchema } from '../../schemas' import { createAccessToken, createRefreshToken } from '../../lib/authentication' -import { getUser } from '../../lib/collections' +import { getUser, getUserIdFromEmail, getUserIdFromPhone } from '../../lib/collections' import { hashPassword, comparePassword, JWT } from '../../lib/crypto' import { containerFor, getItem, queryItems, normalize, createQuerySpec } from '../../lib/database' import { badRequestError, badRequestFormError, unauthorizedError, serverError } from '../../lib/errors' @@ -46,17 +46,19 @@ function registerRoute(server: FastifyInstance('/api/register', options, async (request, reply) => { + server.post('/v1/register', options, async (request, reply) => { if (!server.database) return serverError(reply) - const { name, email, password, requiresApproval, privacy, about, imageUrl, coverImageUrl, theme, invitation: code } = request.body + const { name, email, password, requiresApproval, privacy, about, phone, imageUrl, coverImageUrl, theme, invitation: code, intro } = request.body const id = normalize(request.body.id) + const emailT = email.trim() + const phoneT = phone ? phone.trim() : undefined const userContainer = containerFor(server.database.client, 'Users') const groupContainer = containerFor(server.database.client, 'Groups') @@ -110,6 +116,14 @@ function registerRoute(server: FastifyInstance({ container: userContainer, id }) if (existingUser) return badRequestFormError(reply, 'id', 'User id already taken') + const emailListingId = await getUserIdFromEmail(server.database.client, emailT, request.log) + if (emailListingId) return badRequestFormError(reply, 'email', 'Email address already used') + + if (phoneT) { + const phoneListingId = await getUserIdFromPhone(server.database.client, phoneT, request.log) + if (phoneListingId) return badRequestFormError(reply, 'phone', 'Phone number already used') + } + let userPending = false let invitation: GroupInvitation | undefined let group: Group | undefined @@ -179,6 +193,8 @@ function registerRoute(server: FastifyInstance invitation.limit : true, }) } + + if (!userPending) { + const groupItem = groupContainer.item(group.id, group.id) + await groupItem.replace({ + ...group, + members: group.members + 1, + }) + } } const mediaContainer = containerFor(server.database.client, 'Media') @@ -268,7 +292,7 @@ function authenticateRoute(server: FastifyInstance('/api/authenticate', options, async (request, reply) => { + server.post('/v1/authenticate', options, async (request, reply) => { if (!server.database) return serverError(reply) const container = containerFor(server.database.client, 'Users') @@ -325,7 +349,7 @@ function refreshRoute(server: FastifyInstance('/api/refresh', options, async (request, reply) => { + server.post('/v1/refresh', options, async (request, reply) => { if (!server.database) return serverError(reply) const tokenString = tokenFromHeader(request.headers.authorization) @@ -376,7 +400,7 @@ function selfRoute(server: FastifyInstance('/api/self', options, async (request, reply) => { + server.get('/v1/self', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply) diff --git a/src/plugins/api/groups.ts b/src/plugins/api/groups.ts index 4084238..b95e803 100644 --- a/src/plugins/api/groups.ts +++ b/src/plugins/api/groups.ts @@ -9,12 +9,11 @@ import { } from 'fastify' import { Server, IncomingMessage, ServerResponse } from 'http' -import merge from 'lodash/merge' import { MIN_ID_LENGTH, MAX_NAME_LENGTH, GROUP_LISTING_PARTITION_KEY } from '../../constants' -import { errorSchema, groupListingSchema, userSchema } from '../../schemas' -import { unauthorizedError, badRequestError, notFoundError, serverError } from '../../lib/errors' -import { getUsers, getUserMembership } from '../../lib/collections' +import { errorSchema, groupSchema, userSchema } from '../../schemas' +import { unauthorizedError, badRequestError, notFoundError, serverError, forbiddenError } from '../../lib/errors' +import { getUsers, getUserMembership, getUser, userIsValid } from '../../lib/collections' import { containerFor, createQuerySpec, queryItems, getItem, normalize } from '../../lib/database' import { createInvitationCode } from '../../lib/utils' import { attachMedia, deleteMedia } from '../../lib/media' @@ -71,7 +70,7 @@ function availabilityRoute(server: FastifyInstance('/api/group/available', options, async (request, reply) => { + server.post('/v1/group/available', options, async (request, reply) => { if (!server.database) return serverError(reply) const id = normalize(request.body.name) @@ -136,7 +135,7 @@ function createRoute(server: FastifyInstance('/api/group', options, async (request, reply) => { + server.post('/v1/group', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply) @@ -161,6 +160,9 @@ function createRoute(server: FastifyInstance('/api/group/:id', options, async (request, reply) => { + server.get('/v1/group/:id', options, async (request, reply) => { if (!server.database) return serverError(reply) - const groupContainer = containerFor(server.database.client, 'Groups') - - const listing = await getItem({ - container: containerFor(server.database.client, 'GroupDirectory'), - id: request.params.id, - partitionKey: 'pk', - }) + const container = containerFor(server.database.client, 'Groups') const group = await getItem({ - container: groupContainer, + container, id: request.params.id, }) - const combine = async (group: Group, listing: GroupListing) => { - if (request.viewer) { - const memberships = await queryItems({ - container: groupContainer, - query: createQuerySpec( - ` - SELECT * FROM Groups g WHERE - g.pk = @pk AND - g.t = @type AND - g.userId = @userId AND - g.pending = false - `, - { - pk: group.id, - type: GroupItemType.Membership, - userId: request.viewer.id, - } - ), - logger: request.log, - }) - - if (memberships.length > 0) { - return merge(group, listing, { - membership: memberships[0].membership, - }) + if (!group) return notFoundError(reply) + + if (request.viewer) { + const memberships = await queryItems({ + container, + query: createQuerySpec( + ` + SELECT * FROM Groups g WHERE + g.pk = @pk AND + g.t = @type AND + g.userId = @userId AND + g.pending = false + `, + { + pk: group.id, + type: GroupItemType.Membership, + userId: request.viewer.id, + } + ), + logger: request.log, + }) + + if (memberships.length > 0) { + return { + ...group, + membership: memberships[0].membership, } } - - return merge(group, listing) } - if (!group || !listing) return notFoundError(reply) - return combine(group, listing) + return group }) } @@ -328,7 +322,7 @@ function updateRoute(server: FastifyInstance('/api/group/:id', options, async (request, reply) => { + server.put('/v1/group/:id', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply) @@ -336,14 +330,14 @@ function updateRoute(server: FastifyInstance() if (!group) return notFoundError(reply) - const { resource: groupListing } = await groupListingItem.read() + const { resource: listing } = await listingItem.read() const { name, @@ -365,35 +359,19 @@ function updateRoute(server: FastifyInstance(group) - await groupItem.replace({ - ...group, - ...updates, - }) - - if (groupListing) { - await groupListingItem.replace({ - ...groupListing, - ...updates, - }) + if (listing) { + if (registration) listing.registration = registration as GroupRegistrationType + await listingItem.replace(listing) } await groupContainer.items.create({ @@ -443,7 +421,7 @@ function blockRoute(server: FastifyInstance('/api/group/:id/block', options, async (request, reply) => { + server.post('/v1/group/:id/block', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply) @@ -497,7 +475,7 @@ function unblockRoute(server: FastifyInstance('/api/group/:id/unblock', options, async (request, reply) => { + server.post('/v1/group/:id/unblock', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply) @@ -583,7 +561,7 @@ function activateRoute(server: FastifyInstance('/api/group/:id/activate', options, async (request, reply) => { + server.post('/v1/group/:id/activate', options, async (request, reply) => { if (!server.database) return serverError(reply) if (request.headers.adminkey !== process.env.ADMIN_KEY) return serverError(reply) @@ -603,14 +581,13 @@ function activateRoute(server: FastifyInstance() if (!listing) { await directoryContainer.items.create({ id: group.id, - name: group.name, pk: GROUP_LISTING_PARTITION_KEY, registration: group.registration, members: 1, @@ -656,7 +633,7 @@ function listRoute(server: FastifyInstance('/api/groups', options, async (request, reply) => { + server.get('/v1/groups', options, async (request, reply) => { if (!server.database) return serverError(reply) const { sort = 'members', registration, continuation } = request.query let registrationString = '' - if (registration) { - registrationString = `AND d.registration = '${registration}'` - } + if (registration) registrationString = `AND d.registration = '${registration}'` - const container = containerFor(server.database.client, 'GroupDirectory') - const { resources: groups, requestCharge, continuation: newContinuation } = await container.items.query( - `SELECT * FROM GroupDirectory d WHERE d.pk = '${GROUP_LISTING_PARTITION_KEY}' ${registrationString} ORDER BY d.${sort}`, + const directoryContainer = containerFor(server.database.client, 'Directory') + + const { resources: groups, requestCharge, continuation: newContinuation } = await directoryContainer.items.query( + `SELECT d.id FROM Directory d WHERE d.pk = '${GROUP_LISTING_PARTITION_KEY}' ${registrationString} ORDER BY d.${sort}`, { maxItemCount: 40, continuation, @@ -687,7 +663,14 @@ function listRoute(server: FastifyInstance({ + container: containerFor(server.database.client, 'Groups'), + query: createQuerySpec( + 'SELECT * FROM Groups g WHERE ARRAY_CONTAINS(@ids, g.id)', + { ids: groups.map(g => g.id) } + ), + logger: request.log, + }), continuation: newContinuation, } }) @@ -730,7 +713,7 @@ function membersRoute(server: FastifyInstance('/api/group/:id/members', options, async (request, reply) => { + server.get('/v1/group/:id/members', options, async (request, reply) => { if (!server.database) return serverError(reply) const groupContainer = containerFor(server.database.client, 'Groups') @@ -745,8 +728,7 @@ function membersRoute(server: FastifyInstance( + const { resources: memberships, requestCharge, continuation: newContinuation } = await groupContainer.items.query( `SELECT g.userId, g.membership FROM Groups g WHERE g.pk = '${group.id}' AND g.t = '${GroupItemType.Membership}' ${typeString} ORDER BY g.created DESC`, { maxItemCount: 100, @@ -801,7 +783,7 @@ function createInvitationRoute(server: FastifyInstance('/api/group/:id/invitation', options, async (request, reply) => { + server.post('/v1/group/:id/invitation', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply) @@ -885,7 +867,7 @@ function invitationsRoute(server: FastifyInstance('/api/group/:id/invitations', options, async (request, reply) => { + server.get('/v1/group/:id/invitations', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply) @@ -966,7 +948,7 @@ function logsRoute(server: FastifyInstance('/api/group/:id/logs', options, async (request, reply) => { + server.get('/v1/group/:id/logs', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply) @@ -1003,6 +985,177 @@ function logsRoute(server: FastifyInstance) { + interface Query { + continuation?: string + } + + const options: RouteShorthandOptions = { + schema: { + description: 'Get pending Group members.', + tags: ['group', 'user'], + querystring: { + type: 'object', + properties: { + continuation: { type: 'string' }, + }, + }, + response: { + 200: { + description: 'Successful response.', + type: 'object', + properties: { + users: { + type: 'array', + items: userSchema, + }, + continuation: { type: 'string' }, + } + }, + 400: errorSchema, + } + }, + } + + server.get('/v1/group/members/pending', options, async (request, reply) => { + if (!server.database) return serverError(reply) + if (!request.viewer) return unauthorizedError(reply) + + const viewer = await getUser(server.database.client, request.viewer.id) + if (!viewer) return unauthorizedError(reply) + if (!viewer.group) return badRequestError(reply) + + const membership = await getUserMembership(server.database.client, viewer.id, request.log) + if (!membership) return serverError(reply) + if (membership.membership !== GroupMembershipType.Admin) return forbiddenError(reply) + + const groupContainer = containerFor(server.database.client, 'Groups') + const { resources: memberships, requestCharge, continuation: newContinuation } = await groupContainer.items.query( + `SELECT g.userId, g.intro FROM Groups g WHERE g.pk = '${viewer.group.id}' AND g.t = '${GroupItemType.Membership}' AND g.pending = true ORDER BY g.created DESC`, + { + maxItemCount: 100, + continuation: request.query.continuation, + } + ).fetchAll() + + request.log.trace('Query: %d', requestCharge) + const users = await getUsers(server.database.client, memberships.map(membership => membership.userId), request.log) + + return { + users: users.map(user => { + const m = memberships.find(membership => membership.userId === user.id) + return { + ...user, + intro: m ? m.intro : undefined, + } + }), + continuation: newContinuation, + } + }) +} + +function approveMemberRoute(server: FastifyInstance) { + interface Params { + id: string + } + + const options: RouteShorthandOptions = { + schema: { + description: 'Approve a pending Group member.', + tags: ['group', 'user'], + params: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + }, + response: { + 200: { + description: 'Member approved.', + type: 'object', + }, + 400: errorSchema, + }, + }, + } + + server.post('/v1/group/member/:id/approve', options, async (request, reply) => { + if (!server.database) return serverError(reply) + if (!request.viewer) return unauthorizedError(reply) + + const viewer = await getUser(server.database.client, request.viewer.id) + if (!viewer) return unauthorizedError(reply) + if (!viewer.group) return badRequestError(reply) + + const viewerMembership = await getUserMembership(server.database.client, viewer.id, request.log) + if (!viewerMembership) return serverError(reply) + if (viewerMembership.membership !== GroupMembershipType.Admin) return forbiddenError(reply) + + const membership = await getUserMembership(server.database.client, request.params.id, request.log) + if (!membership) return notFoundError(reply) + if (!membership.pending) return badRequestError(reply) + + const groupContainer = containerFor(server.database.client, 'Groups') + const groupItem = groupContainer.item(viewer.group.id, viewer.group.id) + const membershipItem = groupContainer.item(membership.id!, membership.pk) + + await groupItem.replace({ + ...viewer.group, + members: viewer.group.members + 1, + }) + + await membershipItem.replace({ + ...membership, + pending: false, + }) + }) +} + +function rejectMemberRoute(server: FastifyInstance) { + interface Params { + id: string + } + + const options: RouteShorthandOptions = { + schema: { + description: 'Reject a pending Group member.', + tags: ['group', 'user'], + params: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + }, + response: { + 200: { + description: 'Member rejected.', + type: 'object', + }, + 400: errorSchema, + }, + }, + } + + server.post('/v1/group/member/:id/reject', options, async (request, reply) => { + if (!server.database) return serverError(reply) + if (!request.viewer) return unauthorizedError(reply) + + const viewer = await getUser(server.database.client, request.viewer.id) + if (!viewer) return unauthorizedError(reply) + if (!viewer.group) return badRequestError(reply) + + const viewerMembership = await getUserMembership(server.database.client, viewer.id, request.log) + if (!viewerMembership) return serverError(reply) + if (viewerMembership.membership !== GroupMembershipType.Admin) return forbiddenError(reply) + + const membership = await getUserMembership(server.database.client, request.params.id, request.log) + if (!membership) return notFoundError(reply) + if (!membership.pending) return badRequestError(reply) + + // TODO + }) +} + const plugin: Plugin = async server => { availabilityRoute(server) createRoute(server) @@ -1016,6 +1169,9 @@ const plugin: Plugin = a createInvitationRoute(server) invitationsRoute(server) logsRoute(server) + pendingMembersRoute(server) + approveMemberRoute(server) + rejectMemberRoute(server) } export default plugin diff --git a/src/plugins/api/media.ts b/src/plugins/api/media.ts index e147cf3..6f26497 100644 --- a/src/plugins/api/media.ts +++ b/src/plugins/api/media.ts @@ -37,7 +37,7 @@ function getSASRoute(server: FastifyInstance('/api/sas', options, async () => { + server.get('/v1/sas', options, async () => { return { sas: generateSAS('arcw', 5), id: createId(), @@ -77,7 +77,7 @@ function addRoute(server: FastifyInstance('/api/media', options, async (request, reply) => { + server.post('/v1/media', options, async (request, reply) => { if (!server.database) return serverError(reply) const { name, size, type, originalName } = request.body @@ -131,7 +131,7 @@ function deleteRoute(server: FastifyInstance('/api/media/delete', options, async (request, reply) => { + server.post('/v1/media/delete', options, async (request, reply) => { if (!server.database) return serverError(reply) const mediaItem = containerFor(server.database.client, 'Media').item(request.body.name, MEDIA_PARTITION_KEY) diff --git a/src/plugins/api/posts.ts b/src/plugins/api/posts.ts index 914d7b2..6403453 100644 --- a/src/plugins/api/posts.ts +++ b/src/plugins/api/posts.ts @@ -16,9 +16,9 @@ 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 } from '../../lib/errors' +import { unauthorizedError, serverError, badRequestError, badRequestFormError, notFoundError, forbiddenError } from '../../lib/errors' import { trimContent, createPostId } from '../../lib/utils' -import { getUsers, getApprovedSubscriptions, getUserBlocks, getUser } from '../../lib/collections' +import { getUsers, getApprovedSubscriptions, getUserBlocks, getUser, userIsValid } from '../../lib/collections' import { containerFor, createQuerySpec, queryItems, getItem, normalize } from '../../lib/database' import { @@ -109,8 +109,7 @@ async function createPost(client: CosmosClient, userId: string, appId: string, b const viewer = await getItem({ container: userContainer, id: userId }) if (!viewer) return serverError(reply) - if (viewer.pending) return badRequestError(reply, 'User requires approval') - if (!viewer.groupId) return badRequestError(reply, 'User must belong to a group') + if (!userIsValid(viewer)) return forbiddenError(reply) const postId = createPostId() @@ -190,6 +189,14 @@ async function createPost(client: CosmosClient, userId: string, appId: string, b }) } + const viewerItem = userContainer.item(viewer.id, viewer.id) + await viewerItem.replace({ + ...viewer, + posts: viewer.posts + 1, + }) + + // TODO: Figure out how to update Group effeciently + return { id: postId, } @@ -214,7 +221,7 @@ function createPostRoute(server: FastifyInstance('/api/post', options, async (request, reply) => { + server.post('/v1/post', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply) @@ -274,7 +281,7 @@ function createAppPostRoute(server: FastifyInstance('/api/app/post', options, async (request, reply) => { + server.post('/v1/app/post', options, async (request, reply) => { if (!server.database) return serverError(reply) const { timestamp, signature } = request.headers @@ -342,7 +349,7 @@ function postsByUserRoute(server: FastifyInstance('/api/user/:id/posts', options, async (request, reply) => { + server.get('/v1/user/:id/posts', options, async (request, reply) => { if (!server.database) return serverError(reply) const id = normalize(request.params.id) @@ -440,7 +447,7 @@ function timelineRoute(server: FastifyInstance('/api/timeline', options, async (request, reply) => { + server.get('/v1/timeline', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply) @@ -521,7 +528,7 @@ function postRoute(server: FastifyInstance('/api/post/:id', options, async (request, reply) => { + server.get('/v1/post/:id', options, async (request, reply) => { if (!server.database) return serverError(reply) const postContainer = containerFor(server.database.client, 'Posts') diff --git a/src/plugins/api/users.ts b/src/plugins/api/users.ts index 89246fd..8a8cb0f 100644 --- a/src/plugins/api/users.ts +++ b/src/plugins/api/users.ts @@ -10,12 +10,12 @@ import { import { Server, IncomingMessage, ServerResponse } from 'http' -import { unauthorizedError, serverError, notFoundError, badRequestError } from '../../lib/errors' -import { getUserBlocks, getUser } from '../../lib/collections' +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 } from '../../constants' +import { MAX_NAME_LENGTH, USER_LISTING_PARTITION_KEY } from '../../constants' import { userSchema, selfSchema, errorSchema, userSettingsSchema } from '../../schemas' import { @@ -28,6 +28,7 @@ import { GroupItemType, BlockType, UserInverseSubscription, + UserListing, UserSettings, } from '../../types/collections' @@ -66,7 +67,7 @@ function availabilityRoute(server: FastifyInstance('/api/user/available', options, async (request, reply) => { + server.post('/v1/user/available', options, async (request, reply) => { if (!server.database) return serverError(reply) const id = normalize(request.body.name) @@ -86,6 +87,8 @@ function availabilityRoute(server: FastifyInstance) { interface Body { name?: string + email?: string + phone?: string about?: string requiresApproval?: boolean privacy?: UserPrivacyType @@ -106,6 +109,10 @@ function updateRoute(server: FastifyInstance('/api/self', options, async (request, reply) => { + server.put('/v1/self', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply) @@ -134,33 +141,74 @@ function updateRoute(server: FastifyInstance(viewer) + + const listingItem = containerFor(server.database.client, 'Directory').item(request.viewer.id, USER_LISTING_PARTITION_KEY) + const { resource: listing } = await listingItem.read() + + if (listing) { + if (email) listing.email = email.trim() + if (phone) listing.phone = phone.trim() + + await listingItem.replace(listing) + } + return viewer }) } @@ -193,7 +241,7 @@ function getRoute(server: FastifyInstance('/api/user/:id', options, async (request, reply) => { + server.get('/v1/user/:id', options, async (request, reply) => { if (!server.database) return serverError(reply) const userContainer = containerFor(server.database.client, 'Users') @@ -279,7 +327,7 @@ function subscribeRoute(server: FastifyInstance('/api/user/:id/subscribe', options, async (request, reply) => { + server.post('/v1/user/:id/subscribe', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply) @@ -379,7 +427,7 @@ function unsubscribeRoute(server: FastifyInstance('/api/user/:id/unsubscribe', options, async (request, reply) => { + server.post('/v1/user/:id/unsubscribe', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply) @@ -451,7 +499,7 @@ function blockRoute(server: FastifyInstance('/api/user/:id/block', options, async (request, reply) => { + server.post('/v1/user/:id/block', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply) @@ -506,7 +554,7 @@ function unblockRoute(server: FastifyInstance('/api/user/:id/unblock', options, async (request, reply) => { + server.post('/v1/user/:id/unblock', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply) diff --git a/src/schemas.ts b/src/schemas.ts index 2837006..d8adb1b 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -20,7 +20,7 @@ export const userSettingsSchema: JSONSchema = { }, } -export const groupListingSchema: JSONSchema = { +export const groupSchema: JSONSchema = { description: 'Group entity.', type: 'object', properties: { @@ -54,7 +54,7 @@ export const userSchema: JSONSchema = { type: 'object', properties: { id: { type: 'string' }, - group: groupListingSchema, + group: groupSchema, name: { type: 'string' }, about: { type: 'string' }, imageUrl: { type: 'string' }, @@ -65,6 +65,7 @@ export const userSchema: JSONSchema = { items: subscriptionSchema, }, membership: { type: 'string' }, + intro: { type: 'string' }, posts: { type: 'number' }, points: { type: 'number' }, created: { type: 'number' }, @@ -141,7 +142,7 @@ export const selfSchema: JSONSchema = { type: 'object', properties: { id: { type: 'string' }, - group: groupListingSchema, + group: groupSchema, name: { type: 'string' }, email: { type: 'string' }, about: { type: 'string' }, diff --git a/src/types/collections.ts b/src/types/collections.ts index c8b5331..0f64f45 100644 --- a/src/types/collections.ts +++ b/src/types/collections.ts @@ -4,15 +4,15 @@ // - Partition Key: pk (userId) // Groups // - Partition Key: pk (groupId) -// GroupDirectory +// Directory // - Partition Key: pk // Posts // - Partition Key: pk (postId) // Ancestry // - Partition Key: pk (postId) -// Points: total reward value + likes import { + USER_LISTING_PARTITION_KEY, GROUP_LISTING_PARTITION_KEY, APP_PARTITION_KEY, INSTALLATION_PARTITION_KEY, @@ -74,11 +74,19 @@ export enum BlockType { Group = 'group', } +export interface UserListing { + id: string + pk: typeof USER_LISTING_PARTITION_KEY + email: string + phone: string + posts: number + points: number + created: number +} + export interface GroupListing { id: string pk: typeof GROUP_LISTING_PARTITION_KEY - name: string - about?: string registration: GroupRegistrationType members: number posts: number @@ -99,6 +107,9 @@ export interface Group { iconImageUrl?: string theme: string registration: GroupRegistrationType + members: number + posts: number + points: number status: GroupStatus active: boolean created: number @@ -111,6 +122,7 @@ export interface GroupMembership { userId: string pending: boolean membership: GroupMembershipType + intro?: string invitation?: string created: number } @@ -175,6 +187,8 @@ export interface User { theme: string email: string emailVerified: boolean + phone?: string + phoneVerified: boolean passwordHash: string settings: UserSettings installations: string[] @@ -186,7 +200,6 @@ export interface User { pending: boolean requiresApproval: boolean privacy: UserPrivacyType - paid: boolean active: boolean created: number // Timestamp }